diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6284ff67..761b2165 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,6 +36,23 @@ jobs: main-branch-name: main - name: Run Checks run: pnpm run test:pr + delivery: + name: Delivery (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Setup Tools + uses: TanStack/config/.github/setup@b313637fa7d314532b98638f6b57b7b9c169d390 # main + - name: Run Managed Link Tests + run: pnpm --filter @tanstack/intent run test:delivery preview: name: Preview runs-on: ubuntu-latest diff --git a/benchmarks/intent/catalog.bench.ts b/benchmarks/intent/catalog.bench.ts new file mode 100644 index 00000000..f947f9ff --- /dev/null +++ b/benchmarks/intent/catalog.bench.ts @@ -0,0 +1,107 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { scanForIntents } from '../../packages/intent/src/discovery/scanner.js' +import { buildCurrentLockfileSources } from '../../packages/intent/src/core/lockfile/lockfile-state.js' +import { writeIntentLockfile } from '../../packages/intent/src/core/lockfile/lockfile.js' +import { + createCliRunner, + createConsoleSilencer, + createTempDir, + writeFile, + writeJson, + writePackage, +} from './helpers.js' + +const consoleSilencer = createConsoleSilencer() +const root = createTempDir('catalog') +const runner = createCliRunner({ cwd: root }) + +const PACKAGES = [ + { + name: '@bench/query', + skills: [ + 'queries', + 'mutations', + 'invalidation', + 'prefetching', + 'suspense', + 'pagination', + 'optimistic-updates', + 'ssr-hydration', + ], + }, + { + name: '@bench/router', + skills: [ + 'routing', + 'loaders', + 'search-params', + 'navigation', + 'code-splitting', + 'route-masking', + 'not-found', + ], + }, + { + name: '@bench/table', + skills: ['columns', 'sorting', 'filtering', 'grouping', 'virtualization'], + }, +] + +let getIntentCatalogContext: (options: { + cwd: string + refresh?: boolean +}) => Promise + +beforeAll(async () => { + consoleSilencer.silence() + writeJson(join(root, 'package.json'), { + name: 'intent-catalog-benchmark', + private: true, + intent: { skills: ['@bench/*'] }, + dependencies: Object.fromEntries( + PACKAGES.map((pkg) => [pkg.name, '1.0.0']), + ), + }) + writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: "9.0"\n') + for (const pkg of PACKAGES) { + writePackage(join(root, 'node_modules'), pkg.name, '1.0.0', { + skills: pkg.skills, + }) + } + + // Without a lockfile the catalogue skips verification entirely, so the warm + // path would measure an empty loop instead of the per-skill hashing it runs + // on every session start. + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: buildCurrentLockfileSources( + scanForIntents(root, { scope: 'local' }).packages, + ), + }) + + await runner.setup() + const catalog = await import('../../packages/intent/dist/catalog.mjs') + getIntentCatalogContext = catalog.getIntentCatalogContext +}) + +afterAll(() => { + runner.teardown() + rmSync(root, { recursive: true, force: true }) + consoleSilencer.restore() +}) + +describe('intent catalog', () => { + bench('cold catalogue generation through API', async () => { + await getIntentCatalogContext({ cwd: root, refresh: true }) + }) + + bench('warm cached catalogue retrieval through API', async () => { + await getIntentCatalogContext({ cwd: root }) + }) + + bench('warm cached catalogue retrieval through CLI', async () => { + await runner.run(['catalog', '--json']) + }) +}) diff --git a/benchmarks/intent/install-plan.bench.ts b/benchmarks/intent/install-plan.bench.ts new file mode 100644 index 00000000..b8615df5 --- /dev/null +++ b/benchmarks/intent/install-plan.bench.ts @@ -0,0 +1,47 @@ +import { bench, describe } from 'vitest' +import { updateIntentConsumerConfigText } from '../../packages/intent/src/commands/install/config.js' +import { buildSkillSelectionPlan } from '../../packages/intent/src/commands/install/plan.js' +import type { IntentPackage } from '../../packages/intent/src/shared/types.js' + +const packages: Array = Array.from( + { length: 20 }, + (_, packageIndex) => ({ + name: `@bench/package-${String(packageIndex).padStart(2, '0')}`, + version: '1.0.0', + kind: 'npm', + source: 'local', + packageRoot: `node_modules/@bench/package-${packageIndex}`, + intent: { version: 1, repo: 'bench/packages', docs: 'docs/' }, + skills: Array.from({ length: 5 }, (_, skillIndex) => ({ + name: `skill-${skillIndex}`, + path: `skills/skill-${skillIndex}/SKILL.md`, + description: `Skill ${skillIndex}`, + })), + }), +) + +const packageJson = `${JSON.stringify( + { + name: 'install-plan-benchmark', + private: true, + intent: { skills: [], exclude: [] }, + }, + null, + 2, +)}\n` + +const selection = buildSkillSelectionPlan(packages, { mode: 'all-found' }) + +describe('installer planning', () => { + bench('plans 100 discovered skills', () => { + buildSkillSelectionPlan(packages, { mode: 'all-found' }) + }) + + bench('updates consumer JSONC configuration', () => { + updateIntentConsumerConfigText(packageJson, { + skills: selection.skills, + exclude: selection.exclude, + install: { method: 'symlink', targets: ['agents'] }, + }) + }) +}) diff --git a/benchmarks/intent/lockfile-hash.bench.ts b/benchmarks/intent/lockfile-hash.bench.ts new file mode 100644 index 00000000..58d79949 --- /dev/null +++ b/benchmarks/intent/lockfile-hash.bench.ts @@ -0,0 +1,25 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { computeSkillContentHash } from '../../packages/intent/src/core/lockfile/hash.js' +import { createTempDir, writeFile } from './helpers.js' + +const root = createTempDir('lockfile-hash') +const skillDir = join(root, 'skills', 'representative') + +beforeAll(() => { + writeFile(join(skillDir, 'SKILL.md'), '# Guidance\n'.repeat(200)) + writeFile(join(skillDir, 'references', 'api.md'), '# API\n'.repeat(200)) + writeFile(join(skillDir, 'assets', 'example.json'), '{"enabled":true}\n') + writeFile(join(skillDir, 'scripts', 'check.mjs'), 'process.exit(0)\n') +}) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('per-skill lock hashing', () => { + bench('hashes a representative skill folder', () => { + computeSkillContentHash({ packageRoot: root, skillDir }) + }) +}) diff --git a/benchmarks/intent/sync.bench.ts b/benchmarks/intent/sync.bench.ts new file mode 100644 index 00000000..9245aa1f --- /dev/null +++ b/benchmarks/intent/sync.bench.ts @@ -0,0 +1,77 @@ +import { bench, describe } from 'vitest' +import { buildInstallDeltaInventory } from '../../packages/intent/src/commands/install/plan.js' +import { createSyncAliases } from '../../packages/intent/src/commands/sync/targets.js' +import type { IntentLockfileSource } from '../../packages/intent/src/core/lockfile/lockfile.js' +import type { IntentConsumerConfig } from '../../packages/intent/src/commands/install/config.js' +import type { IntentPackage } from '../../packages/intent/src/shared/types.js' + +const packages: Array = Array.from( + { length: 20 }, + (_, packageIndex) => ({ + name: `@bench/package-${String(packageIndex).padStart(2, '0')}`, + version: '1.0.0', + kind: 'npm', + source: 'local', + packageRoot: `node_modules/@bench/package-${packageIndex}`, + intent: { version: 1, repo: 'bench/packages', docs: 'docs/' }, + skills: Array.from({ length: 5 }, (_, skillIndex) => ({ + name: `skill-${skillIndex}`, + path: `skills/skill-${skillIndex}/SKILL.md`, + description: `Skill ${skillIndex}`, + })), + }), +) + +const sources: Array = packages.map((pkg) => ({ + kind: pkg.kind, + id: pkg.name, + skills: pkg.skills.map((skill) => ({ + path: `skills/${skill.name}`, + contentHash: `${pkg.name}-${skill.name}`, + })), +})) + +const config: IntentConsumerConfig = { + skills: ['@bench/*'], + exclude: [], + install: { method: 'symlink', targets: ['agents'] }, +} + +describe('sync planning', () => { + bench('plans unchanged representative sources and aliases', () => { + buildInstallDeltaInventory( + packages, + sources, + { status: 'found', lockfile: { lockfileVersion: 1, sources } }, + config, + ) + createSyncAliases( + packages.flatMap((pkg) => + pkg.skills.map((skill) => ({ + kind: pkg.kind, + id: pkg.name, + skill: skill.name, + })), + ), + ) + }) + + bench('plans changed and pending sources', () => { + const changed = sources.map((source, index) => + index === 0 + ? { + ...source, + skills: source.skills.map((skill, skillIndex) => + skillIndex === 0 ? { ...skill, contentHash: 'changed' } : skill, + ), + } + : source, + ) + buildInstallDeltaInventory( + packages, + changed, + { status: 'found', lockfile: { lockfileVersion: 1, sources } }, + { ...config, skills: ['@bench/package-00'] }, + ) + }) +}) diff --git a/benchmarks/intent/tsconfig.json b/benchmarks/intent/tsconfig.json index fa8caa6e..8ce9be06 100644 --- a/benchmarks/intent/tsconfig.json +++ b/benchmarks/intent/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "rootDir": ".", "noEmit": true }, "include": ["*.ts"] diff --git a/knip.json b/knip.json index f46e762c..27dbb6a9 100644 --- a/knip.json +++ b/knip.json @@ -15,7 +15,13 @@ ] }, "packages/intent": { - "entry": ["src/index.ts", "src/cli.ts", "src/core.ts", "src/setup.ts"], + "entry": [ + "src/index.ts", + "src/cli.ts", + "src/core.ts", + "src/setup.ts", + "src/catalog.ts" + ], "ignoreDependencies": ["verdaccio"] } } diff --git a/packages/intent/package.json b/packages/intent/package.json index 53f94c7e..891de85e 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -16,6 +16,10 @@ "./core": { "import": "./dist/core.mjs", "types": "./dist/core.d.mts" + }, + "./catalog": { + "import": "./dist/catalog.mjs", + "types": "./dist/catalog.d.mts" } }, "bin": { @@ -26,6 +30,7 @@ "meta" ], "dependencies": { + "@clack/prompts": "1.7.0", "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", @@ -39,9 +44,10 @@ }, "scripts": { "prepack": "npm run build", - "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/core.ts --format esm --dts", - "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null && node dist/cli.mjs load --help > /dev/null", + "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/core.ts src/catalog.ts --format esm --dts", + "test:smoke": "pnpm run build && node dist/cli.mjs --help && node dist/cli.mjs load --help && node --input-type=module -e \"const api = await import('@tanstack/intent/catalog'); if (typeof api.getIntentCatalogContext !== 'function' || typeof api.runSessionCatalogueHook !== 'function') process.exit(1)\"", "test:lib": "vitest run --exclude 'tests/integration/**'", + "test:delivery": "vitest run tests/sync.test.ts", "test:integration": "vitest run tests/integration/", "test:types": "tsc --noEmit", "test:eslint": "eslint ." diff --git a/packages/intent/src/catalog-lock.ts b/packages/intent/src/catalog-lock.ts new file mode 100644 index 00000000..4eb68d48 --- /dev/null +++ b/packages/intent/src/catalog-lock.ts @@ -0,0 +1,107 @@ +import { join } from 'node:path' +import { createIntentFsCache } from './discovery/fs-cache.js' +import { + getProjectReadFs, + scanIntentPackageAtRoot, +} from './discovery/scanner.js' +import { buildCurrentLockfileSources } from './core/lockfile/lockfile-state.js' +import { readIntentLockfile } from './core/lockfile/lockfile.js' +import { formatSkillUse } from './skills/use.js' +import type { CatalogueVerificationEntry } from './session-catalog.js' +import type { IntentSkillList } from './core/index.js' +import type { ReadFs } from './shared/utils.js' + +export interface LockCheckedCatalogueDiscovery { + result: IntentSkillList + verification: Array +} + +export function applyCatalogueLock( + result: IntentSkillList, + workspaceRoot: string, + readFs: ReadFs = getProjectReadFs(workspaceRoot), +): LockCheckedCatalogueDiscovery { + const locked = readIntentLockfile(join(workspaceRoot, 'intent.lock')) + if (locked.status === 'missing') return { result, verification: [] } + + const fsCache = createIntentFsCache() + fsCache.useFs(readFs) + const allowedUses = new Set() + const verification: Array = [] + + for (const summary of result.packages) { + const scanned = scanIntentPackageAtRoot(summary.packageRoot, { + fallbackName: summary.name, + fsCache, + projectRoot: workspaceRoot, + source: summary.source, + }).package + if (!scanned) continue + + const currentSource = buildCurrentLockfileSources( + [scanned], + fsCache.getReadFs(), + )[0] + if (!currentSource) continue + const lockedSource = locked.lockfile.sources.find( + (source) => + source.kind === currentSource.kind && source.id === currentSource.id, + ) + if (!lockedSource) continue + + const lockedSkills = new Map( + lockedSource.skills.map((skill) => [skill.path, skill.contentHash]), + ) + for (const skill of currentSource.skills) { + if (lockedSkills.get(skill.path) !== skill.contentHash) continue + const skillName = skill.path.slice('skills/'.length) + allowedUses.add(formatSkillUse(currentSource.id, skillName)) + verification.push({ + packageRoot: summary.packageRoot, + skillPath: skill.path, + contentHash: skill.contentHash, + }) + } + } + + const skills = result.skills.filter((skill) => allowedUses.has(skill.use)) + const skillCountByPackage = new Map() + for (const skill of skills) { + skillCountByPackage.set( + skill.packageRoot, + (skillCountByPackage.get(skill.packageRoot) ?? 0) + 1, + ) + } + const packages = result.packages.flatMap((pkg) => { + const skillCount = skillCountByPackage.get(pkg.packageRoot) ?? 0 + return skillCount > 0 ? [{ ...pkg, skillCount }] : [] + }) + const withheldCount = result.skills.length - skills.length + const warnings = + withheldCount > 0 + ? [ + ...result.warnings, + `${withheldCount} ${withheldCount === 1 ? 'skill was' : 'skills were'} withheld because installed content does not match intent.lock.`, + ] + : result.warnings + + return { + result: { + ...result, + skills, + packages, + warnings, + ...(result.debug + ? { + debug: { + ...result.debug, + packageCount: packages.length, + skillCount: skills.length, + warningCount: warnings.length, + }, + } + : {}), + }, + verification, + } +} diff --git a/packages/intent/src/catalog.ts b/packages/intent/src/catalog.ts new file mode 100644 index 00000000..cf718475 --- /dev/null +++ b/packages/intent/src/catalog.ts @@ -0,0 +1,122 @@ +import { readFileSync } from 'node:fs' +import { applyCatalogueLock } from './catalog-lock.js' +import { getProjectReadFs } from './discovery/scanner.js' +import { + formatSessionCatalogue, + getSessionCatalogue, + resolveCatalogueWorkspaceRoot, +} from './session-catalog.js' +import type { HookAgent } from './hooks/types.js' + +export type { HookAgent } from './hooks/types.js' + +export interface IntentCatalogContext { + cacheStatus: 'hit' | 'miss' | 'refresh' + context: string +} + +export async function getIntentCatalogContext({ + cwd, + refresh = false, +}: { + cwd: string + refresh?: boolean +}): Promise { + const workspaceRoot = resolveCatalogueWorkspaceRoot(cwd) + const readFs = getProjectReadFs(workspaceRoot) + const result = await getSessionCatalogue({ + root: workspaceRoot, + policyRoot: cwd, + readFs, + refresh, + discover: async () => { + const { listIntentSkills } = await import('./core/index.js') + const discovered = listIntentSkills({ + audience: 'agent', + cwd, + }) + return applyCatalogueLock(discovered, workspaceRoot, readFs) + }, + }) + const context = formatSessionCatalogue(result.catalogue) + + return { + cacheStatus: result.cacheStatus, + context, + } +} + +export async function runSessionCatalogueHook({ + agent, + event = readEventFromStdin(), +}: { + agent: HookAgent + event?: Record +}): Promise { + try { + const eventName = getLifecycleEventName(agent, event) + if (!eventName) return + + const result = await getIntentCatalogContext({ cwd: getEventCwd(event) }) + process.stdout.write( + JSON.stringify(formatHookOutput(agent, eventName, result.context)), + ) + } catch (error) { + console.error( + `[intent catalog] hook failed open: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +function readEventFromStdin(): Record { + try { + const value = JSON.parse(readFileSync(0, 'utf8')) as unknown + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} + } catch { + return {} + } +} + +function getLifecycleEventName( + agent: HookAgent, + event: Record, +): 'SessionStart' | 'SubagentStart' | null { + const explicit = event.hook_event_name ?? event.hookEventName + if (explicit === 'SessionStart' || explicit === 'sessionStart') { + return 'SessionStart' + } + if (explicit === 'SubagentStart' || explicit === 'subagentStart') { + return 'SubagentStart' + } + if (agent === 'copilot') { + if (typeof event.agentName === 'string') return 'SubagentStart' + if ( + event.source === 'startup' || + event.source === 'resume' || + event.source === 'new' + ) { + return 'SessionStart' + } + } + return null +} + +function getEventCwd(event: Record): string { + return typeof event.cwd === 'string' ? event.cwd : process.cwd() +} + +function formatHookOutput( + agent: HookAgent, + eventName: 'SessionStart' | 'SubagentStart', + additionalContext: string, +): Record { + if (agent === 'copilot') return { additionalContext } + return { + hookSpecificOutput: { + hookEventName: eventName, + additionalContext, + }, + } +} diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index d317b9d7..55ec68af 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -5,12 +5,15 @@ import { fileURLToPath } from 'node:url' import { cac } from 'cac' import { fail, isCliFailure } from './shared/cli-error.js' import type { CAC } from 'cac' +import type { HookAgent } from './catalog.js' import type { ExcludeCommandOptions } from './commands/exclude.js' import type { HooksInstallCommandOptions } from './commands/hooks/command.js' +import type { CatalogCommandOptions } from './commands/catalog.js' import type { InstallCommandOptions } from './commands/install/command.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' import type { StaleCommandOptions } from './commands/stale.js' +import type { SyncCommandOptions } from './commands/sync/command.js' import type { ValidateCommandOptions } from './commands/validate.js' function createCli(): CAC { @@ -23,7 +26,7 @@ function createCli(): CAC { 'Discover intent-enabled packages from the project or workspace', ) .usage( - 'list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--no-notices]', + 'list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--why] [--no-notices]', ) .option('--json', 'Output JSON') .option('--debug', 'Print discovery debug details to stderr') @@ -33,15 +36,32 @@ function createCli(): CAC { '--show-hidden', 'Show hidden skill sources not listed in intent.skills', ) + .option('--why', 'Explain why each shown skill is available or hidden') .option('--no-notices', 'Suppress non-critical notices on stderr') .example('list') .example('list --json') .example('list --global') + .example('list --why') .action(async (options: ListCommandOptions) => { const { runListCommand } = await import('./commands/list.js') await runListCommand(options) }) + cli + .command( + 'catalog', + 'Build compact cached skill context for agent lifecycle hooks', + ) + .usage('catalog [--json] [--refresh]') + .option('--json', 'Output JSON with context and cache metrics') + .option('--refresh', 'Ignore a valid cached catalogue') + .example('catalog') + .example('catalog --json') + .action(async (options: CatalogCommandOptions) => { + const { runCatalogCommand } = await import('./commands/catalog.js') + await runCatalogCommand(options) + }) + cli .command( 'exclude [action] [pattern]', @@ -118,28 +138,28 @@ function createCli(): CAC { ) cli - .command( - 'install', - 'Create or update skill loading guidance in an agent config file', - ) + .command('install', 'Configure trusted skill sources and delivery targets') .usage( - 'install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices]', + 'install [--map] [--dry-run] [--no-input] [--global] [--global-only] [--no-notices]', ) .option('--map', 'Write explicit skill-to-task mappings') - .option('--dry-run', 'Print the generated block without writing') + .option('--dry-run', 'Preview installation without writing files') .option( - '--print-prompt', - 'Print the legacy agent setup prompt instead of writing', + '--no-input', + 'Install or synchronize from package.json without prompts', ) - .option('--global', 'Include global packages after project packages') - .option('--global-only', 'Install mappings from global packages only') + .option('--global', 'With --map, include global packages') + .option('--global-only', 'With --map, use only global packages') .option('--no-notices', 'Suppress non-critical notices on stderr') .example('install') .example('install --map') .example('install --dry-run') - .example('install --print-prompt') - .example('install --global') + .example('install --no-input') + .example('install --map --global') .action(async (options: InstallCommandOptions) => { + if (options.map && options.input === false) { + fail('Cannot combine --map and --no-input.') + } const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ import('./commands/support.js'), import('./commands/install/command.js'), @@ -147,30 +167,59 @@ function createCli(): CAC { await runInstallCommand(options, scanIntentsOrFail) }) + cli + .command('sync', 'Synchronize verified skill links into configured targets') + .usage('sync [--dry-run] [--json]') + .option('--dry-run', 'Report changes without writing files') + .option('--json', 'Output JSON') + .example('sync') + .example('sync --dry-run') + .action(async (options: SyncCommandOptions) => { + const { runSyncCommand } = await import('./commands/sync/command.js') + await runSyncCommand(options) + }) + cli .command( 'hooks [action]', - 'Manage agent hooks that surface and enforce skill loading', + 'Manage agent hooks that surface available skills', ) .usage( - 'hooks install [--scope project|user] [--agents copilot,claude,codex|all]', + 'hooks [--scope project|user] [--agents copilot,claude,codex|all] [--agent copilot|claude|codex]', ) .option('--scope ', 'Hook install scope: project or user') .option('--agents ', 'Hook agents: copilot,claude,codex, or all') + .option('--agent ', 'Hook agent: copilot, claude, or codex') .example('hooks install') .example('hooks install --scope user --agents copilot') + .example('hooks run --agent copilot') .action( async ( action: string | undefined, - options: HooksInstallCommandOptions, + options: HooksInstallCommandOptions & { agent?: string }, ) => { - if (action !== 'install') { - fail('Unknown hooks action: expected install.') + if (action === 'install') { + const { runHooksInstallCommand } = + await import('./commands/hooks/command.js') + runHooksInstallCommand(options) + return + } + + if (action === 'run') { + if (!options.agent) { + fail('Missing hook agent. Expected copilot, claude, or codex.') + } + if (!['copilot', 'claude', 'codex'].includes(options.agent)) { + fail( + `Unknown hook agent: ${options.agent}. Expected copilot, claude, or codex.`, + ) + } + const { runSessionCatalogueHook } = await import('./catalog.js') + await runSessionCatalogueHook({ agent: options.agent as HookAgent }) + return } - const { runHooksInstallCommand } = - await import('./commands/hooks/command.js') - runHooksInstallCommand(options) + fail('Unknown hooks action: expected install or run.') }, ) diff --git a/packages/intent/src/commands/catalog.ts b/packages/intent/src/commands/catalog.ts new file mode 100644 index 00000000..ab349970 --- /dev/null +++ b/packages/intent/src/commands/catalog.ts @@ -0,0 +1,26 @@ +import { getIntentCatalogContext } from '../catalog.js' + +export interface CatalogCommandOptions { + json?: boolean + refresh?: boolean +} + +export async function runCatalogCommand( + options: CatalogCommandOptions = {}, +): Promise { + const result = await getIntentCatalogContext({ + cwd: process.cwd(), + refresh: options.refresh, + }) + if (!options.json) { + console.log(result.context) + return + } + + console.log( + JSON.stringify({ + cacheStatus: result.cacheStatus, + context: result.context, + }), + ) +} diff --git a/packages/intent/src/commands/exclude.ts b/packages/intent/src/commands/exclude.ts index 503d573b..eb6d9aa9 100644 --- a/packages/intent/src/commands/exclude.ts +++ b/packages/intent/src/commands/exclude.ts @@ -1,7 +1,13 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { fail } from '../shared/cli-error.js' import { compileExcludePatterns } from '../core/excludes.js' +import { writeTextFileAtomic } from '../shared/atomic-write.js' +import { + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from './install/config.js' +import type { IntentConsumerConfig } from './install/config.js' export interface ExcludeCommandOptions { json?: boolean @@ -20,65 +26,34 @@ function getPackageJsonPath(cwd: string): string { return join(cwd, 'package.json') } -function readPackageJson(cwd: string): Record { +function readPackageJsonText(cwd: string): string { const packageJsonPath = getPackageJsonPath(cwd) if (!existsSync(packageJsonPath)) { fail(`No package.json found in ${cwd}`) } + return readFileSync(packageJsonPath, 'utf8') +} +function readConfig(text: string): IntentConsumerConfig { try { - return JSON.parse(readFileSync(packageJsonPath, 'utf8')) as Record< - string, - unknown - > + return readIntentConsumerConfig(text) } catch (err) { fail( - `Failed to parse ${packageJsonPath}: ${err instanceof Error ? err.message : String(err)}`, + `Invalid package.json intent configuration: ${err instanceof Error ? err.message : String(err)}`, ) } } -function readConfiguredExcludes(pkg: Record): Array { - const intent = pkg.intent - if (intent === undefined) return [] - if (!intent || typeof intent !== 'object') { - fail('Invalid package.json: intent must be an object when present.') - } - - const raw = (intent as Record).exclude - if (raw === undefined) return [] - if (!Array.isArray(raw)) { - fail('Invalid package.json: intent.exclude must be an array of strings.') - } - - const excludes: Array = [] - for (const entry of raw) { - if (typeof entry !== 'string') { - fail('Invalid package.json: intent.exclude must contain only strings.') - } - const trimmed = entry.trim() - if (trimmed.length === 0) continue - excludes.push(trimmed) - } - return excludes -} - -function setConfiguredExcludes( - pkg: Record, +function writeExcludes( + cwd: string, + text: string, + config: IntentConsumerConfig, excludes: Array, ): void { - const intent = - pkg.intent && typeof pkg.intent === 'object' - ? (pkg.intent as Record) - : {} - - intent.exclude = excludes - pkg.intent = intent -} - -function writePackageJson(cwd: string, pkg: Record): void { - const packageJsonPath = getPackageJsonPath(cwd) - writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8') + writeTextFileAtomic( + getPackageJsonPath(cwd), + updateIntentConsumerConfigText(text, { ...config, exclude: excludes }), + ) } function normalizePattern( @@ -133,8 +108,9 @@ export function runExcludeCommand( ): void { const action = normalizeAction(actionArg) const cwd = process.cwd() - const pkg = readPackageJson(cwd) - const currentExcludes = readConfiguredExcludes(pkg) + const text = readPackageJsonText(cwd) + const config = readConfig(text) + const currentExcludes = config.exclude if (action === 'list') { if (patternArg) { @@ -158,8 +134,7 @@ export function runExcludeCommand( } const updated = [...currentExcludes, pattern] - setConfiguredExcludes(pkg, updated) - writePackageJson(cwd, pkg) + writeExcludes(cwd, text, config, updated) if (options.json) { printExcludes(updated, true) return @@ -180,8 +155,7 @@ export function runExcludeCommand( return } - setConfiguredExcludes(pkg, updated) - writePackageJson(cwd, pkg) + writeExcludes(cwd, text, config, updated) if (options.json) { printExcludes(updated, true) return diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 2721081b..118fd1c6 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,6 +1,7 @@ -import { relative } from 'node:path' +import { readFileSync } from 'node:fs' +import { join, relative } from 'node:path' import { fail } from '../../shared/cli-error.js' -import { detectIntentCommandPackageManager } from '../../shared/command-runner.js' +import { detectIntentAudience } from '../../shared/environment.js' import { coreOptionsFromGlobalFlags, noticeOptionsFromGlobalFlags, @@ -8,123 +9,125 @@ import { printWarnings, } from '../support.js' import { - buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, resolveIntentSkillsBlockTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from './guidance.js' import type { GlobalScanFlags } from '../support.js' +import type { InstallerPrompter } from './consumer.js' +import type { SkillSelection } from './plan.js' import type { IntentCoreOptions } from '../../core/index.js' import type { ScanResult } from '../../shared/types.js' -export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. - -Goal: create or update one agent config file with an intent-skills mapping block. - -Hard rules: -- Do not report success until a file was created or updated, or an existing mapping block was confirmed. -- If skills are discovered and no mapping block exists, create AGENTS.md unless the user asks for another supported config file. -- If a mapping block already exists in a supported config file, update that file. -- Preserve all content outside the managed block unchanged. -- Store compact \`id\` values and runnable \`run\` commands in the managed block; do not write local paths. -- Never write absolute local file paths, node_modules paths, or package-manager-internal paths in the managed block. -- Verify the target file before your final response. - -Follow these steps in order: - -1. CHECK FOR EXISTING MAPPINGS - Search the project's agent config files (AGENTS.md, CLAUDE.md, .cursorrules, - .github/copilot-instructions.md) for a block delimited by: - - - - If found: show the user the current mappings, keep that file as the source of truth, - and ask "What would you like to update?" Then skip to step 4 with their requested changes. - - If not found: continue to step 2. - -2. DISCOVER AVAILABLE SKILLS - Run: \`npx @tanstack/intent@latest list\` - This scans project-local node_modules by default and outputs each package and skill's name, - description, and source. - If the user explicitly wants globally installed skills included, run: - \`npx @tanstack/intent@latest list --global\` - This works best in Node-compatible environments (npm, pnpm, Bun, or Deno npm interop - with node_modules enabled). - If no skills are found, do not create a config file. Report: "No intent-enabled skills found." - -3. SCAN THE REPOSITORY - Build a picture of the project's structure and patterns: - - Read package.json for library dependencies - - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) - - Note recurring patterns (routing, data fetching, auth, UI components, etc.) - - Mapping coverage rule: - - Create mappings for all discovered actionable skills. - - Do not omit an actionable skill only because the repo does not currently appear to use it. - - Do not map reference, meta, maintainer, or maintainer-only skills by default. - - Include slash-named sub-skills when no parent mapping exists, or when they describe distinct user tasks. - - If the proposed block would exceed 12 mappings, show the full discovered list and ask which packages - or skill groups to include before writing. - - Add one fallback note telling the agent to run \`npx @tanstack/intent@latest list\` for less common local skills. - - Based on the repository scan and the coverage rule, propose the skill-to-task mappings. - For each one explain: - - The task or code area (in plain language the user would recognise) - - Which skill applies and why - - Then ask: "What other tasks do you commonly use AI coding agents for? - I'll create mappings for those too." - Also ask: "I'll default to AGENTS.md unless you want another supported config file. - Do you have a preference?" - -4. WRITE THE MAPPINGS BLOCK - Once you have the full set of mappings, write or update the agent config file. - - If you found an existing intent-skills block, update that file in place. - - Otherwise prefer AGENTS.md by default, unless the user asked for another supported file. - - Do not stop after discovery. If skills were found, the task is incomplete until this file exists - and contains the managed block. - - Use this exact block: - - -# TanStack Intent - before editing files, run the matching guidance command. -tanstackIntent: - - id: "@scope/package#skill-name" - run: "npx @tanstack/intent@latest load @scope/package#skill-name" - for: "describe the task or code area here" - - - Rules: - - Use the user's own words for \`for\` descriptions - - Use compact \`id\` values in \`#\` format - - Include a \`run\` command that loads the matching \`id\` - - Do not include machine-specific directories such as \`/Users/...\`, \`/home/...\`, \`/private/...\`, - drive letters, temp workspace paths, \`.pnpm/\`, \`.bun/\`, or \`.yarn/\`. - - Agents should run the \`run\` command before editing matching files - - Keep entries concise - this block is read on every agent task - - Preserve all content outside the block tags unchanged - - If the user is on Deno, note that this setup is best-effort today and relies on npm interop - -5. VERIFY AND REPORT - Before reporting completion: - - Confirm the target file exists - - Confirm it contains both managed block markers - - Confirm every mapping has \`id\`, \`run\`, and \`for\` - - Confirm every \`id\` parses as \`#\` - - Confirm every \`run\` command loads the matching \`id\` - - Confirm no path-like machine-specific values are stored in the managed block - - Confirm every discovered actionable skill is mapped, skipped by rule, or deferred by user choice +async function runInstallWithPrompts({ + dryRun, + prompts, + root, + selection, +}: { + dryRun?: boolean + prompts: InstallerPrompter + root: string + selection?: SkillSelection +}): Promise { + const [{ runConsumerInstall }, { scanForIntents }] = await Promise.all([ + import('./consumer.js'), + import('../../discovery/scanner.js'), + ]) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + dryRun, + prompts, + root, + selection, + }) +} - Final response must include: - - The target file path - - Whether it was created, updated, or already contained a valid block - - The number of mappings - - The verification result` +async function runDeclarativeInstall( + options: InstallCommandOptions, +): Promise { + const { resolveProjectContext } = + await import('../../core/project-context.js') + const context = resolveProjectContext({ cwd: process.cwd() }) + const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd + let packageJson: string + try { + packageJson = readFileSync(join(root, 'package.json'), 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + fail(`Non-interactive install requires package.json in ${root}.`) + } + throw error + } + const { readIntentLockfile } = await import('../../core/lockfile/lockfile.js') + if (readIntentLockfile(join(root, 'intent.lock')).status === 'found') { + const { runSyncCommand } = await import('../sync/command.js') + await runSyncCommand({ ...options, cwd: root }, { review: 'fail' }) + return + } + const { hasExplicitIntentSkills, readIntentConsumerConfig } = + await import('./config.js') + const config = readIntentConsumerConfig(packageJson) + if (!hasExplicitIntentSkills(packageJson)) { + fail( + 'Non-interactive install requires an explicit package.json intent.skills array. Add intent.skills (use [] to deny all) before running `intent install --no-input`.', + ) + } + if (!config.install) { + fail( + 'Non-interactive install requires an explicit valid package.json intent.install object. Configure intent.install.method and intent.install.targets before running `intent install --no-input`.', + ) + } + const install = config.install + if (install.method === 'hooks' && install.targets.includes('github')) { + fail( + 'Non-interactive install cannot bootstrap GitHub Copilot hooks because they require interactive approval for user-home access. Remove "github" from intent.install.targets or run `intent install` in a terminal.', + ) + } + const selection: SkillSelection = { + mode: 'configured-policy', + skills: config.skills, + exclude: config.exclude, + } + const prompts: InstallerPrompter = { + advisory: (message) => console.log(message), + complete: (message) => console.log(message), + selectMethod: () => Promise.resolve(install.method), + selectTargets: () => Promise.resolve(install.targets), + confirmSymlink: () => Promise.resolve(true), + confirmUserScopeHooks: () => Promise.resolve(false), + selectSkills: () => Promise.resolve(selection), + confirmInstall: () => Promise.resolve('install'), + } + await runInstallWithPrompts({ + dryRun: options.dryRun, + prompts, + root, + selection, + }) +} export interface InstallCommandOptions extends GlobalScanFlags { dryRun?: boolean + input?: boolean map?: boolean - printPrompt?: boolean +} + +export async function runInteractiveInstall({ + cwd, + dryRun, + prompts, +}: { + cwd: string + dryRun?: boolean + prompts: InstallerPrompter +}): Promise { + const { resolveProjectContext } = + await import('../../core/project-context.js') + const context = resolveProjectContext({ cwd }) + const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd + await runInstallWithPrompts({ dryRun, prompts, root }) } function formatTargetPath(targetPath: string): string { @@ -198,8 +201,8 @@ export async function runInstallCommand( options: InstallCommandOptions, scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, ): Promise { - if (options.printPrompt) { - console.log(INSTALL_PROMPT) + if (options.input === false) { + await runDeclarativeInstall(options) return } @@ -207,46 +210,17 @@ export async function runInstallCommand( const noticeOptions = noticeOptionsFromGlobalFlags(options) if (!options.map) { - const generated = buildIntentSkillGuidanceBlock( - detectIntentCommandPackageManager(), - ) - - if (options.dryRun) { - const targetPath = resolveIntentSkillsBlockTargetPath(process.cwd(), 1) - console.log( - `Generated skill loading guidance for ${formatTargetPath(targetPath!)}.`, - ) - console.log(generated.block) - return - } - - const result = writeIntentSkillsBlock({ - ...generated, - root: process.cwd(), - skipWhenEmpty: false, - }) - - if (!result.targetPath) { - fail('Install guidance target was not created.') - } - - const verification = verifyIntentSkillsBlockFile({ - expectedBlock: generated.block, - targetPath: result.targetPath, - }) - - const target = formatTargetPath(result.targetPath) - if (!verification.ok) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { fail( - [ - `Install verification failed for ${target}:`, - ...verification.errors.map((error) => `- ${error}`), - ].join('\n'), + 'Interactive installation requires a terminal. Run `intent install` in a TTY, use `intent install --map`, or configure explicit package.json intent.skills and intent.install values before using `intent install --no-input`.', ) } - - printWriteResult(result) - printPlacementTip(result.targetPath) + const { createClackInstallerPrompter } = await import('./prompts.js') + await runInteractiveInstall({ + cwd: process.cwd(), + dryRun: options.dryRun, + prompts: createClackInstallerPrompter(), + }) return } @@ -311,5 +285,13 @@ export async function runInstallCommand( printPlacementTip(result.targetPath) printWarnings(scanResult.warnings) - printNotices(scanResult.notices, noticeOptions) + const snapshotNotices = + result.status !== 'unchanged' && + generated.mappingCount > 0 && + detectIntentAudience() === 'human' + ? [ + 'The intent-skills block is a snapshot and does not update when dependencies change. Re-run `intent install --map` to regenerate it.', + ] + : [] + printNotices([...snapshotNotices, ...scanResult.notices], noticeOptions) } diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts new file mode 100644 index 00000000..0c723d2a --- /dev/null +++ b/packages/intent/src/commands/install/config.ts @@ -0,0 +1,310 @@ +import { existsSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { applyEdits, modify, parse } from 'jsonc-parser' +import { compileExcludePatterns } from '../../core/excludes.js' +import { parseSkillSources } from '../../core/skill-sources.js' + +export type InstallMethod = 'symlink' | 'hooks' +export type InstallTarget = + | 'agents' + | 'github' + | 'vscode' + | 'cursor' + | 'codex' + | 'claude' + +export interface IntentInstallPreferences { + targets: Array + method: InstallMethod +} + +export interface IntentConsumerConfig { + skills: Array + exclude: Array + install?: IntentInstallPreferences +} + +export const INSTALL_TARGETS: ReadonlyArray<{ + id: InstallTarget + label: string +}> = [ + { id: 'agents', label: 'Shared .agents directory' }, + { id: 'github', label: 'GitHub Copilot' }, + { id: 'vscode', label: 'VS Code' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'codex', label: 'Codex' }, + { id: 'claude', label: 'Claude Code' }, +] + +const INSTALL_METHODS: Readonly< + Record> +> = { + symlink: new Set(INSTALL_TARGETS.map((target) => target.id)), + hooks: new Set(['github', 'codex', 'claude']), +} + +export function installTargetsForMethod( + method: InstallMethod, +): typeof INSTALL_TARGETS { + return INSTALL_TARGETS.filter((target) => + INSTALL_METHODS[method].has(target.id), + ) +} + +function isDirectory(root: string, path: string): boolean { + const target = join(root, path) + return existsSync(target) && statSync(target).isDirectory() +} + +export function detectInstallTargets(root: string): Array { + return INSTALL_TARGETS.flatMap((target) => { + switch (target.id) { + case 'agents': + return isDirectory(root, '.agents') || + existsSync(join(root, 'AGENTS.md')) + ? [target.id] + : [] + case 'github': + return existsSync(join(root, '.github/copilot-instructions.md')) + ? [target.id] + : [] + case 'vscode': + return isDirectory(root, '.vscode') ? [target.id] : [] + case 'cursor': + return isDirectory(root, '.cursor') || + existsSync(join(root, '.cursorrules')) + ? [target.id] + : [] + case 'codex': + return isDirectory(root, '.codex') ? [target.id] : [] + case 'claude': + return isDirectory(root, '.claude') || + existsSync(join(root, 'CLAUDE.md')) + ? [target.id] + : [] + } + }) +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function requireStringArray(value: unknown, label: string): Array { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== 'string') + ) { + throw new Error(`${label} must be an array of strings.`) + } + return value +} + +function validateExcludes(excludes: Array): void { + if (excludes.some((entry) => entry.trim() === '')) { + throw new Error('intent.exclude must not contain blank entries.') + } + compileExcludePatterns(excludes) +} + +function validateInstall(value: unknown): IntentInstallPreferences { + if (!isRecord(value)) throw new Error('intent.install must be an object.') + for (const key of Object.keys(value)) { + if (key !== 'targets' && key !== 'method') { + throw new Error(`Unknown intent.install field "${key}".`) + } + } + const targets = requireStringArray(value.targets, 'intent.install.targets') + if (targets.length === 0) { + throw new Error('intent.install.targets must not be empty.') + } + const seen = new Set() + for (const target of targets) { + if (!INSTALL_TARGETS.some((candidate) => candidate.id === target)) { + throw new Error(`Unknown install target "${target}".`) + } + if (seen.has(target)) + throw new Error(`Duplicate install target "${target}".`) + seen.add(target) + } + if (typeof value.method !== 'string' || !(value.method in INSTALL_METHODS)) { + throw new Error(`Unknown install method "${String(value.method)}".`) + } + const method = value.method as InstallMethod + for (const target of targets) { + if (!INSTALL_METHODS[method].has(target as InstallTarget)) { + throw new Error( + `Install method "${method}" is not supported for "${target}".`, + ) + } + } + return { targets: targets as Array, method } +} + +function parsePackageJson(text: string): Record { + const errors: Array<{ error: number; offset: number; length: number }> = [] + const value = parse(text.replace(/^\ufeff/, ''), errors, { + allowTrailingComma: true, + disallowComments: false, + }) + if (errors.length > 0 || !isRecord(value)) { + throw new Error('Invalid package.json JSONC.') + } + return value +} + +export function hasIntentDevDependency(text: string): boolean { + const devDependencies = parsePackageJson(text).devDependencies + return ( + isRecord(devDependencies) && + typeof devDependencies['@tanstack/intent'] === 'string' + ) +} + +export function hasExplicitIntentSkills(text: string): boolean { + const intent = parsePackageJson(text).intent + return isRecord(intent) && Object.hasOwn(intent, 'skills') +} + +export function readIntentConsumerConfig(text: string): IntentConsumerConfig { + const packageJson = parsePackageJson(text) + const intent = packageJson.intent + if (intent === undefined) return { skills: [], exclude: [] } + if (!isRecord(intent)) throw new Error('intent must be an object.') + const skills = + intent.skills === undefined + ? [] + : requireStringArray(intent.skills, 'intent.skills') + const exclude = + intent.exclude === undefined + ? [] + : requireStringArray(intent.exclude, 'intent.exclude') + parseSkillSources(skills) + validateExcludes(exclude) + return { + skills, + exclude, + ...(intent.install === undefined + ? {} + : { install: validateInstall(intent.install) }), + } +} + +function equalsConfig( + left: IntentConsumerConfig, + right: IntentConsumerConfig, +): boolean { + if ( + left.skills.length !== right.skills.length || + left.exclude.length !== right.exclude.length + ) { + return false + } + if ( + left.skills.some((entry, index) => entry !== right.skills[index]) || + left.exclude.some((entry, index) => entry !== right.exclude[index]) + ) { + return false + } + if (left.install === undefined || right.install === undefined) { + return left.install === right.install + } + return ( + left.install.method === right.install.method && + left.install.targets.length === right.install.targets.length && + left.install.targets.every( + (target, index) => target === right.install!.targets[index], + ) + ) +} + +function equalsArray( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((entry, index) => entry === right[index]) + ) +} + +function equalsInstall( + left: IntentInstallPreferences | undefined, + right: IntentInstallPreferences | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right + return ( + left.method === right.method && equalsArray(left.targets, right.targets) + ) +} + +function formattingOptions(text: string): { + eol: string + insertSpaces: boolean + tabSize: number +} { + const indentation = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ' + return { + eol: text.includes('\r\n') ? '\r\n' : '\n', + insertSpaces: !indentation.includes('\t'), + tabSize: indentation.includes('\t') ? 1 : indentation.length, + } +} + +function applyModification( + text: string, + path: Array, + value: unknown, + options: ReturnType, +): string { + return applyEdits( + text, + modify(text, path, value, { formattingOptions: options }), + ) +} + +export function updateIntentConsumerConfigText( + text: string, + requested: IntentConsumerConfig, +): string { + const existing = readIntentConsumerConfig(text) + const normalized = { + skills: requireStringArray(requested.skills, 'intent.skills'), + exclude: requireStringArray(requested.exclude, 'intent.exclude'), + ...(requested.install === undefined + ? {} + : { install: validateInstall(requested.install) }), + } + parseSkillSources(normalized.skills) + validateExcludes(normalized.exclude) + if (equalsConfig(existing, normalized)) return text + + const bom = text.startsWith('\ufeff') ? '\ufeff' : '' + const options = formattingOptions(text) + let updated = bom === '' ? text : text.slice(1) + if (!equalsArray(existing.skills, normalized.skills)) { + updated = applyModification( + updated, + ['intent', 'skills'], + normalized.skills, + options, + ) + } + if (!equalsArray(existing.exclude, normalized.exclude)) { + updated = applyModification( + updated, + ['intent', 'exclude'], + normalized.exclude, + options, + ) + } + if (!equalsInstall(existing.install, normalized.install)) { + updated = applyModification( + updated, + ['intent', 'install'], + normalized.install, + options, + ) + } + return `${bom}${updated}` +} diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts new file mode 100644 index 00000000..a26e5a9d --- /dev/null +++ b/packages/intent/src/commands/install/consumer.ts @@ -0,0 +1,278 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { compileExcludePatterns } from '../../core/excludes.js' +import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' +import { + readIntentLockfile, + writeIntentLockfile, +} from '../../core/lockfile/lockfile.js' +import { applySourcePolicy } from '../../core/source-policy.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { runInstallHooks } from '../../hooks/install.js' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' +import { runSyncCommand } from '../sync/command.js' +import { reconcileManagedLinks } from '../sync/links.js' +import { buildSyncLinkPlan } from '../sync/plan.js' +import { wireIntentSyncPrepare } from '../sync/prepare.js' +import { readInstallStateForLinks } from '../sync/state.js' +import { toProjectRelativePath } from '../sync/targets.js' +import { + INSTALL_TARGETS, + detectInstallTargets, + hasIntentDevDependency, + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from './config.js' +import { + buildInstallDeltaInventory, + buildSkillSelectionPlan, + summarizeInstallDeltaInventory, +} from './plan.js' +import type { + InstallMethod, + InstallTarget, + IntentConsumerConfig, + IntentInstallPreferences, +} from './config.js' +import type { SkillSelection } from './plan.js' +import type { HookAgent } from '../../hooks/types.js' +import type { IntentPackage } from '../../shared/types.js' + +interface ConsumerInstallConfig extends IntentConsumerConfig { + install: IntentInstallPreferences +} + +export type InstallConfirmation = 'install' | 'back' | null + +export interface InstallerPrompter { + advisory: (message: string) => void + complete: (message: string) => void + selectMethod: () => Promise + selectTargets: ( + method: InstallMethod, + detected: ReadonlyArray, + ) => Promise | null> + confirmSymlink: () => Promise + confirmUserScopeHooks: () => Promise + selectSkills: ( + discovered: ReadonlyArray, + ) => Promise + confirmInstall: (confirmation: { + config: ConsumerInstallConfig + skillCount: number + }) => Promise +} + +export interface RunConsumerInstallOptions { + discovered: ReadonlyArray + dryRun?: boolean + prompts: InstallerPrompter + root: string + selection?: SkillSelection +} + +function hookAgentForTarget(target: InstallTarget): HookAgent { + switch (target) { + case 'github': + return 'copilot' + case 'claude': + case 'codex': + return target + default: + throw new Error( + `Install method "hooks" is not supported for "${target}".`, + ) + } +} + +function countSkills(entries: ReadonlyArray<{ skillCount: number }>): number { + return entries.reduce((count, entry) => count + entry.skillCount, 0) +} + +export async function runConsumerInstall({ + discovered, + dryRun = false, + prompts, + root, + selection: fixedSelection, +}: RunConsumerInstallOptions): Promise { + const packageJsonPath = join(root, 'package.json') + const packageJson = readFileSync(packageJsonPath, 'utf8') + const intentDevDependency = hasIntentDevDependency(packageJson) + const existingConfig = readIntentConsumerConfig(packageJson) + const install = dryRun ? undefined : existingConfig.install + if (install) { + const existingLock = readIntentLockfile(join(root, 'intent.lock')) + const inventory = buildInstallDeltaInventory( + discovered, + buildCurrentLockfileSources(discovered), + existingLock, + existingConfig, + ) + const summary = summarizeInstallDeltaInventory(inventory) + const newDependencies = countSkills(summary.newDependencies) + const newSkills = countSkills(summary.newSkills) + const changed = countSkills(summary.changed) + if ( + newDependencies === 0 && + newSkills === 0 && + changed === 0 && + summary.removed === 0 && + existingLock.status === 'found' + ) { + prompts.complete('Project is up to date.') + return + } + console.log( + `Install changes: ${newDependencies} new ${newDependencies === 1 ? 'dependency' : 'dependencies'}, ${newSkills} new ${newSkills === 1 ? 'skill' : 'skills'}, ${changed} changed, ${summary.removed} removed.`, + ) + } + for (;;) { + const method = install ? install.method : await prompts.selectMethod() + if (!method) return + const targets = install + ? install.targets + : await prompts.selectTargets(method, detectInstallTargets(root)) + if (!targets || targets.length === 0) return + if (!install && method === 'symlink') { + const symlinkAccepted = await prompts.confirmSymlink() + if (!symlinkAccepted) return + } + if ( + fixedSelection === undefined && + discovered.every((pkg) => pkg.skills.length === 0) + ) { + prompts.complete('No intent-enabled skills found.') + return + } + const selection = fixedSelection ?? (await prompts.selectSkills(discovered)) + if (!selection) return + const plan = buildSkillSelectionPlan(discovered, selection) + const installation = { + config: { + skills: plan.skills, + exclude: plan.exclude, + install: { method, targets }, + } satisfies ConsumerInstallConfig, + skillCount: plan.packages.reduce( + (count, pkg) => + count + + pkg.skills.filter((skill) => skill.status === 'enabled').length, + 0, + ), + } + const confirmation = await prompts.confirmInstall(installation) + if (confirmation === null) return + if (confirmation === 'back') continue + + const updatedConsumerConfig = updateIntentConsumerConfigText( + packageJson, + installation.config, + ) + const updatedPackageJson = + method === 'symlink' && intentDevDependency + ? wireIntentSyncPrepare(updatedConsumerConfig) + : updatedConsumerConfig + const policy = applySourcePolicy( + { packages: [...discovered] }, + { + config: parseSkillSources(installation.config.skills), + excludeMatchers: compileExcludePatterns(installation.config.exclude), + }, + ) + const lockfile = { + lockfileVersion: 1 as const, + sources: buildCurrentLockfileSources(policy.packages), + } + if (method === 'symlink') { + const linkPlan = buildSyncLinkPlan({ + config: installation.config, + currentSources: lockfile.sources, + discovered, + lock: { status: 'found', lockfile }, + packages: policy.packages, + root, + }) + const preflight = reconcileManagedLinks({ + dryRun: true, + expected: linkPlan.expected, + stateResult: readInstallStateForLinks(root), + }) + if (preflight.conflicts.length > 0) { + throw new Error( + `Install target conflicts: ${preflight.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } + } + + if (dryRun) { + const labels = new Map( + INSTALL_TARGETS.map((target) => [target.id, target.label]), + ) + const targetLabels = targets.map((target) => labels.get(target) ?? target) + console.log( + `Would install ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} to ${targetLabels.join(', ')} using ${method}.`, + ) + console.log( + `Would update package.json intent configuration:\n${JSON.stringify(installation.config, null, 2)}`, + ) + console.log( + `Would write intent.lock with ${lockfile.sources.length} ${lockfile.sources.length === 1 ? 'source' : 'sources'}.`, + ) + prompts.complete('Dry run complete.') + return + } + + let userScopeHooksAccepted = false + if (method === 'hooks' && targets.includes('github')) { + const accepted = await prompts.confirmUserScopeHooks() + if (accepted === null) return + userScopeHooksAccepted = accepted + } + + writeTextFileAtomic(packageJsonPath, updatedPackageJson) + writeIntentLockfile(join(root, 'intent.lock'), lockfile) + if (!intentDevDependency) { + prompts.advisory( + 'Skills will not re-sync automatically because the prepare script was not wired. intent.lock records the accepted skill baseline, but nothing will check it automatically. Add @tanstack/intent as a devDependency to enable both.', + ) + } + if (method === 'symlink') { + await runSyncCommand({ cwd: root }, { review: 'reminder' }) + prompts.complete( + `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using ${method}.`, + ) + return + } + + const hookAgents = targets.map(hookAgentForTarget) + const projectAgents = hookAgents.filter((agent) => agent !== 'copilot') + const installedAgents = + projectAgents.length > 0 + ? runInstallHooks({ + agents: projectAgents.join(','), + root, + scope: 'project', + }) + .filter((result) => result.status !== 'skipped') + .map((result) => result.agent) + : [] + if (userScopeHooksAccepted) { + installedAgents.push( + ...runInstallHooks({ agents: 'copilot', root, scope: 'user' }) + .filter((result) => result.status !== 'skipped') + .map((result) => result.agent), + ) + } + const skippedCopilot = + targets.includes('github') && !userScopeHooksAccepted + ? ' Copilot was skipped because home-directory access was declined.' + : '' + prompts.complete( + `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using hooks. Installed hook agents: ${installedAgents.length > 0 ? installedAgents.join(', ') : 'none'}.${skippedCopilot}`, + ) + return + } +} diff --git a/packages/intent/src/commands/install/guidance.ts b/packages/intent/src/commands/install/guidance.ts index 0a7e4f19..6eaad81f 100644 --- a/packages/intent/src/commands/install/guidance.ts +++ b/packages/intent/src/commands/install/guidance.ts @@ -131,7 +131,7 @@ function containsLocalPathValue(value: string): boolean { function parseLoadedSkillUse(command: string): string | null { const match = command.match( - /(?:^|&&|\|\||;|\|)\s*(?:bunx\s+@tanstack\/intent(?:@latest)?|pnpm\s+exec\s+intent|pnpm\s+dlx\s+@tanstack\/intent(?:@latest)?|npx\s+@tanstack\/intent(?:@latest)?|yarn\s+dlx\s+@tanstack\/intent(?:@latest)?|intent)\s+load\s+([^\s|;&]+)/i, + /(?:^|&&|\|\||;|\|)\s*(?:bunx\s+@tanstack\/intent(?:@[^\s]+)?|pnpm\s+exec\s+intent|pnpm\s+dlx\s+@tanstack\/intent(?:@[^\s]+)?|npx\s+@tanstack\/intent(?:@[^\s]+)?|yarn\s+dlx\s+@tanstack\/intent(?:@[^\s]+)?|intent)\s+load\s+([^\s|;&]+)/i, ) return match?.[1] ?? null } diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts new file mode 100644 index 00000000..3d3523f7 --- /dev/null +++ b/packages/intent/src/commands/install/plan.ts @@ -0,0 +1,422 @@ +import { + compileExcludePatterns, + isPackageExcluded, + isSkillExcluded, +} from '../../core/excludes.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { compileSkillSourcePolicy } from '../../core/source-policy.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../../core/lockfile/lockfile.js' +import type { ExcludeMatcher } from '../../core/excludes.js' +import type { SkillSourcesConfig } from '../../core/skill-sources.js' +import type { CompiledSkillSourcePolicy } from '../../core/source-policy.js' +import type { IntentConsumerConfig } from './config.js' +import type { IntentPackage, SkillEntry } from '../../shared/types.js' + +export type SkillSelection = + | { mode: 'all-found' } + | { mode: 'scope'; scope: string } + | { mode: 'individual'; enabled: Array } + | { + mode: 'configured-policy' + skills: Array + exclude: Array + } + +export interface SkillSelectionPlan { + skills: Array + exclude: Array + packages: Array<{ + name: string + kind: IntentPackage['kind'] + skills: Array<{ id: string; status: 'enabled' | 'excluded' }> + }> +} + +export type InventoryPolicyStatus = 'enabled' | 'excluded' | 'pending' +export type InventoryLockStatus = 'accepted' | 'new' | 'changed' | null + +export interface InstallDeltaInventory { + packages: Array<{ + name: string + kind: IntentPackage['kind'] + skills: Array<{ + id: string + policy: InventoryPolicyStatus + lock: InventoryLockStatus + }> + }> + removed: Array<{ + kind: IntentPackage['kind'] + id: string + path: string | null + }> +} + +export interface InstallDeltaSummary { + newDependencies: Array<{ name: string; skillCount: number }> + newSkills: Array<{ name: string; skillCount: number }> + changed: Array<{ name: string; skillCount: number }> + removed: number +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function sourceEntry(pkg: Pick): string { + return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name +} + +export function summarizeInstallDeltaInventory( + inventory: InstallDeltaInventory, +): InstallDeltaSummary { + return { + newDependencies: inventory.packages + .map((pkg) => ({ + name: sourceEntry(pkg), + skillCount: pkg.skills.filter((skill) => skill.policy === 'pending') + .length, + })) + .filter((entry) => entry.skillCount > 0), + newSkills: inventory.packages + .map((pkg) => ({ + name: sourceEntry(pkg), + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'new', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + changed: inventory.packages + .map((pkg) => ({ + name: sourceEntry(pkg), + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'changed', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + removed: inventory.removed.length, + } +} + +export function skillSelectionId( + pkg: IntentPackage, + skill: SkillEntry, +): string { + return `${sourceEntry(pkg)}#${skill.name}` +} + +function classifySkillPolicy( + pkg: Pick, + skillName: string, + sources: SkillSourcesConfig, + sourcePolicy: CompiledSkillSourcePolicy, + excludes: Array, +): InventoryPolicyStatus { + if ( + isPackageExcluded(pkg.name, excludes) || + isSkillExcluded(pkg.name, skillName, excludes) || + sources.mode === 'empty' + ) { + return 'excluded' + } + return sourcePolicy.permitsSkill(pkg.name, skillName, pkg.kind) + ? 'enabled' + : 'pending' +} + +function sortedPackages( + packages: ReadonlyArray, +): Array { + return [...packages].sort((left, right) => { + const byName = compareStrings(left.name, right.name) + return byName === 0 ? compareStrings(left.kind, right.kind) : byName + }) +} + +function sortedSkills(pkg: IntentPackage): Array { + return [...pkg.skills].sort((left, right) => + compareStrings(left.name, right.name), + ) +} + +function assertUniqueDiscovery(packages: ReadonlyArray): void { + const sources = new Set() + for (const pkg of packages) { + const source = `${pkg.kind}\0${pkg.name}` + if (sources.has(source)) { + throw new Error(`Duplicate discovered source "${sourceEntry(pkg)}".`) + } + sources.add(source) + const skills = new Set() + for (const skill of pkg.skills) { + if (skills.has(skill.name)) { + throw new Error( + `Duplicate discovered skill "${skillSelectionId(pkg, skill)}".`, + ) + } + skills.add(skill.name) + } + } +} + +function validateScope(scope: string): void { + if (!/^@[a-z0-9][a-z0-9._-]*\/\*$/.test(scope)) { + throw new Error( + 'Scope selection must be an npm scope pattern such as "@tanstack/*".', + ) + } +} + +function assertExclusionsRepresentable( + packages: ReadonlyArray, + grouped: ReadonlyArray<{ + skills: ReadonlyArray<{ status: 'enabled' | 'excluded' }> + }>, + exclude: ReadonlySet, +): void { + if (exclude.size === 0) return + const patterns = [...exclude].map((pattern) => ({ + pattern, + matchers: compileExcludePatterns([pattern.trim()]), + })) + + for (const [index, pkg] of packages.entries()) { + const packageSkills = sortedSkills(pkg) + for (const [skillIndex, entry] of grouped[index]!.skills.entries()) { + if (entry.status !== 'enabled') continue + const skillName = packageSkills[skillIndex]!.name + const offending = patterns.find( + ({ matchers }) => + isPackageExcluded(pkg.name, matchers) || + isSkillExcluded(pkg.name, skillName, matchers), + ) + if (offending) { + throw new Error( + `Cannot write intent.exclude "${offending.pattern}": it would also hide "${sourceEntry(pkg)}#${skillName}", which this selection enables.`, + ) + } + } + } +} + +export function buildSkillSelectionPlan( + discovered: ReadonlyArray, + selection: SkillSelection, +): SkillSelectionPlan { + const packages = sortedPackages(discovered) + assertUniqueDiscovery(packages) + if (selection.mode === 'configured-policy') { + const sources = parseSkillSources(selection.skills) + const sourcePolicy = compileSkillSourcePolicy(sources) + const excludeMatchers = compileExcludePatterns(selection.exclude) + return { + skills: selection.skills, + exclude: selection.exclude, + packages: packages.map((pkg) => ({ + name: pkg.name, + kind: pkg.kind, + skills: sortedSkills(pkg).map((skill) => { + const id = skillSelectionId(pkg, skill) + const status = classifySkillPolicy( + pkg, + skill.name, + sources, + sourcePolicy, + excludeMatchers, + ) + if (status === 'pending') { + throw new Error( + `Configured policy leaves "${id}" pending. Add it to intent.skills or intent.exclude before non-interactive install.`, + ) + } + return { id, status } + }), + })), + } + } + const selected = new Set() + if (selection.mode === 'scope') validateScope(selection.scope) + if (selection.mode === 'individual') { + for (const id of selection.enabled) { + if (selected.has(id)) throw new Error(`Duplicate selected skill "${id}".`) + selected.add(id) + } + const discoveredIds = new Set( + packages.flatMap((pkg) => + sortedSkills(pkg).map((skill) => skillSelectionId(pkg, skill)), + ), + ) + for (const id of selected) { + if (!/^[^#\s]+#[^#\s]+$/.test(id) || !discoveredIds.has(id)) { + throw new Error(`Unknown selected skill "${id}".`) + } + } + } + + const skills = new Set() + const exclude = new Set() + const grouped = packages.map((pkg) => { + const packageSkills = sortedSkills(pkg) + const packageMatchesScope = + selection.mode === 'scope' && + pkg.kind === 'npm' && + new RegExp( + `^${selection.scope.slice(0, -1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ).test(pkg.name) + const packageEnabled = + selection.mode === 'all-found' || + packageMatchesScope || + (selection.mode === 'individual' && + packageSkills.some((skill) => + selected.has(skillSelectionId(pkg, skill)), + )) + if (selection.mode === 'scope') { + skills.add(selection.scope) + } else if (selection.mode === 'all-found') { + skills.add(sourceEntry(pkg)) + } + + const entries = packageSkills.map((skill) => { + const id = skillSelectionId(pkg, skill) + const enabled = selection.mode !== 'individual' || selected.has(id) + return { + id, + status: enabled ? ('enabled' as const) : ('excluded' as const), + } + }) + if (selection.mode === 'scope' && !packageMatchesScope) { + exclude.add(pkg.name) + return { + name: pkg.name, + kind: pkg.kind, + skills: entries.map((entry) => ({ + ...entry, + status: 'excluded' as const, + })), + } + } + if (selection.mode === 'individual' && !packageEnabled) { + exclude.add(pkg.name) + } else if (selection.mode === 'individual') { + const enabledEntries = entries.filter( + (entry) => entry.status === 'enabled', + ) + if (enabledEntries.length === entries.length) { + skills.add(sourceEntry(pkg)) + } else { + for (const entry of enabledEntries) skills.add(entry.id) + } + } + return { name: pkg.name, kind: pkg.kind, skills: entries } + }) + + assertExclusionsRepresentable(packages, grouped, exclude) + + return { + skills: [...skills].sort(compareStrings), + exclude: [...exclude].sort(compareStrings), + packages: grouped, + } +} + +function sourceKey(source: Pick): string { + return `${source.kind}\0${source.id}` +} + +function currentSkill( + skill: SkillEntry, + current: IntentLockfileSource | undefined, +): IntentLockfileSource['skills'][number] | undefined { + return current?.skills.find((entry) => entry.path === `skills/${skill.name}`) +} + +export function buildInstallDeltaInventory( + discovered: ReadonlyArray, + currentSources: ReadonlyArray, + lockResult: ReadIntentLockfileResult, + config: IntentConsumerConfig, +): InstallDeltaInventory { + assertUniqueDiscovery(discovered) + const sources = parseSkillSources(config.skills) + const sourcePolicy = compileSkillSourcePolicy(sources) + const excludes = compileExcludePatterns(config.exclude) + const currentByKey = new Map( + currentSources.map((source) => [sourceKey(source), source]), + ) + const lockedByKey = new Map( + lockResult.status === 'found' + ? lockResult.lockfile.sources.map((source) => [sourceKey(source), source]) + : [], + ) + const seen = new Set() + const packages = sortedPackages(discovered).map((pkg) => { + const key = sourceKey({ kind: pkg.kind, id: pkg.name }) + seen.add(key) + const current = currentByKey.get(key) + const locked = lockedByKey.get(key) + return { + name: pkg.name, + kind: pkg.kind, + skills: sortedSkills(pkg).map((skill) => { + const policy = classifySkillPolicy( + pkg, + skill.name, + sources, + sourcePolicy, + excludes, + ) + if (policy !== 'enabled') + return { id: skillSelectionId(pkg, skill), policy, lock: null } + const currentEntry = currentSkill(skill, current) + const lockedEntry = currentEntry + ? locked?.skills.find((entry) => entry.path === currentEntry.path) + : undefined + const lock: InventoryLockStatus = + lockedEntry === undefined || currentEntry === undefined + ? 'new' + : lockedEntry.contentHash === currentEntry.contentHash + ? 'accepted' + : 'changed' + return { + id: skillSelectionId(pkg, skill), + policy, + lock, + } + }), + } + }) + const removed: Array<{ + kind: IntentPackage['kind'] + id: string + path: string | null + }> = [] + if (lockResult.status === 'found') { + for (const source of lockResult.lockfile.sources) { + const current = currentByKey.get(sourceKey(source)) + if (!seen.has(sourceKey(source)) || !current) { + removed.push({ kind: source.kind, id: source.id, path: null }) + continue + } + for (const skill of source.skills) { + if (!current.skills.some((entry) => entry.path === skill.path)) { + removed.push({ kind: source.kind, id: source.id, path: skill.path }) + } + } + } + } + return { + packages, + removed: removed.sort((left, right) => { + const bySource = compareStrings( + `${left.kind}\0${left.id}`, + `${right.kind}\0${right.id}`, + ) + return bySource === 0 + ? compareStrings(left.path ?? '', right.path ?? '') + : bySource + }), + } +} diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts new file mode 100644 index 00000000..d0c15cf1 --- /dev/null +++ b/packages/intent/src/commands/install/prompts.ts @@ -0,0 +1,164 @@ +import { + cancel, + confirm, + groupMultiselect, + intro, + isCancel, + multiselect, + note, + outro, + select, +} from '@clack/prompts' +import { installTargetsForMethod } from './config.js' +import { skillSelectionId } from './plan.js' +import type { InstallConfirmation, InstallerPrompter } from './consumer.js' +import type { InstallMethod, InstallTarget } from './config.js' +import type { SkillSelection } from './plan.js' +import type { IntentPackage } from '../../shared/types.js' + +function cancelled(value: T | symbol): T | null { + if (!isCancel(value)) return value + cancel('Installation cancelled.') + return null +} + +function sourceLabel(pkg: IntentPackage): string { + return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name +} + +export function groupSkillOptions( + discovered: ReadonlyArray, +): Record< + string, + Array<{ value: string; label: string; hint: string | undefined }> +> { + return Object.fromEntries( + discovered.map((pkg) => [ + sourceLabel(pkg), + pkg.skills.map((skill) => ({ + value: skillSelectionId(pkg, skill), + label: skill.name, + hint: skill.description || undefined, + })), + ]), + ) +} + +export async function selectClackSkills( + discovered: ReadonlyArray, + includeModes = true, +): Promise { + note( + `${discovered.reduce((count, pkg) => count + pkg.skills.length, 0)} skills from ${discovered.length} packages`, + 'Skills found', + ) + if (includeModes) { + const mode = cancelled( + await select({ + message: 'Which skills do you want to enable?', + options: [ + { value: 'all-found', label: 'Enable all skills found' }, + { value: 'scope', label: 'Enable all @tanstack/* skills' }, + { value: 'individual', label: 'Select skills' }, + ], + }), + ) + if (!mode) return null + if (mode === 'all-found') return { mode } + if (mode === 'scope') return { mode, scope: '@tanstack/*' } + } + const enabled = cancelled( + await groupMultiselect({ + message: 'Select skills to enable', + options: groupSkillOptions(discovered), + required: false, + selectableGroups: true, + groupSpacing: 1, + }), + ) + return enabled ? { mode: 'individual', enabled } : null +} + +export function createClackInstallerPrompter(): InstallerPrompter { + intro('Configure TanStack Intent') + return { + advisory(message: string): void { + note(message, 'Automatic re-sync is not enabled') + }, + complete(message: string): void { + outro(message) + }, + async selectMethod(): Promise { + return cancelled( + await select({ + message: 'How do you want to install skills?', + options: [ + { value: 'symlink', label: 'Symlink skill folders' }, + { value: 'hooks', label: 'Install lifecycle hooks' }, + ], + }), + ) + }, + async selectTargets( + method: InstallMethod, + detected: ReadonlyArray, + ): Promise | null> { + const targets = installTargetsForMethod(method) + const supported = new Set(targets.map((target) => target.id)) + return cancelled( + await multiselect({ + message: 'Where do you want to install skills?', + options: targets.map((target) => ({ + value: target.id, + label: target.label, + })), + initialValues: detected.filter((target) => supported.has(target)), + required: true, + }), + ) + }, + async confirmSymlink(): Promise { + note( + 'Package updates may change linked skills before Intent verifies intent.lock. Intent detects drift when it runs again, but it cannot prevent an agent from reading changed content before that check.', + 'Symlinks expose live package skill content', + ) + return cancelled( + await confirm({ + message: 'Continue with symlinks?', + initialValue: false, + vertical: true, + }), + ) + }, + async confirmUserScopeHooks(): Promise { + note( + 'GitHub Copilot hooks are stored in your home directory and affect Copilot sessions in this and other repositories.', + 'GitHub Copilot hooks apply across repositories', + ) + return cancelled( + await confirm({ + message: + 'Allow Intent to write GitHub Copilot hooks in your home directory?', + initialValue: false, + vertical: true, + }), + ) + }, + async selectSkills( + discovered: ReadonlyArray, + ): Promise { + return selectClackSkills(discovered) + }, + async confirmInstall({ config, skillCount }): Promise { + return cancelled( + await select>({ + message: `Install ${skillCount} ${skillCount === 1 ? 'skill' : 'skills'} using ${config.install.method}?`, + options: [ + { value: 'install', label: 'Install' }, + { value: 'back', label: 'Go back' }, + ], + }), + ) + }, + } +} diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 0fc45c5e..d85550a3 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -10,6 +10,7 @@ import { } from './support.js' import type { GlobalScanFlags } from './support.js' import type { + IntentExcludedSkillSummary, IntentPackageSummary, IntentSkillList, IntentSkillSummary, @@ -19,6 +20,7 @@ import type { ScanResult } from '../shared/types.js' export interface ListCommandOptions extends GlobalScanFlags { json?: boolean showHidden?: boolean + why?: boolean } function printListDebug(result: IntentSkillList): void { @@ -81,15 +83,31 @@ function getPackageSkills( return skillsByPackageRoot.get(pkg.packageRoot) ?? [] } +function getExcludedPackageSummary( + skill: IntentExcludedSkillSummary, +): IntentPackageSummary { + return { + name: skill.packageName, + version: skill.packageVersion, + source: skill.packageSource, + packageRoot: skill.packageRoot, + skillCount: 0, + } +} + function formatLoadCommand( - skill: IntentSkillSummary, + skillUse: string, packageManager: ScanResult['packageManager'], scopeFlag: string, ): string { - return formatIntentCommand(packageManager, `load ${skill.use}${scopeFlag}`) + return formatIntentCommand(packageManager, `load ${skillUse}${scopeFlag}`) } -function printHiddenSources(result: IntentSkillList, audience: string): void { +function printHiddenSources( + result: IntentSkillList, + audience: string, + why: boolean, +): void { if (audience === 'agent') { console.log( 'Hidden skill sources are not revealed in agent sessions. Run this command outside the agent session to review candidates.', @@ -101,9 +119,15 @@ function printHiddenSources(result: IntentSkillList, audience: string): void { console.log('\nHidden skill sources:\n') for (const source of result.hiddenSources) { + const count = `${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'}` console.log( - ` ${source.name} (${source.skillCount} ${source.skillCount === 1 ? 'skill' : 'skills'})`, + source.hiddenSkills + ? ` ${source.name} (${count} not listed: ${source.hiddenSkills.join(', ')})` + : ` ${source.name} (${count})`, ) + if (why) { + console.log(' Hidden because not listed in intent.skills') + } } } @@ -111,9 +135,11 @@ export async function runListCommand( options: ListCommandOptions, ): Promise { const audience = detectIntentAudience() + const explain = audience === 'human' && options.why === true && !options.json const result = listIntentSkills({ ...coreOptionsFromGlobalFlags(options), audience, + why: explain, }) const noticeOptions = noticeOptionsFromGlobalFlags(options) printListDebug(result) @@ -131,16 +157,18 @@ export async function runListCommand( const { computeSkillNameWidth, printSkillTree, printTable } = await import('../shared/display.js') - if (result.packages.length === 0) { + if (result.packages.length === 0 && result.excludedSkills?.length === 0) { console.log('No intent-enabled packages found.') if (options.showHidden && result.hiddenSourceCount > 0) { - printHiddenSources(result, audience) + printHiddenSources(result, audience, explain) } if (result.warnings.length > 0) { console.log() printWarnings(result.warnings) } - printNotices(result.notices, noticeOptions) + if (audience === 'human') { + printNotices(result.notices, noticeOptions) + } return } @@ -148,25 +176,37 @@ export async function runListCommand( `\n${result.packages.length} intent-enabled packages, ${result.skills.length} skills\n`, ) - const rows = result.packages.map((pkg) => [ - pkg.name, - pkg.source, - pkg.version, - String(pkg.skillCount), - ]) - printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS'], rows) + if (audience === 'human') { + const rows = result.packages.map((pkg) => [ + pkg.name, + pkg.source, + pkg.version, + String(pkg.skillCount), + ]) + printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS'], rows) + } printVersionConflicts(result) if (options.showHidden) { - printHiddenSources(result, audience) + printHiddenSources(result, audience, explain) } - const skillsByPackageRoot = groupSkillsByPackageRoot(result.skills) - const allSkills = result.packages.map((pkg) => + const displaySkills = [...result.skills, ...(result.excludedSkills ?? [])] + const skillsByPackageRoot = groupSkillsByPackageRoot(displaySkills) + const displayPackages = [...result.packages] + for (const skill of result.excludedSkills ?? []) { + if (!displayPackages.some((pkg) => pkg.packageRoot === skill.packageRoot)) { + displayPackages.push(getExcludedPackageSummary(skill)) + } + } + const packagesWithSkills = displayPackages.filter( + (pkg) => getPackageSkills(pkg, skillsByPackageRoot).length > 0, + ) + const allSkills = packagesWithSkills.map((pkg) => getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ name: skill.skillName, - description: skill.description, + description: 'excluded' in skill ? '(excluded)' : skill.description, type: skill.type, })), ) @@ -178,15 +218,25 @@ export async function runListCommand( ? ' --global' : '' + if (audience === 'agent') { + console.log( + `Load a skill with \`${formatLoadCommand('', result.packageManager, scopeFlag)}\`.`, + ) + } + console.log(`\nSkills:\n`) - for (const pkg of result.packages) { + for (const pkg of packagesWithSkills) { console.log(` ${pkg.name}`) printSkillTree( getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ name: skill.skillName, - description: skill.description, - loadCommand: formatLoadCommand(skill, result.packageManager, scopeFlag), + description: 'excluded' in skill ? '(excluded)' : skill.description, + loadCommand: + audience === 'human' && !('excluded' in skill) + ? formatLoadCommand(skill.use, result.packageManager, scopeFlag) + : undefined, type: skill.type, + why: skill.why, })), { nameWidth, packageName: pkg.name, showTypes }, ) @@ -194,5 +244,7 @@ export async function runListCommand( } printWarnings(result.warnings) - printNotices(result.notices, noticeOptions) + if (audience === 'human') { + printNotices(result.notices, noticeOptions) + } } diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts new file mode 100644 index 00000000..53ea3bd1 --- /dev/null +++ b/packages/intent/src/commands/sync/command.ts @@ -0,0 +1,454 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fail } from '../../shared/cli-error.js' +import { compileExcludePatterns } from '../../core/excludes.js' +import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' +import { + readIntentLockfile, + writeIntentLockfile, +} from '../../core/lockfile/lockfile.js' +import { resolveProjectContext } from '../../core/project-context.js' +import { + applySourcePolicy, + compileSkillSourcePolicy, + scanForConfiguredIntents, +} from '../../core/source-policy.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' +import { + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from '../install/config.js' +import { buildSkillSelectionPlan } from '../install/plan.js' +import { updateIntentGitignore } from './gitignore.js' +import { reconcileManagedLinks } from './links.js' +import { buildSyncLinkPlan } from './plan.js' +import { + INSTALL_STATE_PATH, + readInstallStateForLinks, + writeInstallState, +} from './state.js' +import { toProjectRelativePath } from './targets.js' +import type { SkillSelection } from '../install/plan.js' +import type { LinkReconciliation } from './links.js' +import type { IntentPackage } from '../../shared/types.js' + +export interface SyncCommandOptions { + cwd?: string + dryRun?: boolean + json?: boolean +} + +export type NewDependencyDecision = 'review' | 'exclude' | 'later' + +export interface SyncReviewPrompter { + complete: (message: string) => void + reviewNewDependencies: ( + entries: ReadonlyArray, + ) => Promise + selectSkills: ( + packages: ReadonlyArray, + ) => Promise +} + +export type SyncReviewMode = 'interactive' | 'reminder' | 'fail' + +export interface SyncCommandRuntime { + review?: SyncReviewMode + prompts?: SyncReviewPrompter +} + +interface SyncPackageSummary { + name: string + skillCount: number +} + +interface SyncCommandResult { + created: Array + repaired: Array + removed: Array + unchanged: Array + conflicts: Array + newDependencies: Array + newSkills: Array + changed: Array +} + +function printReminder( + title: string, + entries: Array, + action: string, +): void { + if (entries.length === 0) return + const width = Math.max(...entries.map((entry) => entry.name.length)) + const packages = entries + .map( + (entry) => + `${entry.name.padEnd(width)} ${entry.skillCount} ${entry.skillCount === 1 ? 'skill' : 'skills'}`, + ) + .join('\n') + console.log(`${title}:\n\n${packages}\n\n${action}`) +} + +function writeGitignore(root: string, paths: Array): boolean { + const path = join(root, '.gitignore') + const before = existsSync(path) ? readFileSync(path, 'utf8') : null + const after = updateIntentGitignore(before, paths) + if (before === after) return false + writeFileSync(path, after, 'utf8') + return true +} + +function writeManagedLinkState(root: string, links: LinkReconciliation): void { + const entries = links.entries.map((entry) => ({ + ...entry, + path: toProjectRelativePath(root, entry.path), + })) + writeInstallState(root, { version: 1, entries }) + writeGitignore(root, [ + ...entries.map((entry) => entry.path), + INSTALL_STATE_PATH, + ]) +} + +function output( + result: SyncCommandResult, + json: boolean, + interactiveReview: boolean, +): void { + if (json) { + console.log(JSON.stringify(result)) + return + } + console.log( + `Intent sync: ${result.created.length} created, ${result.repaired.length} repaired, ${result.removed.length} removed.`, + ) + printReminder( + 'New dependencies with skills found', + result.newDependencies, + interactiveReview + ? 'Choose how to handle them below.' + : 'Run `intent install` to review and install them, or add them to `intent.exclude`.', + ) + printReminder( + 'New skills found in enabled dependencies', + result.newSkills, + 'Run `intent install` to review and install them.', + ) + printReminder( + 'Changed skill content', + result.changed, + 'Run `intent install` to review and accept the new baseline.', + ) + if (result.conflicts.length > 0) + console.log(`Conflicts: ${result.conflicts.join(', ')}.`) +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function sourceKey(source: Pick): string { + return `${source.kind}\0${source.name}` +} + +function sourceName(source: Pick): string { + return source.kind === 'workspace' ? `workspace:${source.name}` : source.name +} + +function shouldReviewInteractively( + options: SyncCommandOptions, + runtime: SyncCommandRuntime, +): boolean { + if ( + options.dryRun === true || + options.json === true || + process.env.npm_lifecycle_event === 'prepare' + ) { + return false + } + if (runtime.review !== undefined) return runtime.review === 'interactive' + return process.stdin.isTTY === true && process.stdout.isTTY === true +} + +async function reviewNewDependencies({ + config, + discovered, + lock, + packages, + prompts, + root, +}: { + config: ReturnType + discovered: Array + lock: Extract, { status: 'found' }> + packages: Array + prompts: SyncReviewPrompter + root: string +}): Promise { + const decision = await prompts.reviewNewDependencies( + packages.map((pkg) => ({ + name: sourceName(pkg), + skillCount: pkg.skills.length, + })), + ) + if (!decision || decision === 'later') { + prompts.complete('New dependencies remain pending.') + return + } + const packageJsonPath = join(root, 'package.json') + const packageJson = readFileSync(packageJsonPath, 'utf8') + if (decision === 'exclude') { + const sourcePolicy = compileSkillSourcePolicy( + parseSkillSources(config.skills), + ) + const updatedConfig = { + ...config, + exclude: [ + ...new Set([ + ...config.exclude, + ...packages.flatMap((pkg) => + sourcePolicy.permits(pkg.name, pkg.kind) + ? pkg.skills.map((skill) => `${pkg.name}#${skill.name}`) + : [pkg.name], + ), + ]), + ].sort(compareStrings), + } + writeTextFileAtomic( + packageJsonPath, + updateIntentConsumerConfigText(packageJson, updatedConfig), + ) + prompts.complete('Excluded new skill dependencies.') + return + } + + const selection = await prompts.selectSkills(packages) + if (!selection) { + prompts.complete('New dependencies remain pending.') + return + } + const selectionPlan = buildSkillSelectionPlan(packages, selection) + const configuredSources = parseSkillSources(config.skills) + const configuredPolicy = compileSkillSourcePolicy(configuredSources) + const addedSkills = new Set(selectionPlan.skills) + for (const pkg of selectionPlan.packages) { + const packageCovered = + configuredSources.mode === 'absent' || + configuredSources.mode === 'allow-all' || + configuredPolicy.matchers.some( + (matcher) => + matcher.matchesSkill === undefined && + matcher.matchesPackage(pkg.name, pkg.kind), + ) + if (packageCovered) { + addedSkills.delete(sourceName(pkg)) + for (const skill of pkg.skills) addedSkills.delete(skill.id) + continue + } + const hasSkillEntries = configuredPolicy.matchers.some( + (matcher) => + matcher.matchesSkill !== undefined && + matcher.matchesPackage(pkg.name, pkg.kind), + ) + if (hasSkillEntries) { + addedSkills.delete(sourceName(pkg)) + for (const skill of pkg.skills) { + if (skill.status === 'enabled') addedSkills.add(skill.id) + } + } + } + const updatedConfig = { + ...config, + skills: [...new Set([...config.skills, ...addedSkills])].sort( + compareStrings, + ), + exclude: [...new Set([...config.exclude, ...selectionPlan.exclude])].sort( + compareStrings, + ), + } + const policy = applySourcePolicy( + { packages: discovered }, + { + config: parseSkillSources(updatedConfig.skills), + excludeMatchers: compileExcludePatterns(updatedConfig.exclude), + }, + ) + const reviewedKeys = new Set(packages.map(sourceKey)) + const currentSources = buildCurrentLockfileSources(policy.packages) + const prospectiveLock = { + lockfileVersion: 1 as const, + sources: [ + ...lock.lockfile.sources.filter( + (source) => !reviewedKeys.has(`${source.kind}\0${source.id}`), + ), + ...currentSources.filter((source) => + reviewedKeys.has(`${source.kind}\0${source.id}`), + ), + ], + } + const expected = buildSyncLinkPlan({ + config: updatedConfig, + currentSources, + discovered, + lock: { status: 'found', lockfile: prospectiveLock }, + packages: policy.packages, + root, + }).expected + const stateResult = readInstallStateForLinks(root) + const preflight = reconcileManagedLinks({ + dryRun: true, + expected, + stateResult, + }) + if (preflight.conflicts.length > 0) { + fail( + `Intent sync found managed link conflicts: ${preflight.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } + writeTextFileAtomic( + packageJsonPath, + updateIntentConsumerConfigText(packageJson, updatedConfig), + ) + writeIntentLockfile(join(root, 'intent.lock'), prospectiveLock) + const links = reconcileManagedLinks({ + dryRun: false, + expected, + stateResult, + }) + writeManagedLinkState(root, links) + prompts.complete( + 'Installed selected skills using the existing delivery settings.', + ) +} + +export async function runSyncCommand( + options: SyncCommandOptions, + runtime: SyncCommandRuntime = {}, +): Promise { + const context = resolveProjectContext({ cwd: options.cwd ?? process.cwd() }) + const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd + const packageJsonPath = join(root, 'package.json') + if (!existsSync(packageJsonPath)) { + fail( + 'Intent sync requires intent.install configuration and intent.lock. Run `intent install` first.', + ) + } + const packageJson = readFileSync(packageJsonPath, 'utf8') + const config = readIntentConsumerConfig(packageJson) + const lock = readIntentLockfile(join(root, 'intent.lock')) + if (!config.install || lock.status !== 'found') { + fail( + 'Intent sync requires intent.install configuration and intent.lock. Run `intent install` first.', + ) + } + if (config.install.method !== 'symlink') { + fail( + `Intent sync adapter for method "${config.install.method}" is not implemented yet.`, + ) + } + + const { discovered, policy } = scanForConfiguredIntents({ + root, + config: parseSkillSources(config.skills), + exclude: config.exclude, + }) + const { expected, inventory } = buildSyncLinkPlan({ + config, + currentSources: buildCurrentLockfileSources(policy.packages), + discovered, + lock, + packages: policy.packages, + root, + }) + const links = reconcileManagedLinks({ + dryRun: options.dryRun === true, + expected, + stateResult: readInstallStateForLinks(root), + }) + const summaries = { + newDependencies: inventory.packages + .map((pkg) => ({ + name: sourceName(pkg), + skillCount: pkg.skills.filter((skill) => skill.policy === 'pending') + .length, + })) + .filter((entry) => entry.skillCount > 0), + newSkills: inventory.packages + .map((pkg) => ({ + name: sourceName(pkg), + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'new', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + changed: inventory.packages + .map((pkg) => ({ + name: sourceName(pkg), + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'changed', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + } + const result = { + created: links.created.map((path) => toProjectRelativePath(root, path)), + repaired: links.repaired.map((path) => toProjectRelativePath(root, path)), + removed: links.removed.map((path) => toProjectRelativePath(root, path)), + unchanged: links.unchanged.map((path) => toProjectRelativePath(root, path)), + conflicts: links.conflicts.map((path) => toProjectRelativePath(root, path)), + ...summaries, + } + if (!options.dryRun) { + writeManagedLinkState(root, links) + } + const interactiveReview = + summaries.newDependencies.length > 0 && + shouldReviewInteractively(options, runtime) + output(result, options.json === true, interactiveReview) + if (links.conflicts.length > 0) + fail('Intent sync found managed link conflicts.') + if ( + runtime.review === 'fail' && + (summaries.newDependencies.length > 0 || + summaries.newSkills.length > 0 || + summaries.changed.length > 0) + ) { + fail('Intent sync requires review before automation can continue.') + } + if (interactiveReview) { + const pendingSkills = new Map( + inventory.packages.map((pkg) => [ + `${pkg.kind}\0${pkg.name}`, + new Set( + pkg.skills + .filter((skill) => skill.policy === 'pending') + .map((skill) => skill.id.slice(skill.id.indexOf('#') + 1)), + ), + ]), + ) + const packages = discovered.flatMap((pkg) => { + const skills = pendingSkills.get(sourceKey(pkg)) + if (!skills || skills.size === 0) return [] + return [ + { + ...pkg, + skills: pkg.skills.filter((skill) => skills.has(skill.name)), + }, + ] + }) + const prompts = + runtime.prompts ?? + (await import('./prompts.js')).createClackSyncReviewPrompter() + await reviewNewDependencies({ + config, + discovered, + lock, + packages, + prompts, + root, + }) + } +} diff --git a/packages/intent/src/commands/sync/gitignore.ts b/packages/intent/src/commands/sync/gitignore.ts new file mode 100644 index 00000000..1dab932c --- /dev/null +++ b/packages/intent/src/commands/sync/gitignore.ts @@ -0,0 +1,19 @@ +const START = '# intent skill links:start' +const END = '# intent skill links:end' + +export function updateIntentGitignore( + text: string | null, + paths: ReadonlyArray, +): string { + const eol = text?.includes('\r\n') ? '\r\n' : '\n' + const prefix = text ?? '' + const entries = [...new Set(paths)].sort() + const block = [START, ...entries, END].join(eol) + const matcher = new RegExp( + `${START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ) + if (matcher.test(prefix)) return prefix.replace(matcher, block) + if (prefix === '') return `${block}${eol}` + const separator = prefix.endsWith('\n') ? '' : eol + return `${prefix}${separator}${block}${eol}` +} diff --git a/packages/intent/src/commands/sync/links.ts b/packages/intent/src/commands/sync/links.ts new file mode 100644 index 00000000..5e28b18b --- /dev/null +++ b/packages/intent/src/commands/sync/links.ts @@ -0,0 +1,200 @@ +import { + lstatSync, + mkdirSync, + readlinkSync, + realpathSync, + rmdirSync, + symlinkSync, + unlinkSync, +} from 'node:fs' +import { dirname, isAbsolute, relative, resolve } from 'node:path' +import type { InstallStateEntry, ReadInstallStateResult } from './state.js' + +export interface ExpectedLink { + path: string + targetDirectory: string + alias: string + source: { kind: 'npm' | 'workspace'; id: string } + skillPath: string + sourceDirectory: string + packageRoot: string +} + +export interface LinkReconciliation { + created: Array + repaired: Array + removed: Array + unchanged: Array + conflicts: Array + entries: Array +} + +function exists(path: string): boolean { + try { + lstatSync(path) + return true + } catch { + return false + } +} + +function resolveLinkTarget(path: string): string | null { + try { + const target = readlinkSync(path) + return resolve(dirname(path), target) + } catch { + return null + } +} + +function isLink(path: string): boolean { + try { + return lstatSync(path).isSymbolicLink() + } catch { + return false + } +} + +function isInside(path: string, parent: string): boolean { + const value = relative(parent, path) + return value === '' || (!value.startsWith('..') && !isAbsolute(value)) +} + +function sourceTarget(expected: ExpectedLink): string | null { + try { + const packageRoot = realpathSync(expected.packageRoot) + const sourceDirectory = realpathSync(expected.sourceDirectory) + return isInside(sourceDirectory, packageRoot) ? sourceDirectory : null + } catch { + return null + } +} + +function stateEntry( + expected: ExpectedLink, + linkTarget: string, +): InstallStateEntry { + return { + targetDirectory: expected.targetDirectory, + path: expected.path, + alias: expected.alias, + source: expected.source, + skillPath: expected.skillPath, + linkTarget, + } +} + +function createLink(path: string, target: string): void { + mkdirSync(dirname(path), { recursive: true }) + if (process.platform === 'win32') { + symlinkSync(target, path, 'junction') + return + } + symlinkSync(relative(dirname(path), target), path, 'dir') +} + +// `rmSync` with recursive+force silently leaves some directory symlinks in place. +// On Windows a directory symlink or junction needs `rmdirSync`, not `unlinkSync`. +function removeLink(path: string): void { + try { + unlinkSync(path) + } catch { + rmdirSync(path) + } +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function reconcileManagedLinks({ + dryRun, + expected, + stateResult, +}: { + dryRun: boolean + expected: ReadonlyArray + stateResult: ReadInstallStateResult +}): LinkReconciliation { + const result: LinkReconciliation = { + created: [], + repaired: [], + removed: [], + unchanged: [], + conflicts: [], + entries: [], + } + const expectedByPath = new Map(expected.map((entry) => [entry.path, entry])) + const prior = stateResult.status === 'found' ? stateResult.state.entries : [] + const priorByPath = new Map(prior.map((entry) => [entry.path, entry])) + + for (const entry of [...expected].sort((left, right) => + compareStrings(left.path, right.path), + )) { + const target = sourceTarget(entry) + if (!target) { + result.conflicts.push(entry.path) + continue + } + const priorEntry = priorByPath.get(entry.path) + if (!exists(entry.path)) { + if (!dryRun) createLink(entry.path, target) + result.created.push(entry.path) + result.entries.push(stateEntry(entry, target)) + continue + } + if (!isLink(entry.path) || !priorEntry) { + result.conflicts.push(entry.path) + if (priorEntry) result.entries.push(priorEntry) + continue + } + const current = resolveLinkTarget(entry.path) + if (current === target) { + result.unchanged.push(entry.path) + result.entries.push(stateEntry(entry, target)) + continue + } + if (current === priorEntry.linkTarget) { + if (!dryRun) { + removeLink(entry.path) + createLink(entry.path, target) + } + result.repaired.push(entry.path) + result.entries.push(stateEntry(entry, target)) + continue + } + result.conflicts.push(entry.path) + result.entries.push(priorEntry) + } + + if (stateResult.status === 'found') { + for (const priorEntry of prior) { + if (expectedByPath.has(priorEntry.path)) continue + if (!exists(priorEntry.path)) { + result.removed.push(priorEntry.path) + continue + } + if ( + isLink(priorEntry.path) && + resolveLinkTarget(priorEntry.path) === priorEntry.linkTarget + ) { + if (!dryRun) removeLink(priorEntry.path) + result.removed.push(priorEntry.path) + continue + } + result.conflicts.push(priorEntry.path) + result.entries.push(priorEntry) + } + } + + return { + created: result.created.sort(compareStrings), + repaired: result.repaired.sort(compareStrings), + removed: result.removed.sort(compareStrings), + unchanged: result.unchanged.sort(compareStrings), + conflicts: [...new Set(result.conflicts)].sort(compareStrings), + entries: result.entries.sort((left, right) => + compareStrings(left.path, right.path), + ), + } +} diff --git a/packages/intent/src/commands/sync/plan.ts b/packages/intent/src/commands/sync/plan.ts new file mode 100644 index 00000000..967c9425 --- /dev/null +++ b/packages/intent/src/commands/sync/plan.ts @@ -0,0 +1,95 @@ +import { dirname, join, resolve } from 'node:path' +import { buildInstallDeltaInventory } from '../install/plan.js' +import { + createSyncAliases, + resolveSyncTargetDirectories, + toProjectRelativePath, +} from './targets.js' +import type { IntentConsumerConfig } from '../install/config.js' +import type { InstallDeltaInventory } from '../install/plan.js' +import type { ExpectedLink } from './links.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../../core/lockfile/lockfile.js' +import type { IntentPackage } from '../../shared/types.js' + +function findSkill(pkg: IntentPackage, name: string) { + return pkg.skills.find((skill) => skill.name === name) +} + +export function buildSyncLinkPlan({ + config, + currentSources, + discovered, + lock, + packages, + root, +}: { + config: IntentConsumerConfig + currentSources: ReadonlyArray + discovered: ReadonlyArray + lock: ReadIntentLockfileResult + packages: ReadonlyArray + root: string +}): { + expected: Array + inventory: InstallDeltaInventory +} { + if (!config.install) + throw new Error('Intent install configuration is missing.') + const inventory = buildInstallDeltaInventory( + discovered, + currentSources, + lock, + config, + ) + const aliases = new Map( + createSyncAliases( + packages.flatMap((pkg) => + pkg.skills.map((skill) => ({ + kind: pkg.kind, + id: pkg.name, + skill: skill.name, + })), + ), + ).map((entry) => [ + `${entry.kind}\0${entry.id}\0${entry.skill}`, + entry.alias, + ]), + ) + const sources = new Map( + discovered.map((pkg) => [`${pkg.kind}\0${pkg.name}`, pkg]), + ) + const accepted = inventory.packages.flatMap((pkg) => { + const source = sources.get(`${pkg.kind}\0${pkg.name}`) + if (!source) return [] + return pkg.skills.flatMap((skill) => { + if (skill.policy !== 'enabled' || skill.lock !== 'accepted') return [] + const sourceSkill = findSkill( + source, + skill.id.slice(skill.id.indexOf('#') + 1), + ) + return sourceSkill ? [{ pkg, skill: sourceSkill, source }] : [] + }) + }) + const targetDirectories = resolveSyncTargetDirectories( + root, + config.install.targets, + ) + const expected = accepted.flatMap(({ pkg, skill, source }) => { + const identity = `${pkg.kind}\0${pkg.name}\0${skill.name}` + const alias = aliases.get(identity) + if (!alias) throw new Error(`Missing sync alias for ${identity}.`) + return targetDirectories.map((target) => ({ + path: join(target.path, alias), + targetDirectory: toProjectRelativePath(root, target.path), + alias, + source: { kind: pkg.kind, id: pkg.name }, + skillPath: `skills/${skill.name}`, + sourceDirectory: resolve(root, dirname(skill.path)), + packageRoot: source.packageRoot, + })) + }) + return { expected, inventory } +} diff --git a/packages/intent/src/commands/sync/prepare.ts b/packages/intent/src/commands/sync/prepare.ts new file mode 100644 index 00000000..e598f624 --- /dev/null +++ b/packages/intent/src/commands/sync/prepare.ts @@ -0,0 +1,50 @@ +import { applyEdits, modify, parse } from 'jsonc-parser' + +function formattingOptions(text: string): { + eol: string + insertSpaces: boolean + tabSize: number +} { + const indentation = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ' + return { + eol: text.includes('\r\n') ? '\r\n' : '\n', + insertSpaces: !indentation.includes('\t'), + tabSize: indentation.includes('\t') ? 1 : indentation.length, + } +} + +function containsIntentSync(value: string): boolean { + return value + .split('&&') + .some((segment) => /^\s*intent sync(?:\s|$)/.test(segment)) +} + +export function wireIntentSyncPrepare(text: string): string { + const errors: Array<{ error: number; offset: number; length: number }> = [] + const bom = text.startsWith('\ufeff') ? '\ufeff' : '' + const body = bom ? text.slice(1) : text + const value = parse(body, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as Record | null + if (errors.length > 0 || !value || typeof value !== 'object') { + throw new Error('Invalid package.json JSONC.') + } + const scripts = value.scripts + const prepare = + scripts && typeof scripts === 'object' && !Array.isArray(scripts) + ? (scripts as Record).prepare + : undefined + if (typeof prepare === 'string' && containsIntentSync(prepare)) return text + const next = + typeof prepare === 'string' && prepare.trim() + ? `${prepare} && intent sync` + : 'intent sync' + const updated = applyEdits( + body, + modify(body, ['scripts', 'prepare'], next, { + formattingOptions: formattingOptions(body), + }), + ) + return `${bom}${updated}` +} diff --git a/packages/intent/src/commands/sync/prompts.ts b/packages/intent/src/commands/sync/prompts.ts new file mode 100644 index 00000000..aaacea66 --- /dev/null +++ b/packages/intent/src/commands/sync/prompts.ts @@ -0,0 +1,27 @@ +import { cancel, isCancel, outro, select } from '@clack/prompts' +import { selectClackSkills } from '../install/prompts.js' +import type { NewDependencyDecision, SyncReviewPrompter } from './command.js' + +export function createClackSyncReviewPrompter(): SyncReviewPrompter { + return { + complete(message: string): void { + outro(message) + }, + async reviewNewDependencies(): Promise { + const decision = await select({ + message: 'How do you want to handle these dependencies?', + options: [ + { value: 'review', label: 'Review and install' }, + { value: 'exclude', label: 'Exclude these packages' }, + { value: 'later', label: 'Remind me later' }, + ], + }) + if (!isCancel(decision)) return decision + cancel('Sync review cancelled. New dependencies remain pending.') + return null + }, + selectSkills(packages) { + return selectClackSkills(packages, false) + }, + } +} diff --git a/packages/intent/src/commands/sync/state.ts b/packages/intent/src/commands/sync/state.ts new file mode 100644 index 00000000..e3e64642 --- /dev/null +++ b/packages/intent/src/commands/sync/state.ts @@ -0,0 +1,118 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' + +export const INSTALL_STATE_PATH = '.intent/install-state.json' + +export interface InstallStateEntry { + targetDirectory: string + path: string + alias: string + source: { kind: 'npm' | 'workspace'; id: string } + skillPath: string + linkTarget: string +} + +export interface InstallState { + version: 1 + entries: Array +} + +export type ReadInstallStateResult = + | { status: 'missing' } + | { status: 'malformed' } + | { status: 'found'; state: InstallState } + +function compareEntry( + left: InstallStateEntry, + right: InstallStateEntry, +): number { + return left.path < right.path ? -1 : left.path > right.path ? 1 : 0 +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function parseEntry(value: unknown): InstallStateEntry | null { + if (!isRecord(value) || !isRecord(value.source)) return null + const keys = Object.keys(value).sort().join(',') + if (keys !== 'alias,linkTarget,path,skillPath,source,targetDirectory') + return null + if (Object.keys(value.source).sort().join(',') !== 'id,kind') return null + if ( + typeof value.targetDirectory !== 'string' || + typeof value.path !== 'string' || + typeof value.alias !== 'string' || + typeof value.skillPath !== 'string' || + typeof value.linkTarget !== 'string' || + typeof value.source.id !== 'string' || + (value.source.kind !== 'npm' && value.source.kind !== 'workspace') + ) { + return null + } + return { + targetDirectory: value.targetDirectory, + path: value.path, + alias: value.alias, + source: { kind: value.source.kind, id: value.source.id }, + skillPath: value.skillPath, + linkTarget: value.linkTarget, + } +} + +export function parseInstallState(text: string): InstallState | null { + try { + const parsed: unknown = JSON.parse(text) + if ( + !isRecord(parsed) || + parsed.version !== 1 || + !Array.isArray(parsed.entries) + ) { + return null + } + if (Object.keys(parsed).sort().join(',') !== 'entries,version') return null + const entries = parsed.entries.map(parseEntry) + if (entries.some((entry) => entry === null)) return null + const typed = entries as Array + if (new Set(typed.map((entry) => entry.path)).size !== typed.length) + return null + return { version: 1, entries: [...typed].sort(compareEntry) } + } catch { + return null + } +} + +export function serializeInstallState(state: InstallState): string { + return `${JSON.stringify({ version: 1, entries: [...state.entries].sort(compareEntry) }, null, 2)}\n` +} + +export function readInstallState(root: string): ReadInstallStateResult { + const path = join(root, INSTALL_STATE_PATH) + if (!existsSync(path)) return { status: 'missing' } + const state = parseInstallState(readFileSync(path, 'utf8')) + return state ? { status: 'found', state } : { status: 'malformed' } +} + +export function readInstallStateForLinks(root: string): ReadInstallStateResult { + const result = readInstallState(root) + if (result.status !== 'found') return result + return { + status: 'found', + state: { + version: 1, + entries: result.state.entries.map((entry) => ({ + ...entry, + path: join(root, ...entry.path.split('/')), + })), + }, + } +} + +export function writeInstallState(root: string, state: InstallState): boolean { + const path = join(root, INSTALL_STATE_PATH) + const content = serializeInstallState(state) + if (existsSync(path) && readFileSync(path, 'utf8') === content) return false + writeTextFileAtomic(path, content) + return true +} diff --git a/packages/intent/src/commands/sync/targets.ts b/packages/intent/src/commands/sync/targets.ts new file mode 100644 index 00000000..12290abc --- /dev/null +++ b/packages/intent/src/commands/sync/targets.ts @@ -0,0 +1,91 @@ +import { createHash } from 'node:crypto' +import { join, relative, resolve, sep } from 'node:path' +import type { InstallTarget } from '../install/config.js' + +export interface SyncTargetDirectory { + id: InstallTarget + path: string +} + +const TARGETS: Readonly> = { + agents: '.agents/skills', + github: '.github/skills', + vscode: '.github/skills', + cursor: '.cursor/skills', + codex: '.codex/skills', + claude: '.claude/skills', +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function toProjectRelativePath(root: string, path: string): string { + return relative(resolve(root), resolve(path)).split(sep).join('/') +} + +export function resolveSyncTargetDirectories( + root: string, + targets: ReadonlyArray, +): Array { + const unique = new Map() + for (const id of targets) { + const path = join(root, TARGETS[id]) + const relativePath = toProjectRelativePath(root, path) + if (!unique.has(relativePath)) unique.set(relativePath, { id, path }) + } + return [...unique.values()].sort((left, right) => + compareStrings( + toProjectRelativePath(root, left.path), + toProjectRelativePath(root, right.path), + ), + ) +} + +function sanitize(value: string): string { + return value + .replace(/^@/, '') + .replace(/[\\/]+/g, '-') + .replace(/[^a-zA-Z0-9-]+/g, '-') + .toLowerCase() + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') +} + +export interface SyncAliasInput { + kind: 'npm' | 'workspace' + id: string + skill: string +} + +export interface SyncAlias extends SyncAliasInput { + alias: string +} + +export function createSyncAliases( + inputs: ReadonlyArray, +): Array { + const preliminary = inputs.map((input) => ({ + ...input, + alias: `${input.kind}-${sanitize(input.id)}-${sanitize(input.skill)}`, + })) + const counts = new Map() + for (const entry of preliminary) { + counts.set(entry.alias, (counts.get(entry.alias) ?? 0) + 1) + } + return preliminary + .map((entry) => { + if (counts.get(entry.alias) === 1) return entry + const identity = `${entry.kind}:${entry.id}#${entry.skill}` + const suffix = createHash('sha256') + .update(identity) + .digest('hex') + .slice(0, 8) + return { ...entry, alias: `${entry.alias}-${suffix}` } + }) + .sort((left, right) => { + const leftIdentity = `${left.kind}\0${left.id}\0${left.skill}` + const rightIdentity = `${right.kind}\0${right.id}\0${right.skill}` + return compareStrings(leftIdentity, rightIdentity) + }) +} diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 81b3e3e7..74856276 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -142,8 +142,18 @@ export function isPackageExcluded( ) } +export function findPackageExcludeMatch( + packageName: string, + matchers: Array, +): ExcludeMatcher | undefined { + return matchers.find( + (matcher) => + matcher.matchesSkill === undefined && matcher.matchesPackage(packageName), + ) +} + // A prefixed skill is loadable by its short alias too; an exclude must match either form. -function skillNameVariants( +export function skillNameVariants( packageName: string, skillName: string, ): Array { @@ -168,6 +178,19 @@ export function isSkillExcluded( }) } +export function findSkillExcludeMatch( + packageName: string, + skillName: string, + matchers: Array, +): ExcludeMatcher | undefined { + const variants = skillNameVariants(packageName, skillName) + return matchers.find((matcher) => { + if (!matcher.matchesPackage(packageName)) return false + if (matcher.matchesSkill === undefined) return true + return variants.some((variant) => matcher.matchesSkill!(variant)) + }) +} + export function warningMentionsPackage( warning: string, packageName: string, diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index d24f7a95..02d47147 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -1,4 +1,4 @@ -import { isAbsolute, relative, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { createIntentFsCache } from '../discovery/fs-cache.js' import { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' import { formatSkillUse, parseSkillUse } from '../skills/use.js' @@ -7,14 +7,17 @@ import { getEffectiveExcludePatterns, } from './excludes.js' import { rewriteLoadedSkillMarkdownDestinations } from './markdown.js' +import { computeSkillContentHash } from './lockfile/hash.js' +import { readIntentLockfile } from './lockfile/lockfile.js' import { resolveSkillUseFastPath } from './load-resolution.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, - isSourcePermitted, + compileSkillSourcePolicy, packageNotListedRefusal, readSkillSourcesConfig, scanForPolicedIntents, + skillNotListedRefusal, } from './source-policy.js' import type { ResolveSkillResult } from '../skills/resolver.js' import type { IntentFsCache } from '../discovery/fs-cache.js' @@ -23,6 +26,7 @@ import type { ScanOptions, ScanScope } from '../shared/types.js' import type { IntentCoreErrorCode, IntentCoreOptions, + IntentExcludedSkillSummary, IntentSkillList, IntentSkillSummary, LoadedIntentSkill, @@ -33,6 +37,7 @@ import type { export type { IntentCoreErrorCode, IntentCoreOptions, + IntentExcludedSkillSummary, IntentPackageSummary, IntentSkillListDebug, IntentSkillList, @@ -101,13 +106,20 @@ export function listIntentSkills( const scanOptions = toScanOptions(options) const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) - const { hiddenSourceCount, hiddenSources, scan, excludePatterns } = - scanForPolicedIntents({ - cwd, - scanOptions: withFsCache(scanOptions, fsCache), - coreOptions: options, - context: projectContext, - }) + const { + hiddenSourceCount, + hiddenSources, + excludedSkills, + scan, + excludePatterns, + config, + sourcePolicy, + } = scanForPolicedIntents({ + cwd, + scanOptions: withFsCache(scanOptions, fsCache), + coreOptions: options, + context: projectContext, + }) const packages = scan.packages const skills = packages.flatMap((pkg) => pkg.skills.map((skill): IntentSkillSummary => { @@ -121,6 +133,22 @@ export function listIntentSkills( description: skill.description, type: skill.type, framework: skill.framework, + why: options.why + ? config.mode === 'absent' + ? 'Available because intent.skills is not set' + : config.mode === 'allow-all' + ? 'Allowed because intent.skills allows all sources' + : (() => { + const decision = sourcePolicy.explainPermitsSkill( + pkg.name, + skill.name, + pkg.kind, + ) + return decision.source + ? `Allowed by intent.skills[${JSON.stringify(decision.source.raw)}]` + : undefined + })() + : undefined, } }), ) @@ -142,6 +170,24 @@ export function listIntentSkills( conflicts: scan.conflicts, } + if (options.why) { + result.excludedSkills = excludedSkills.map( + ({ package: pkg, skill, pattern }): IntentExcludedSkillSummary => ({ + use: formatSkillUse(pkg.name, skill.name), + packageName: pkg.name, + packageRoot: pkg.packageRoot, + packageVersion: pkg.version, + packageSource: pkg.source, + skillName: skill.name, + description: skill.description, + type: skill.type, + framework: skill.framework, + why: `Excluded by intent.exclude[${JSON.stringify(pattern)}]`, + excluded: true, + }), + ) + } + if (options.debug) { result.debug = { cwd, @@ -179,6 +225,8 @@ function toResolvedIntentSkill( use: string, resolved: ResolveSkillResult, readFs: ReadFs, + kind: 'npm' | 'workspace', + lockRoot: string, debug?: LoadedIntentSkillDebug, ): { realPackageRoot: string @@ -208,6 +256,38 @@ function toResolvedIntentSkill( ) } + const lock = readIntentLockfile(join(lockRoot, 'intent.lock')) + if (lock.status === 'found') { + const skillDirectory = dirname(realResolvedPath) + const relativeSkillDirectory = relative(realPackageRoot, skillDirectory) + const skillPath = + sep === '\\' + ? relativeSkillDirectory.split(sep).join('/') + : relativeSkillDirectory + const lockedSkill = lock.lockfile.sources + .find( + (source) => source.kind === kind && source.id === resolved.packageName, + ) + ?.skills.find((skill) => skill.path === skillPath) + if (!lockedSkill) { + throw new IntentCoreError( + 'skill-not-accepted', + `Cannot load skill use "${use}": skill is not accepted in intent.lock.`, + ) + } + const contentHash = computeSkillContentHash({ + packageRoot: realPackageRoot, + skillDir: skillDirectory, + fs: readFs, + }) + if (contentHash !== lockedSkill.contentHash) { + throw new IntentCoreError( + 'skill-content-changed', + `Cannot load skill use "${use}": installed content does not match intent.lock.`, + ) + } + } + const result: ResolvedIntentSkill = { path: resolved.path, packageRoot: resolved.packageRoot, @@ -283,11 +363,17 @@ function resolveIntentSkillInCwd( const fsCache = createIntentFsCache() const projectContext = resolveProjectContext({ cwd }) + const lockRoot = + projectContext.workspaceRoot ?? projectContext.packageRoot ?? cwd const excludePatterns = getEffectiveExcludePatterns(options, projectContext) const excludeMatchers = compileExcludePatterns(excludePatterns) const config = readSkillSourcesConfig(cwd, projectContext) + const sourcePolicy = compileSkillSourcePolicy(config) - const refusal = checkLoadAllowed(use, parsedUse, { config, excludeMatchers }) + const refusal = checkLoadAllowed(use, parsedUse, { + sourcePolicy, + excludeMatchers, + }) if (refusal) { throw new IntentCoreError(refusal.code, refusal.message) } @@ -302,10 +388,22 @@ function resolveIntentSkillInCwd( fsCache, ) if (fastPathResolved) { + if (!sourcePolicy.permits(parsedUse.packageName, fastPathResolved.kind)) { + const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) + throw new IntentCoreError(lateRefusal.code, lateRefusal.message) + } if ( - !isSourcePermitted(config, parsedUse.packageName, fastPathResolved.kind) + !sourcePolicy.permitsSkill( + parsedUse.packageName, + parsedUse.skillName, + fastPathResolved.kind, + ) ) { - const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) + const lateRefusal = skillNotListedRefusal( + use, + parsedUse.packageName, + parsedUse.skillName, + ) throw new IntentCoreError(lateRefusal.code, lateRefusal.message) } return toResolvedIntentSkill( @@ -313,6 +411,8 @@ function resolveIntentSkillInCwd( use, fastPathResolved, fsCache.getReadFs(), + fastPathResolved.kind, + lockRoot, options.debug ? createLoadedSkillDebug({ cwd, @@ -331,11 +431,30 @@ function resolveIntentSkillInCwd( scanOptions: withFsCache(scanOptions, fsCache), coreOptions: options, context: projectContext, + config, }) if (droppedNames.includes(parsedUse.packageName)) { const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) throw new IntentCoreError(lateRefusal.code, lateRefusal.message) } + const survivingPackage = scanResult.packages.find( + (pkg) => pkg.name === parsedUse.packageName, + ) + if ( + survivingPackage && + !sourcePolicy.permitsSkill( + parsedUse.packageName, + parsedUse.skillName, + survivingPackage.kind, + ) + ) { + const lateRefusal = skillNotListedRefusal( + use, + parsedUse.packageName, + parsedUse.skillName, + ) + throw new IntentCoreError(lateRefusal.code, lateRefusal.message) + } let resolved: ReturnType try { resolved = resolveSkillUse(use, scanResult) @@ -353,6 +472,8 @@ function resolveIntentSkillInCwd( use, resolved, fsCache.getReadFs(), + resolved.kind, + lockRoot, options.debug ? createLoadedSkillDebug({ cwd, diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index b69dfc63..1dec200b 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -181,9 +181,7 @@ function getWorkspaceLoadFastPathCandidateDirs( return candidates } -export interface FastPathResolveResult extends ResolveSkillResult { - kind: 'npm' | 'workspace' -} +export type FastPathResolveResult = ResolveSkillResult function resolveScannedPackageSkill( scanned: ReturnType, diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts new file mode 100644 index 00000000..fb74b3a3 --- /dev/null +++ b/packages/intent/src/core/lockfile/hash.ts @@ -0,0 +1,235 @@ +import { isUtf8 } from 'node:buffer' +import { createHash } from 'node:crypto' +import { opendirSync } from 'node:fs' +import { isAbsolute, join, relative, resolve } from 'node:path' +import { nodeReadFs } from '../../shared/utils.js' +import type { Dirent } from 'node:fs' +import type { ReadFs } from '../../shared/utils.js' + +const HASH_LIMITS = { + maxRecursionDepth: 32, + maxEntryCount: 1000, + maxFileCount: 1000, + maxFileBytes: 4 * 1024 * 1024, + maxTotalBytes: 16 * 1024 * 1024, +} as const + +export interface ComputeSkillContentHashOptions { + packageRoot: string + skillDir: string + fs?: HashReadFs +} + +type HashEntry = { path: string; content: Buffer } +type HashReadFs = ReadFs & { opendirSync?: typeof opendirSync } + +const nodeHashReadFs: HashReadFs = { ...nodeReadFs, opendirSync } +const HASH_ENTRY_SEPARATOR = Buffer.from([0]) + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function isWithin(parent: string, candidate: string): boolean { + const path = relative(parent, candidate) + return path === '' || (!path.startsWith('..') && !isAbsolute(path)) +} + +function normalizeContent(content: Buffer): Buffer { + if (content.includes(0) || !isUtf8(content)) return content + return Buffer.from(content.toString('utf8').replace(/\r\n|\r/g, '\n'), 'utf8') +} + +function resolveInPackage( + fs: ReadFs, + filePath: string, + packageRoot: string, + label: string, +): string { + let resolved: string + try { + resolved = fs.realpathSync(filePath) + } catch (error) { + throw new Error( + `Failed to resolve ${label}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + if (!isWithin(packageRoot, resolved)) { + throw new Error(`${label} escapes package root through a symlink.`) + } + return resolved +} + +function hashEntries(entries: ReadonlyArray): string { + const hash = createHash('sha256') + for (const entry of [...entries].sort((a, b) => + compareStrings(a.path, b.path), + )) { + const path = Buffer.from(entry.path, 'utf8') + hash.update(String(path.length), 'ascii') + hash.update(HASH_ENTRY_SEPARATOR) + hash.update(path) + hash.update(HASH_ENTRY_SEPARATOR) + hash.update(String(entry.content.length), 'ascii') + hash.update(HASH_ENTRY_SEPARATOR) + hash.update(entry.content) + hash.update(HASH_ENTRY_SEPARATOR) + } + return `sha256-${hash.digest('hex')}` +} + +function readBoundedFile(fs: ReadFs, filePath: string): Buffer { + const stats = fs.lstatSync(filePath) + if (stats.size > HASH_LIMITS.maxFileBytes) { + throw new Error(`Hash file size limit exceeded by ${filePath}.`) + } + const descriptor = fs.openSync!(filePath, 'r') + const chunks: Array = [] + let total = 0 + try { + for (;;) { + const remaining = HASH_LIMITS.maxFileBytes + 1 - total + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining)) + const bytesRead = fs.readSync!(descriptor, buffer, 0, buffer.length, null) + if (bytesRead === 0) break + total += bytesRead + if (total > HASH_LIMITS.maxFileBytes) { + throw new Error(`Hash file size limit exceeded by ${filePath}.`) + } + chunks.push(buffer.subarray(0, bytesRead)) + } + } finally { + fs.closeSync!(descriptor) + } + return Buffer.concat(chunks, total) +} + +function readDirectoryEntries( + fs: HashReadFs, + path: string, +): Array> { + if (!('opendirSync' in fs)) { + return fs.readdirSync(path, { encoding: 'utf8', withFileTypes: true }) + } + + const directory = fs.opendirSync!(path, { encoding: 'utf8' }) + const entries: Array> = [] + try { + for (;;) { + const entry = directory.readSync() + if (!entry) break + entries.push(entry) + if (entries.length > HASH_LIMITS.maxEntryCount) { + throw new Error('Hash entry count limit exceeded.') + } + } + } finally { + directory.closeSync() + } + return entries +} + +export function computeSkillContentHash({ + packageRoot, + skillDir, + fs = nodeHashReadFs, +}: ComputeSkillContentHashOptions): string { + const realPackageRoot = fs.realpathSync(resolve(packageRoot)) + const requestedSkillDir = isAbsolute(skillDir) + ? resolve(skillDir) + : resolve(packageRoot, skillDir) + const realSkillDir = resolveInPackage( + fs, + requestedSkillDir, + realPackageRoot, + 'skill directory', + ) + if (!fs.lstatSync(realSkillDir).isDirectory()) { + throw new Error('Skill directory is not a directory.') + } + const hashState: { + entries: Array + entryCount: number + totalBytes: number + } = { + entries: [], + entryCount: 0, + totalBytes: 0, + } + + const readFile = (physicalPath: string, logicalPath: string): void => { + const realPath = resolveInPackage( + fs, + physicalPath, + realPackageRoot, + logicalPath, + ) + if (!fs.lstatSync(realPath).isFile()) { + throw new Error(`${logicalPath} is not a regular file.`) + } + const content = readBoundedFile(fs, realPath) + hashState.totalBytes += content.length + if (hashState.totalBytes > HASH_LIMITS.maxTotalBytes) + throw new Error('Hash total size limit exceeded.') + if (hashState.entries.length + 1 > HASH_LIMITS.maxFileCount) + throw new Error('Hash file count limit exceeded.') + hashState.entries.push({ + path: logicalPath, + content: normalizeContent(content), + }) + } + + const collect = ( + physicalDir: string, + logicalDir: string, + depth: number, + ): void => { + if (depth > HASH_LIMITS.maxRecursionDepth) + throw new Error('Hash recursion depth limit exceeded.') + const dirEntries = readDirectoryEntries(fs, physicalDir) + for (const entry of [...dirEntries].sort((a, b) => + compareStrings(a.name, b.name), + )) { + hashState.entryCount += 1 + if (hashState.entryCount > HASH_LIMITS.maxEntryCount) + throw new Error('Hash entry count limit exceeded.') + const logicalPath = `${logicalDir}/${entry.name}` + const physicalPath = join(physicalDir, entry.name) + const realPath = resolveInPackage( + fs, + physicalPath, + realPackageRoot, + logicalPath, + ) + const stats = fs.lstatSync(realPath) + if (stats.isDirectory()) { + collect(realPath, logicalPath, depth + 1) + } else if (stats.isFile()) { + readFile(realPath, logicalPath) + } else { + throw new Error(`${logicalPath} is not a regular file or directory.`) + } + } + } + + readFile(join(realSkillDir, 'SKILL.md'), 'SKILL.md') + for (const directory of ['references', 'assets', 'scripts']) { + const physicalDir = join(realSkillDir, directory) + try { + fs.lstatSync(physicalDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + const realDir = resolveInPackage( + fs, + physicalDir, + realPackageRoot, + directory, + ) + if (!fs.lstatSync(realDir).isDirectory()) + throw new Error(`${directory} is not a directory.`) + collect(realDir, directory, 1) + } + return hashEntries(hashState.entries) +} diff --git a/packages/intent/src/core/lockfile/lockfile-diff.ts b/packages/intent/src/core/lockfile/lockfile-diff.ts new file mode 100644 index 00000000..48252904 --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile-diff.ts @@ -0,0 +1,115 @@ +import { canonicalIntentLockfile } from './lockfile.js' +import type { + IntentLockfileSkill, + IntentLockfileSource, + ReadIntentLockfileResult, +} from './lockfile.js' + +interface ChangedLockfileSkill { + path: string + lockedContentHash: string + currentContentHash: string +} + +interface ChangedLockfileSource { + kind: IntentLockfileSource['kind'] + id: string + addedSkills: Array + removedSkills: Array + changedSkills: Array +} + +export interface LockfileDiff { + lockfile: 'missing' | 'found' + addedSources: Array + removedSources: Array + changedSources: Array + isClean: boolean +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function sourceKey(source: Pick): string { + return `${source.kind}\0${source.id}` +} + +export function diffLockfileSources( + currentSources: ReadonlyArray, + locked: ReadIntentLockfileResult, +): LockfileDiff { + if (locked.status === 'missing') { + return { + lockfile: 'missing', + addedSources: [], + removedSources: [], + changedSources: [], + isClean: false, + } + } + const current = canonicalIntentLockfile({ + lockfileVersion: 1, + sources: [...currentSources], + }).sources + const currentByKey = new Map( + current.map((source) => [sourceKey(source), source]), + ) + const lockedByKey = new Map( + locked.lockfile.sources.map((source) => [sourceKey(source), source]), + ) + const addedSources = current.filter( + (source) => !lockedByKey.has(sourceKey(source)), + ) + const removedSources = locked.lockfile.sources.filter( + (source) => !currentByKey.has(sourceKey(source)), + ) + const changedSources: Array = [] + for (const lockedSource of locked.lockfile.sources) { + const currentSource = currentByKey.get(sourceKey(lockedSource)) + if (!currentSource) continue + const currentSkills = new Map( + currentSource.skills.map((skill) => [skill.path, skill]), + ) + const lockedSkills = new Map( + lockedSource.skills.map((skill) => [skill.path, skill]), + ) + const addedSkills = currentSource.skills.filter( + (skill) => !lockedSkills.has(skill.path), + ) + const removedSkills = lockedSource.skills.filter( + (skill) => !currentSkills.has(skill.path), + ) + const changedSkills = lockedSource.skills.flatMap((skill) => { + const currentSkill = currentSkills.get(skill.path) + if (!currentSkill || currentSkill.contentHash === skill.contentHash) { + return [] + } + return [ + { + path: skill.path, + lockedContentHash: skill.contentHash, + currentContentHash: currentSkill.contentHash, + }, + ] + }) + if (addedSkills.length || removedSkills.length || changedSkills.length) { + changedSources.push({ + kind: currentSource.kind, + id: currentSource.id, + addedSkills, + removedSkills, + changedSkills, + }) + } + } + changedSources.sort((a, b) => compareStrings(sourceKey(a), sourceKey(b))) + return { + lockfile: 'found', + addedSources, + removedSources, + changedSources, + isClean: + !addedSources.length && !removedSources.length && !changedSources.length, + } +} diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts new file mode 100644 index 00000000..59d2a6d0 --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -0,0 +1,102 @@ +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { nodeReadFs, toPosixPath } from '../../shared/utils.js' +import { validateSkillPath } from '../skill-path.js' +import { computeSkillContentHash } from './hash.js' +import type { IntentLockfileSource } from './lockfile.js' +import type { IntentPackage } from '../../shared/types.js' +import type { ReadFs } from '../../shared/utils.js' + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function sourceKey(source: Pick): string { + return `${source.kind}\0${source.id}` +} + +function packageRelativeSkillFile( + pkg: IntentPackage, + skillPath: string, +): string { + if (isAbsolute(skillPath)) return resolve(skillPath) + + const normalizedSkillPath = toPosixPath(skillPath) + const nodeModulesPrefix = `node_modules/${pkg.name}/` + if (normalizedSkillPath.startsWith(nodeModulesPrefix)) { + return resolve( + pkg.packageRoot, + normalizedSkillPath.slice(nodeModulesPrefix.length), + ) + } + + const packageSegments = toPosixPath(resolve(pkg.packageRoot)).split('/') + const skillSegments = normalizedSkillPath.split('/') + const compareSegment = + sep === '\\' + ? (left: string, right: string) => + left.toLowerCase() === right.toLowerCase() + : (left: string, right: string) => left === right + for (let start = 0; start < packageSegments.length; start++) { + const suffix = packageSegments.slice(start) + if ( + suffix.length < skillSegments.length && + suffix.every((segment, index) => + compareSegment(segment, skillSegments[index]!), + ) + ) { + return resolve(pkg.packageRoot, ...skillSegments.slice(suffix.length)) + } + } + + return resolve(pkg.packageRoot, skillPath) +} + +function skillDirectoryPath( + pkg: IntentPackage, + skillPath: string, +): { absolute: string; relative: string } { + const absoluteSkillFile = packageRelativeSkillFile(pkg, skillPath) + const absolute = dirname(absoluteSkillFile) + const relativePath = relative(resolve(pkg.packageRoot), absolute) + const packageRelativePath = + sep === '/' ? relativePath : relativePath.split(sep).join('/') + return { absolute, relative: validateSkillPath(packageRelativePath) } +} + +export function buildCurrentLockfileSources( + packages: ReadonlyArray, + fs: ReadFs = nodeReadFs, +): Array { + const sources = packages.map((pkg) => ({ + kind: pkg.kind, + id: pkg.name, + skills: pkg.skills + .map((skill) => { + const path = skillDirectoryPath(pkg, skill.path) + return { + path: path.relative, + contentHash: computeSkillContentHash({ + packageRoot: pkg.packageRoot, + skillDir: path.absolute, + fs, + }), + } + }) + .sort((a, b) => compareStrings(a.path, b.path)), + })) + const identities = new Set() + for (const source of sources) { + const identity = sourceKey(source) + if (identities.has(identity)) + throw new Error( + `Duplicate skill source identity: ${source.kind}:${source.id}.`, + ) + identities.add(identity) + const paths = new Set(source.skills.map((skill) => skill.path)) + if (paths.size !== source.skills.length) + throw new Error( + `Duplicate skill path for source: ${source.kind}:${source.id}.`, + ) + } + return sources.sort((a, b) => compareStrings(sourceKey(a), sourceKey(b))) +} diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts new file mode 100644 index 00000000..04b166a8 --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -0,0 +1,179 @@ +import { readFileSync } from 'node:fs' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' +import { validateSkillPaths } from '../skill-path.js' + +export interface IntentLockfileSkill { + path: string + contentHash: string +} + +export interface IntentLockfileSource { + kind: 'npm' | 'workspace' + id: string + skills: Array +} + +export interface IntentLockfile { + lockfileVersion: 1 + sources: Array +} + +export type ReadIntentLockfileResult = + | { status: 'missing' } + | { status: 'found'; lockfile: IntentLockfile } + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function sourceKey(source: Pick): string { + return `${source.kind}\0${source.id}` +} + +function assertRecord(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Invalid intent.lock ${label}: expected an object.`) + } + return value as Record +} + +function assertFields( + value: Record, + allowed: ReadonlyArray, + label: string, +): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + throw new Error(`Invalid intent.lock ${label}: unknown field "${key}".`) + } + } +} + +function assertString(value: unknown, label: string): string { + if (typeof value !== 'string' || value === '') { + throw new Error( + `Invalid intent.lock ${label}: expected a non-empty string.`, + ) + } + return value +} + +function canonicalSource(source: IntentLockfileSource): IntentLockfileSource { + const paths = source.skills.map((skill) => skill.path) + validateSkillPaths(paths) + return { + kind: source.kind, + id: source.id, + skills: source.skills + .map((skill) => ({ + path: skill.path, + contentHash: skill.contentHash, + })) + .sort((a, b) => compareStrings(a.path, b.path)), + } +} + +export function canonicalIntentLockfile( + lockfile: IntentLockfile, +): IntentLockfile { + const sources = lockfile.sources.map(canonicalSource) + const seen = new Set() + for (const source of sources) { + const key = sourceKey(source) + if (seen.has(key)) { + throw new Error( + `Duplicate intent.lock source: ${source.kind}:${source.id}.`, + ) + } + seen.add(key) + } + return { + lockfileVersion: 1, + sources: sources.sort((a, b) => compareStrings(sourceKey(a), sourceKey(b))), + } +} + +export function parseIntentLockfile(content: string): IntentLockfile { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch (error) { + throw new Error( + `Invalid intent.lock JSON: ${error instanceof Error ? error.message : String(error)}`, + ) + } + const root = assertRecord(parsed, 'root') + assertFields(root, ['lockfileVersion', 'sources'], 'root') + if (typeof root.lockfileVersion === 'number' && root.lockfileVersion > 1) { + throw new Error( + `intent.lock declares lockfileVersion ${root.lockfileVersion}, which this @tanstack/intent cannot read. Upgrade @tanstack/intent.`, + ) + } + if (root.lockfileVersion !== 1 || !Array.isArray(root.sources)) { + throw new Error('Invalid intent.lock root.') + } + return canonicalIntentLockfile({ + lockfileVersion: 1, + sources: root.sources.map((value, sourceIndex) => { + const source = assertRecord(value, `sources[${sourceIndex}]`) + assertFields(source, ['kind', 'id', 'skills'], `sources[${sourceIndex}]`) + if (!Array.isArray(source.skills)) { + throw new Error(`Invalid intent.lock sources[${sourceIndex}].skills.`) + } + if (source.kind !== 'npm' && source.kind !== 'workspace') { + throw new Error( + `intent.lock contains a "${String(source.kind)}" source, which this @tanstack/intent cannot read. Upgrade @tanstack/intent if a newer version wrote this lockfile.`, + ) + } + return { + kind: source.kind, + id: assertString(source.id, `sources[${sourceIndex}].id`), + skills: source.skills.map((skill, skillIndex) => { + const record = assertRecord( + skill, + `sources[${sourceIndex}].skills[${skillIndex}]`, + ) + assertFields( + record, + ['path', 'contentHash'], + `sources[${sourceIndex}].skills[${skillIndex}]`, + ) + return { + path: assertString( + record.path, + `sources[${sourceIndex}].skills[${skillIndex}].path`, + ), + contentHash: assertString( + record.contentHash, + `sources[${sourceIndex}].skills[${skillIndex}].contentHash`, + ), + } + }), + } + }), + }) +} + +export function serializeIntentLockfile(lockfile: IntentLockfile): string { + return `${JSON.stringify(canonicalIntentLockfile(lockfile), null, 2)}\n` +} + +export function readIntentLockfile(filePath: string): ReadIntentLockfileResult { + try { + return { + status: 'found', + lockfile: parseIntentLockfile(readFileSync(filePath, 'utf8')), + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return { status: 'missing' } + throw error + } +} + +export function writeIntentLockfile( + filePath: string, + lockfile: IntentLockfile, +): void { + writeTextFileAtomic(filePath, serializeIntentLockfile(lockfile)) +} diff --git a/packages/intent/src/core/skill-path.ts b/packages/intent/src/core/skill-path.ts new file mode 100644 index 00000000..b681a4b0 --- /dev/null +++ b/packages/intent/src/core/skill-path.ts @@ -0,0 +1,39 @@ +function assertCanonicalPackageRelativePath(path: string): void { + if (typeof path !== 'string' || path === '') { + throw new Error('Skill path must be a non-empty string.') + } + if ( + path.includes('\0') || + path.includes('\\') || + path.startsWith('/') || + /^[a-zA-Z]:/.test(path) || + path.startsWith('//') + ) { + throw new Error(`Invalid skill path: ${path}`) + } + if ( + path + .split('/') + .some((segment) => !segment || segment === '.' || segment === '..') + ) { + throw new Error(`Invalid skill path: ${path}`) + } +} + +export function validateSkillPaths( + paths: ReadonlyArray, +): Array { + const seen = new Set() + for (const path of paths) { + assertCanonicalPackageRelativePath(path) + if (seen.has(path)) { + throw new Error(`Duplicate skill path: ${path}`) + } + seen.add(path) + } + return [...paths] +} + +export function validateSkillPath(path: string): string { + return validateSkillPaths([path])[0]! +} diff --git a/packages/intent/src/core/skill-sources.ts b/packages/intent/src/core/skill-sources.ts index 303fc4b4..4c7a02cf 100644 --- a/packages/intent/src/core/skill-sources.ts +++ b/packages/intent/src/core/skill-sources.ts @@ -1,6 +1,8 @@ // Static-discovery invariant: this module only inspects strings. It never // resolves, requires, or executes any discovered package. +import { compileWildcardPattern } from './excludes.js' + /** * Exact entries keep the `kind` + `id` identity M2's lockfile reuses. Patterns * select multiple discovered identities and remain distinct from exact entries. @@ -8,7 +10,7 @@ * parse time) but is defined here so M2 builds on this shape. */ type SkillSource = - | ({ raw: string; kind: 'npm' | 'workspace' } & ( + | ({ raw: string; kind: 'npm' | 'workspace'; skill?: string } & ( | { id: string } | { pattern: string } )) @@ -119,12 +121,25 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig { } const selector = 'pattern' in parsed ? parsed.pattern : parsed.id - const identity = `${parsed.kind}\u0000${selector}` + const skill = 'skill' in parsed ? parsed.skill : undefined + const identity = `${parsed.kind}\u0000${selector}\u0000${skill ?? ''}` if (seenIdentity.has(identity)) continue seenIdentity.add(identity) sources.push(parsed) } + if (!allowAll) { + for (const source of sources) { + const subsuming = findPackageLevelEntryCovering(source, sources) + if (subsuming) { + issues.push({ + raw: source.raw, + message: `Entry "${source.raw.trim()}" is ambiguous: "${subsuming.raw.trim()}" already allows every skill in that package. Keep one.`, + }) + } + } + } + if (issues.length > 0) { throw new SkillSourcesParseError(issues) } @@ -136,6 +151,27 @@ export function parseSkillSources(value: unknown): SkillSourcesConfig { return { mode: 'explicit', sources } } +function findPackageLevelEntryCovering( + source: SkillSource, + sources: Array, +): SkillSource | undefined { + if ( + source.kind === 'git' || + !('id' in source) || + source.skill === undefined + ) { + return undefined + } + const { id, kind } = source + return sources.find((other) => { + if (other === source || other.kind === 'git') return false + if (other.kind !== kind || other.skill !== undefined) return false + return 'pattern' in other + ? compileWildcardPattern(other.pattern)(id) + : other.id === id + }) +} + function parseEntry( raw: string, trimmed: string, @@ -144,10 +180,12 @@ function parseEntry( // npm names cannot contain ':', so a colon-free entry is unambiguously npm. if (colon === -1) { - const invalid = validateId(trimmed) + const split = splitSkillSelector(raw, trimmed, trimmed) + if ('message' in split) return split + const invalid = validateId(split.packageSegment) if (invalid) return { raw, message: `Invalid npm source "${trimmed}": ${invalid}` } - return packageSource(raw, trimmed, 'npm') + return packageSource(raw, split.packageSegment, 'npm', split.skill) } const prefix = trimmed.slice(0, colon) @@ -161,14 +199,16 @@ function parseEntry( message: `Workspace source "${trimmed}" is missing a package name.`, } } - const invalid = validateId(rest) + const split = splitSkillSelector(raw, trimmed, rest) + if ('message' in split) return split + const invalid = validateId(split.packageSegment) if (invalid) { return { raw, message: `Invalid workspace source "${trimmed}": ${invalid}`, } } - return packageSource(raw, rest, 'workspace') + return packageSource(raw, split.packageSegment, 'workspace', split.skill) } case 'git': return { @@ -183,18 +223,58 @@ function parseEntry( } } +function splitSkillSelector( + raw: string, + trimmed: string, + selector: string, +): { packageSegment: string; skill: string | null } | SkillSourceIssue { + const hash = selector.indexOf('#') + if (hash === -1) return { packageSegment: selector, skill: null } + + const packageSegment = selector.slice(0, hash) + const skillSegment = selector.slice(hash + 1) + + if (skillSegment.includes('#')) { + return { raw, message: `Entry "${trimmed}" has more than one "#".` } + } + if (packageSegment === '') { + return { + raw, + message: `Entry "${trimmed}" is missing a package name before "#".`, + } + } + if (skillSegment === '') { + return { + raw, + message: `Entry "${trimmed}" is missing a skill name after "#".`, + } + } + if (/\s/.test(skillSegment)) { + return { + raw, + message: `Invalid skill selector in "${trimmed}": skill names cannot contain whitespace.`, + } + } + + return { + packageSegment, + skill: skillSegment.replace(/\*+/g, '*') === '*' ? null : skillSegment, + } +} + function packageSource( raw: string, id: string, kind: 'npm' | 'workspace', + skill: string | null, ): SkillSource { - return id.includes('*') ? { raw, pattern: id, kind } : { raw, id, kind } + const source = id.includes('*') + ? { raw, pattern: id, kind } + : { raw, id, kind } + return skill === null ? source : { ...source, skill } } function validateId(id: string): string | null { - if (id.includes('#')) { - return 'skill-level granularity (#) is not supported in intent.skills (it is package-level); use intent.exclude for skill-level control.' - } if (/\s/.test(id)) { return 'package names cannot contain whitespace.' } diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 929fd034..ab686f5a 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -4,10 +4,13 @@ import { ALLOW_ALL_NOTICE } from '../shared/cli-output.js' import { compileExcludePatterns, compileWildcardPattern, + findPackageExcludeMatch, + findSkillExcludeMatch, getConfigDirs, getEffectiveExcludePatterns, isPackageExcluded, isSkillExcluded, + skillNameVariants, warningMentionsPackage, } from './excludes.js' import { readPackageJson } from './package-json.js' @@ -42,30 +45,62 @@ type LoadRefusalCode = | 'package-excluded' | 'package-not-listed' | 'skill-excluded' + | 'skill-not-listed' export interface LoadRefusal { code: LoadRefusalCode message: string } -type ExplicitSkillSource = Extract< +export type ExplicitSkillSource = Extract< SkillSourcesConfig, { mode: 'explicit' } >['sources'][number] +export interface SkillSourcePolicyDecision { + permitted: boolean + source: ExplicitSkillSource | null +} + interface SkillSourceMatcher { source: ExplicitSkillSource matchesPackage: ( packageName: string, packageKind?: 'npm' | 'workspace', ) => boolean + matchesSkill: + | ((packageName: string, skillName: string) => boolean) + | undefined +} + +export interface CompiledSkillSourcePolicy { + matchers: Array + permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean + permitsSkill: ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + ) => boolean + explainPermits: ( + packageName: string, + packageKind?: 'npm' | 'workspace', + ) => SkillSourcePolicyDecision + explainPermitsSkill: ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + ) => SkillSourcePolicyDecision } function compileSkillSourceMatcher( source: ExplicitSkillSource, ): SkillSourceMatcher { if (source.kind === 'git') { - return { source, matchesPackage: () => false } + return { + source, + matchesPackage: () => false, + matchesSkill: undefined, + } } const matchesName = @@ -73,24 +108,45 @@ function compileSkillSourceMatcher( ? compileWildcardPattern(source.pattern) : (packageName: string) => source.id === packageName + const matchesSkillName = + source.skill === undefined + ? undefined + : compileWildcardPattern(source.skill) + return { source, matchesPackage: (packageName, packageKind) => (packageKind === undefined || source.kind === packageKind) && matchesName(packageName), + matchesSkill: + matchesSkillName === undefined + ? undefined + : (packageName, skillName) => + skillNameVariants(packageName, skillName).some(matchesSkillName), } } -function compileSkillSourcePolicy(config: SkillSourcesConfig): { - matchers: Array - permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean -} { +export function compileSkillSourcePolicy( + config: SkillSourcesConfig, +): CompiledSkillSourcePolicy { switch (config.mode) { case 'absent': case 'allow-all': - return { matchers: [], permits: () => true } + return { + matchers: [], + permits: () => true, + permitsSkill: () => true, + explainPermits: () => ({ permitted: true, source: null }), + explainPermitsSkill: () => ({ permitted: true, source: null }), + } case 'empty': - return { matchers: [], permits: () => false } + return { + matchers: [], + permits: () => false, + permitsSkill: () => false, + explainPermits: () => ({ permitted: false, source: null }), + explainPermitsSkill: () => ({ permitted: false, source: null }), + } case 'explicit': { const matchers = config.sources.map(compileSkillSourceMatcher) return { @@ -99,19 +155,39 @@ function compileSkillSourcePolicy(config: SkillSourcesConfig): { matchers.some((matcher) => matcher.matchesPackage(packageName, packageKind), ), + permitsSkill: (packageName, skillName, packageKind) => + matchers.some( + (matcher) => + matcher.matchesPackage(packageName, packageKind) && + (matcher.matchesSkill === undefined || + matcher.matchesSkill(packageName, skillName)), + ), + explainPermits: (packageName, packageKind) => { + const matcher = matchers.find((candidate) => + candidate.matchesPackage(packageName, packageKind), + ) + return { + permitted: matcher !== undefined, + source: matcher?.source ?? null, + } + }, + explainPermitsSkill: (packageName, skillName, packageKind) => { + const matcher = matchers.find( + (candidate) => + candidate.matchesPackage(packageName, packageKind) && + (candidate.matchesSkill === undefined || + candidate.matchesSkill(packageName, skillName)), + ) + return { + permitted: matcher !== undefined, + source: matcher?.source ?? null, + } + }, } } } } -export function isSourcePermitted( - config: SkillSourcesConfig, - packageName: string, - packageKind?: 'npm' | 'workspace', -): boolean { - return compileSkillSourcePolicy(config).permits(packageName, packageKind) -} - export function packageNotListedRefusal( use: string, packageName: string, @@ -122,15 +198,26 @@ export function packageNotListedRefusal( } } +export function skillNotListedRefusal( + use: string, + packageName: string, + skillName: string, +): LoadRefusal { + return { + code: 'skill-not-listed', + message: `Cannot load skill use "${use}": skill "${packageName}#${skillName}" is not listed in intent.skills.`, + } +} + export function checkLoadAllowed( use: string, parsed: SkillUse, params: { - config: SkillSourcesConfig + sourcePolicy: CompiledSkillSourcePolicy excludeMatchers: Array }, ): LoadRefusal | null { - const { config, excludeMatchers } = params + const { sourcePolicy, excludeMatchers } = params const { packageName, skillName } = parsed if (isPackageExcluded(packageName, excludeMatchers)) { @@ -140,13 +227,15 @@ export function checkLoadAllowed( } } - // Name-only pre-check: kind isn't known yet at this point in the load path. - // A late, kind-aware isSourcePermitted call happens once resolution reveals - // the actual kind (see intent-core.ts). - if (!isSourcePermitted(config, packageName)) { + // Name-only pre-check: kind isn't known until resolution. + if (!sourcePolicy.permits(packageName)) { return packageNotListedRefusal(use, packageName) } + if (!sourcePolicy.permitsSkill(packageName, skillName)) { + return skillNotListedRefusal(use, packageName, skillName) + } + if (isSkillExcluded(packageName, skillName, excludeMatchers)) { return { code: 'skill-excluded', @@ -177,11 +266,59 @@ function formatUnlistedNotice( return `${sourceCount} discovered ${noun} skills but ${sourceCount === 1 ? 'is' : 'are'} not listed in intent.skills: ${sorted.map((source) => source.name).join(', ')}. Add to opt in.` } +function formatUnlistedSkillNotice( + hiddenSources: Array, + audience: IntentAudience, +): string { + const uses = [...hiddenSources] + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((source) => + (source.hiddenSkills ?? []).map((skill) => `${source.name}#${skill}`), + ) + + if (audience === 'agent') { + return `${uses.length} ${pluralize(uses.length, 'skill', 'skills')} from listed packages ${pluralize(uses.length, 'is', 'are')} hidden because ${pluralize(uses.length, 'it is', 'they are')} not listed in intent.skills. Ask the user to run \`intent list --show-hidden\` outside the agent session to review candidates.` + } + + return `${uses.length} ${pluralize(uses.length, 'skill', 'skills')} from listed packages ${pluralize(uses.length, 'is', 'are')} not listed in intent.skills: ${uses.join(', ')}. Add to opt in.` +} + export interface SourcePolicyResult { hiddenSourceCount: number hiddenSources: Array + excludedSkills: Array packages: Array notices: Array + sourcePolicy: CompiledSkillSourcePolicy +} + +export interface ExcludedSkill { + package: IntentPackage + skill: IntentPackage['skills'][number] + pattern: string +} + +export function scanForConfiguredIntents({ + config, + exclude, + root, +}: { + config: SkillSourcesConfig + exclude: Array + root: string +}): { + discovered: Array + policy: SourcePolicyResult +} { + const scan = scanForIntents(root, { scope: 'local' }) + const discovered = scan.packages.filter((pkg) => pkg.source === 'local') + return { + discovered, + policy: applySourcePolicy( + { packages: discovered }, + { config, excludeMatchers: compileExcludePatterns(exclude) }, + ), + } } export function applySourcePolicy( @@ -202,9 +339,24 @@ export function applySourcePolicy( const packages: Array = [] const hiddenSources: Array = [] + const excludedSkills: Array = [] for (const pkg of scanResult.packages) { - if (isPackageExcluded(pkg.name, excludeMatchers)) continue + const packageExclude = findPackageExcludeMatch(pkg.name, excludeMatchers) + if (packageExclude) { + if (sourcePolicy.permits(pkg.name, pkg.kind)) { + for (const skill of pkg.skills) { + if (sourcePolicy.permitsSkill(pkg.name, skill.name, pkg.kind)) { + excludedSkills.push({ + package: pkg, + skill, + pattern: packageExclude.pattern, + }) + } + } + } + continue + } if (!sourcePolicy.permits(pkg.name, pkg.kind)) { if (config.mode === 'explicit') { @@ -213,22 +365,61 @@ export function applySourcePolicy( continue } - const skills = pkg.skills.filter( - (skill) => !isSkillExcluded(pkg.name, skill.name, excludeMatchers), - ) + const skills: Array = [] + const hiddenSkills: Array = [] + for (const skill of pkg.skills) { + if (!sourcePolicy.permitsSkill(pkg.name, skill.name, pkg.kind)) { + hiddenSkills.push(skill.name) + continue + } + const skillExclude = findSkillExcludeMatch( + pkg.name, + skill.name, + excludeMatchers, + ) + if (skillExclude) { + excludedSkills.push({ + package: pkg, + skill, + pattern: skillExclude.pattern, + }) + continue + } + skills.push(skill) + } + if (config.mode === 'explicit' && hiddenSkills.length > 0) { + hiddenSources.push({ + name: pkg.name, + skillCount: hiddenSkills.length, + hiddenSkills, + }) + } packages.push( skills.length === pkg.skills.length ? pkg : { ...pkg, skills }, ) } - if (hiddenSources.length > 0) { - emit(formatUnlistedNotice(hiddenSources, audience)) + const unlistedSources = hiddenSources.filter( + (source) => source.hiddenSkills === undefined, + ) + const partiallyHidden = hiddenSources.filter( + (source) => source.hiddenSkills !== undefined, + ) + if (unlistedSources.length > 0) { + emit(formatUnlistedNotice(unlistedSources, audience)) + } + if (partiallyHidden.length > 0) { + emit(formatUnlistedSkillNotice(partiallyHidden, audience)) } if (config.mode === 'explicit') { for (const matcher of sourcePolicy.matchers) { - const notDiscovered = !scanResult.packages.some((pkg) => - matcher.matchesPackage(pkg.name, pkg.kind), + const { matchesSkill } = matcher + const notDiscovered = !scanResult.packages.some( + (pkg) => + matcher.matchesPackage(pkg.name, pkg.kind) && + (matchesSkill === undefined || + pkg.skills.some((skill) => matchesSkill(pkg.name, skill.name))), ) if (notDiscovered) { emit( @@ -245,8 +436,10 @@ export function applySourcePolicy( return { hiddenSourceCount: hiddenSources.length, hiddenSources, + excludedSkills, packages, notices, + sourcePolicy, } } @@ -273,9 +466,12 @@ export function readSkillSourcesConfig( export interface PolicedScan { hiddenSourceCount: number hiddenSources: Array + excludedSkills: Array scan: ScanResult excludePatterns: Array droppedNames: Array + config: SkillSourcesConfig + sourcePolicy: CompiledSkillSourcePolicy } export function scanForPolicedIntents(params: { @@ -283,16 +479,16 @@ export function scanForPolicedIntents(params: { scanOptions: ScanOptions coreOptions: IntentCoreOptions context?: ProjectContext + config?: SkillSourcesConfig }): PolicedScan { const { cwd, scanOptions, coreOptions } = params const context = params.context ?? resolveProjectContext({ cwd }) const audience = detectIntentAudience(coreOptions.audience) const scanResult = scanForIntents(cwd, scanOptions) - const config = readSkillSourcesConfig(cwd, context) + const config = params.config ?? readSkillSourcesConfig(cwd, context) const excludePatterns = getEffectiveExcludePatterns(coreOptions, context) const excludeMatchers = compileExcludePatterns(excludePatterns) - const policy = applySourcePolicy(scanResult, { audience, config, @@ -309,6 +505,7 @@ export function scanForPolicedIntents(params: { return { hiddenSourceCount: policy.hiddenSourceCount, hiddenSources: audience === 'agent' ? [] : policy.hiddenSources, + excludedSkills: audience === 'agent' ? [] : policy.excludedSkills, scan: { ...scanResult, packages: policy.packages, @@ -323,5 +520,7 @@ export function scanForPolicedIntents(params: { }, excludePatterns, droppedNames, + config, + sourcePolicy: policy.sourcePolicy, } } diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index cf897035..14dd6caa 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -13,6 +13,7 @@ export interface IntentCoreOptions { global?: boolean globalOnly?: boolean exclude?: Array + why?: boolean } export type IntentAudience = 'agent' | 'human' @@ -20,6 +21,7 @@ export type IntentAudience = 'agent' | 'human' export interface IntentHiddenSourceSummary { name: string skillCount: number + hiddenSkills?: Array } export interface IntentSkillSummary { @@ -32,6 +34,11 @@ export interface IntentSkillSummary { description: string type?: string framework?: string + why?: string +} + +export interface IntentExcludedSkillSummary extends IntentSkillSummary { + excluded: true } export interface IntentPackageSummary { @@ -45,6 +52,7 @@ export interface IntentPackageSummary { export interface IntentSkillList { packageManager: PackageManager skills: Array + excludedSkills?: Array packages: Array hiddenSourceCount: number hiddenSources: Array @@ -121,6 +129,9 @@ export type IntentCoreErrorCode = | 'package-excluded' | 'package-not-listed' | 'skill-excluded' + | 'skill-not-listed' | 'skill-not-found' + | 'skill-not-accepted' + | 'skill-content-changed' | 'skill-path-outside-package' | 'skill-file-not-found' diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 622113ce..764c4221 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -1,11 +1,10 @@ // Static-discovery invariant: discovery reads package data as files and never -// executes discovered package code. The only sanctioned dynamic load is Yarn's -// PnP runtime (.pnp.cjs / pnpapi), used solely to map identities to readable -// roots. Enforced by the `intent/static-discovery` ESLint rule. +// executes discovered package code. The only sanctioned project-code dynamic +// load is Yarn's PnP runtime (.pnp.cjs / pnpapi), used solely to map identities +// to readable roots. Enforced by the `intent/static-discovery` ESLint rule. import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' -import semver from 'semver' import { detectGlobalNodeModules, nodeReadFs, @@ -22,6 +21,7 @@ import { detectPackageManager } from './package-manager.js' import { createDependencyWalker, createPackageRegistrar } from './index.js' import type { IntentFsCache } from './fs-cache.js' import type { ReadFs } from '../shared/utils.js' +import type Semver from 'semver' import type { InstalledVariant, IntentConfig, @@ -73,6 +73,13 @@ interface NodeModuleInternals { } const requireFromHere = createRequire(import.meta.url) +let semver: typeof Semver | undefined + +function getSemver(): typeof Semver { + // Static yaml/semver imports add ~20ms of hook startup, and tsdown inlines dynamic imports, so createRequire preserves the lazy boundary. + semver ??= requireFromHere('semver') as typeof Semver + return semver +} function findPnpFile(start: string): string | null { let dir = resolve(start) @@ -126,6 +133,9 @@ function loadPnpApi(root: string): LoadedPnp | null { const readFs = requireFromHere('node:fs') as unknown as ReadFs try { + // Yarn PnP patches CommonJS resolution during setup, so cache yaml before + // loading the runtime; later createRequire calls otherwise fail. + void requireFromHere('yaml') // eslint-disable-next-line no-restricted-syntax -- sanctioned PnP runtime load const pnpModule = requireFromHere(pnpPath) as PnpApi if (typeof pnpModule.setup === 'function') { @@ -166,6 +176,10 @@ function loadPnpApi(root: string): LoadedPnp | null { } } +export function getProjectReadFs(root: string): ReadFs { + return loadPnpApi(root)?.readFs ?? nodeReadFs +} + function getPnpLocatorKey(locator: PnpPackageLocator): string { return `${locator.name ?? ''}@${locator.reference ?? ''}` } @@ -401,10 +415,10 @@ function getPackageDepth(packageRoot: string, projectRoot: string): number { } function normalizeVersion(version: string): string | null { - const validVersion = semver.valid(version) + const validVersion = getSemver().valid(version) if (validVersion) return validVersion - return semver.coerce(version)?.version ?? null + return getSemver().coerce(version)?.version ?? null } function comparePackageVersions(a: string, b: string): number { @@ -417,7 +431,7 @@ function comparePackageVersions(a: string, b: string): number { return 0 } - return semver.compare(versionA, versionB) + return getSemver().compare(versionA, versionB) } function formatVariantWarning( diff --git a/packages/intent/src/hooks/adapters.ts b/packages/intent/src/hooks/adapters.ts index 472b36e6..eec0aa9a 100644 --- a/packages/intent/src/hooks/adapters.ts +++ b/packages/intent/src/hooks/adapters.ts @@ -3,7 +3,6 @@ import type { HookAgent, HookInstallScope } from './types.js' type HookAdapterPaths = { configPath: string - scriptPath: string } type HookAdapterContext = { @@ -22,8 +21,6 @@ export type HookAgentAdapter = { ) => HookAdapterPaths } -const HOOK_SCRIPT_DIR = '.intent/hooks' - export const HOOK_AGENT_ADAPTERS: Record = { claude: { agent: 'claude', @@ -35,15 +32,6 @@ export const HOOK_AGENT_ADAPTERS: Record = { configPath: project ? join(root, '.claude', 'settings.json') : join(homeDir, '.claude', 'settings.json'), - scriptPath: project - ? join(root, HOOK_SCRIPT_DIR, 'intent-claude-gate.mjs') - : join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-claude-gate.mjs', - ), } }, }, @@ -57,15 +45,6 @@ export const HOOK_AGENT_ADAPTERS: Record = { configPath: project ? join(root, '.codex', 'hooks.json') : join(homeDir, '.codex', 'hooks.json'), - scriptPath: project - ? join(root, HOOK_SCRIPT_DIR, 'intent-codex-gate.mjs') - : join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-codex-gate.mjs', - ), } }, }, @@ -79,13 +58,6 @@ export const HOOK_AGENT_ADAPTERS: Record = { 'hooks', 'hooks.json', ), - scriptPath: join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-copilot-gate.mjs', - ), }), }, } diff --git a/packages/intent/src/hooks/agents/claude.ts b/packages/intent/src/hooks/agents/claude.ts deleted file mode 100644 index 44bb71f1..00000000 --- a/packages/intent/src/hooks/agents/claude.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { HookDecision } from '../types.js' - -export type ClaudeHookOutput = { - hookSpecificOutput: { - hookEventName: 'PreToolUse' - permissionDecision: 'deny' - permissionDecisionReason: string - } -} - -export function formatClaudePreToolUseOutput( - decision: HookDecision, -): ClaudeHookOutput | undefined { - if (decision.decision === 'allow') { - return undefined - } - - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: decision.reason, - }, - } -} diff --git a/packages/intent/src/hooks/agents/codex.ts b/packages/intent/src/hooks/agents/codex.ts deleted file mode 100644 index ccc13e87..00000000 --- a/packages/intent/src/hooks/agents/codex.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { HookDecision } from '../types.js' - -export type CodexHookOutput = { - hookSpecificOutput: { - hookEventName: 'PreToolUse' - permissionDecision: 'deny' - permissionDecisionReason: string - } -} - -export function formatCodexPreToolUseOutput( - decision: HookDecision, -): CodexHookOutput | undefined { - if (decision.decision === 'allow') { - return undefined - } - - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: decision.reason, - }, - } -} diff --git a/packages/intent/src/hooks/agents/copilot.ts b/packages/intent/src/hooks/agents/copilot.ts deleted file mode 100644 index c2231075..00000000 --- a/packages/intent/src/hooks/agents/copilot.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { HookDecision } from '../types.js' - -export type CopilotHookOutput = { - permissionDecision: 'deny' - permissionDecisionReason: string -} - -export function formatCopilotPreToolUseOutput( - decision: HookDecision, -): CopilotHookOutput | undefined { - if (decision.decision === 'allow') { - return undefined - } - - return { - permissionDecision: 'deny', - permissionDecisionReason: decision.reason, - } -} diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 2e8f6a5d..4bc9550e 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -5,7 +5,6 @@ import { detectPackageManager } from '../discovery/package-manager.js' import { fail } from '../shared/cli-error.js' import { formatIntentCommand } from '../shared/command-runner.js' import { ALL_HOOK_AGENTS, HOOK_AGENT_ADAPTERS } from './adapters.js' -import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON } from './policy.js' import type { HookAgent, HookInstallScope } from './types.js' type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' @@ -27,7 +26,6 @@ export type InstallHooksOptions = { scope?: string } -const GATE_STATUS_MESSAGE = 'Checking Intent guidance' const CATALOG_STATUS_MESSAGE = 'Loading Intent skill catalog' export function runInstallHooks({ @@ -59,247 +57,6 @@ export function validateHookInstallOptions({ parseAgents(agents) } -export function buildHookRunnerScript( - agent: HookAgent, - catalogCommand = formatIntentCommand( - detectPackageManager(), - 'list --json --no-notices', - ), -): string { - const editTools = [...EDIT_TOOLS_BY_AGENT[agent]].sort() - - return `#!/usr/bin/env node -import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { createHash } from 'node:crypto' -import { performance } from 'node:perf_hooks' - -const AGENT = ${JSON.stringify(agent)} -const CATALOG_COMMAND = ${JSON.stringify(catalogCommand)} -const EDIT_TOOLS = new Set(${JSON.stringify(editTools)}) -const GATE_DENY_REASON = ${JSON.stringify(GATE_DENY_REASON)} -const INTENT_COMMAND_PATTERN = /(?:^|&&|\\|\\||;|\\|)\\s*((?:bunx\\s+@tanstack\\/intent(?:@latest)?)|(?:pnpm\\s+exec\\s+intent)|(?:pnpm\\s+dlx\\s+@tanstack\\/intent(?:@latest)?)|(?:npx\\s+@tanstack\\/intent(?:@latest)?)|(?:yarn\\s+dlx\\s+@tanstack\\/intent(?:@latest)?)|(?:intent))\\s+(list|load)(?:\\s+([^\\s|;&]+))?/i - -try { - await main() -} catch { -} - -process.exit(0) - -async function main() { - const event = readEventFromStdin() - - if (isSessionStartEvent(event)) { - const additionalContext = await createSessionCatalogContext(rootForEvent(event)) - if (additionalContext) { - process.stdout.write(JSON.stringify(sessionStartOutput(additionalContext))) - } - return - } - - const stateFile = stateFileForEvent(event) - const observation = observationFromEvent(event) - - if (observation) { - appendObservation(stateFile, observation) - } - - const toolName = event?.tool_name ?? event?.toolName - if (typeof toolName === 'string' && EDIT_TOOLS.has(toolName) && !hasLoad(stateFile)) { - process.stdout.write(JSON.stringify(denyOutput())) - } -} - -function readEventFromStdin() { - try { - return JSON.parse(readFileSync(0, 'utf8')) - } catch { - return {} - } -} - -function isSessionStartEvent(event) { - return (event?.hook_event_name ?? event?.hookEventName) === 'SessionStart' -} - -function rootForEvent(event) { - return typeof event?.cwd === 'string' ? event.cwd : process.cwd() -} - -async function createSessionCatalogContext(root) { - try { - const start = performance.now() - const result = readIntentList(root) - const durationMs = performance.now() - start - console.error( - \`[intent-\${AGENT}-session-catalog] listIntentSkills found \${result.skills.length} skills from \${result.packages.length} packages in \${formatDuration(durationMs)} (packageJsonReadCount=\${result.debug?.scan.packageJsonReadCount ?? 'unknown'})\`, - ) - return formatSessionCatalog(result) - } catch { - return '' - } -} - -function readIntentList(root) { - const output = execFileSync(CATALOG_COMMAND, { - cwd: root, - encoding: 'utf8', - env: { ...process.env, INTENT_AUDIENCE: 'agent' }, - maxBuffer: 1024 * 1024, - shell: true, - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 9000, - }) - return JSON.parse(output) -} - -function formatDuration(durationMs) { - return \`\${durationMs.toFixed(1)}ms\` -} - -function formatSessionCatalog(result) { - if (!Array.isArray(result.skills) || result.skills.length === 0) return '' - - return [ - 'TanStack Intent skills are available in this repository.', - '', - 'Before substantial work, check whether one listed skill clearly matches the user task. If one clearly matches, load that full skill guidance with the Intent CLI before proceeding.', - '', - 'If no skill clearly matches, continue normally. Do not load a skill just to improve phrasing or gather nonessential context.', - '', - 'Available local Intent skills:', - formatSkillCatalog(result.skills), - formatWarnings(result), - ] - .filter(Boolean) - .join('\\n') -} - -function formatSkillCatalog(skills) { - return skills - .map((skill) => \`- \${skill.use}: \${normalizeDescription(skill.description)}\`) - .join('\\n') -} - -function normalizeDescription(description) { - return typeof description === 'string' ? description.replace(/\\s+/g, ' ').trim() : '' -} - -function formatWarnings(result) { - const warnings = [ - ...(Array.isArray(result.warnings) ? result.warnings : []), - ...(Array.isArray(result.conflicts) - ? result.conflicts.map( - (conflict) => - \`Version conflict for \${conflict.packageName}; using \${conflict.chosen.version}\`, - ) - : []), - ] - - if (warnings.length === 0) return '' - return \`\\nWarnings:\\n\${warnings.map((warning) => \`- \${warning}\`).join('\\n')}\` -} - -function sessionStartOutput(additionalContext) { - if (AGENT === 'copilot') { - return { additionalContext } - } - - return { - hookSpecificOutput: { - hookEventName: 'SessionStart', - additionalContext, - }, - } -} - -function stateFileForEvent(event) { - const sessionId = typeof event?.session_id === 'string' ? event.session_id : 'unknown' - const cwd = typeof event?.cwd === 'string' ? event.cwd : process.cwd() - const key = createHash('sha256').update(AGENT + '\\0' + cwd + '\\0' + sessionId).digest('hex') - return join(tmpdir(), 'tanstack-intent-hooks', key + '.jsonl') -} - -function observationFromEvent(event) { - if (!event || typeof event !== 'object') return undefined - const toolName = event.tool_name ?? event.toolName - const toolInput = event.tool_input ?? event.toolArgs - if (toolName !== 'Bash') return undefined - const command = typeof toolInput === 'string' ? safeCommandFromString(toolInput) : commandFromObject(toolInput) - const parsed = parseIntentInvocation(command) - if (!parsed || typeof command !== 'string') return undefined - return { action: parsed.action, skillUse: parsed.skillUse, raw: command } -} - -function parseIntentInvocation(command) { - if (typeof command !== 'string') return undefined - const match = command.match(INTENT_COMMAND_PATTERN) - if (!match?.[1] || !match[2]) return undefined - const action = match[2].toLowerCase() - if (action !== 'list' && action !== 'load') return undefined - const skillUse = action === 'load' ? match[3] : undefined - if (action === 'load' && !skillUse) return undefined - return action === 'load' ? { action, skillUse } : { action } -} - -function commandFromObject(value) { - return value && typeof value === 'object' ? value.command : undefined -} - -function safeCommandFromString(value) { - try { - const command = commandFromObject(JSON.parse(value)) - return typeof command === 'string' ? command : value - } catch { - return value - } -} - -function appendObservation(stateFile, observation) { - try { - mkdirSync(dirname(stateFile), { recursive: true }) - appendFileSync(stateFile, JSON.stringify({ ts: new Date().toISOString(), ...observation }) + '\\n') - } catch { - } -} - -function hasLoad(stateFile) { - if (!existsSync(stateFile)) return false - try { - return readFileSync(stateFile, 'utf8') - .split('\\n') - .filter(Boolean) - .some((line) => { - try { - return JSON.parse(line).action === 'load' - } catch { - return false - } - }) - } catch { - return false - } -} - -function denyOutput() { - if (AGENT === 'copilot') { - return { permissionDecision: 'deny', permissionDecisionReason: GATE_DENY_REASON } - } - - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: GATE_DENY_REASON, - }, - } -} -` -} - export function formatHookInstallResult(result: HookInstallResult): string { if (result.status === 'skipped') { return `Skipped Intent hooks for ${result.agent}: ${result.reason}` @@ -344,92 +101,54 @@ function installAgentHook({ } } - const { configPath, scriptPath } = adapter.paths(scope, { + const { configPath } = adapter.paths(scope, { copilotHome: copilotHome ?? process.env.COPILOT_HOME, homeDir, root, }) const catalogCommand = formatIntentCommand( detectPackageManager(root), - 'list --json --no-notices', - ) - const scriptStatus = writeIfChanged( - scriptPath, - buildHookRunnerScript(agent, catalogCommand), + `hooks run --agent ${agent}`, ) const configStatus = updateJsonConfig(configPath, (config) => upsertAdapterHooks({ + catalogCommand, config, configKind: adapter.configKind, - project: scope === 'project', - scriptPath, }), ) - return hookInstallResult({ - agent, - configPath, - scope, - scriptPath, - scriptStatus, - configStatus, - }) -} - -function hookInstallResult({ - agent, - configPath, - configStatus, - scope, - scriptPath, - scriptStatus, -}: { - agent: HookAgent - configPath: string - configStatus: HookInstallStatus - scope: HookInstallScope - scriptPath: string - scriptStatus: HookInstallStatus -}): HookInstallResult { return { agent, configPath, scope, - scriptPath, - status: - scriptStatus === 'created' || configStatus === 'created' - ? 'created' - : scriptStatus === 'updated' || configStatus === 'updated' - ? 'updated' - : 'unchanged', + scriptPath: null, + status: configStatus, } } function upsertAdapterHooks({ + catalogCommand, config, configKind, - project, - scriptPath, }: { + catalogCommand: string config: Record configKind: (typeof HOOK_AGENT_ADAPTERS)[HookAgent]['configKind'] - project: boolean - scriptPath: string }): Record { switch (configKind) { case 'claude-settings': - return upsertClaudeHooks(config, project, scriptPath) + return upsertClaudeHooks(config, catalogCommand) case 'codex-hooks': - return upsertCodexHooks(config, project, scriptPath) + return upsertCodexHooks(config, catalogCommand) case 'copilot-hooks': - return upsertCopilotHooks(config, scriptPath) + return upsertCopilotHooks(config, catalogCommand) } } function upsertClaudeHooks( config: Record, - project: boolean, - scriptPath: string, + catalogCommand: string, ): Record { const hooks = objectValue(config.hooks) hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { @@ -437,40 +156,19 @@ function upsertClaudeHooks( hooks: [ { type: 'command', - command: 'node', - args: [ - project - ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs' - : scriptPath, - ], + command: catalogCommand, timeout: 10, statusMessage: CATALOG_STATUS_MESSAGE, }, ], }) - hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { - matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit', - hooks: [ - { - type: 'command', - command: 'node', - args: [ - project - ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs' - : scriptPath, - ], - timeout: 10, - statusMessage: GATE_STATUS_MESSAGE, - }, - ], - }) + hooks.PreToolUse = removeIntentHooks(arrayValue(hooks.PreToolUse)) return { ...config, hooks } } function upsertCodexHooks( config: Record, - project: boolean, - scriptPath: string, + catalogCommand: string, ): Record { const hooks = objectValue(config.hooks) hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { @@ -478,41 +176,25 @@ function upsertCodexHooks( hooks: [ { type: 'command', - command: project - ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' - : `node ${quoteShell(scriptPath)}`, + command: catalogCommand, timeout: 10, statusMessage: CATALOG_STATUS_MESSAGE, }, ], }) - hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { - matcher: 'Bash|apply_patch|Edit|Write', - hooks: [ - { - type: 'command', - command: project - ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' - : `node ${quoteShell(scriptPath)}`, - timeout: 10, - statusMessage: GATE_STATUS_MESSAGE, - }, - ], - }) + hooks.PreToolUse = removeIntentHooks(arrayValue(hooks.PreToolUse)) return { ...config, hooks } } function upsertCopilotHooks( config: Record, - scriptPath: string, + catalogCommand: string, ): Record { const hooks = objectValue(config.hooks) hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { - command: `node ${quoteShell(scriptPath)}`, - }) - hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { - command: `node ${quoteShell(scriptPath)}`, + command: catalogCommand, }) + hooks.PreToolUse = removeIntentHooks(arrayValue(hooks.PreToolUse)) return { ...config, hooks } } @@ -520,7 +202,11 @@ function upsertHookGroup( groups: Array, nextGroup: Record, ): Array { - return [...groups.flatMap(withoutIntentHooks), nextGroup] + return [...removeIntentHooks(groups), nextGroup] +} + +function removeIntentHooks(groups: Array): Array { + return groups.flatMap(withoutIntentHooks) } function withoutIntentHooks(value: unknown): Array { @@ -544,12 +230,17 @@ function isIntentHook(value: unknown): boolean { ? entry.args.filter((arg): arg is string => typeof arg === 'string') : [] - return [command, ...args].some(isIntentGateScriptReference) + return [command, ...args].some(isIntentHookReference) } -function isIntentGateScriptReference(value: string): boolean { - return /(?:^|[\s"'/])(?:old-)?intent-(claude|codex|copilot)-gate\.mjs(?:$|[?#\s"'])/i.test( - value, +function isIntentHookReference(value: string): boolean { + return ( + /(?:^|[\s"'/])(?:old-)?intent-(claude|codex|copilot)-(?:gate|catalog)\.mjs(?:$|[?#\s"'])/i.test( + value, + ) || + /@tanstack\/intent(?:@[^\s]+)?\s+hooks\s+run\s+--agent\s+(?:copilot|claude|codex)(?:$|\s)/i.test( + value, + ) ) } @@ -571,17 +262,6 @@ function updateJsonConfig( return existed ? 'updated' : 'created' } -function writeIfChanged(filePath: string, content: string): HookInstallStatus { - const existed = existsSync(filePath) - if (existed && readFileSync(filePath, 'utf8') === content) { - return 'unchanged' - } - - mkdirSync(dirname(filePath), { recursive: true }) - writeFileSync(filePath, content) - return existed ? 'updated' : 'created' -} - function parseAgents(value: string | undefined): Array { if (!value || value === 'all') { return ALL_HOOK_AGENTS @@ -638,10 +318,6 @@ function arrayValue(value: unknown): Array { return Array.isArray(value) ? value : [] } -function quoteShell(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} - function formatPath(filePath: string): string { return relative(process.cwd(), filePath) || filePath } diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts deleted file mode 100644 index df9eb946..00000000 --- a/packages/intent/src/hooks/policy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { - HookAgent, - HookDecision, - IntentInvocation, - IntentObservation, - ToolEvent, -} from './types.js' - -const INTENT_COMMAND_PATTERN = - /(?:^|&&|\|\||;|\|)\s*((?:bunx\s+@tanstack\/intent(?:@latest)?)|(?:pnpm\s+exec\s+intent)|(?:pnpm\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:npx\s+@tanstack\/intent(?:@latest)?)|(?:yarn\s+dlx\s+@tanstack\/intent(?:@latest)?)|(?:intent))\s+(list|load)(?:\s+([^\s|;&]+))?/i - -export const EDIT_TOOLS_BY_AGENT: Record> = { - claude: new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']), - codex: new Set(['apply_patch', 'Write', 'Edit']), - copilot: new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']), -} - -export const GATE_DENY_REASON = - "Blocked: load matching TanStack guidance before editing. Follow this repo's TanStack guidance setup, then retry the edit." - -export function parseIntentInvocation( - command: unknown, -): IntentInvocation | undefined { - if (typeof command !== 'string') { - return undefined - } - - const match = command.match(INTENT_COMMAND_PATTERN) - - if (!match?.[1] || !match[2]) { - return undefined - } - - const action = match[2].toLowerCase() - - if (action !== 'list' && action !== 'load') { - return undefined - } - - const skillUse = action === 'load' ? match[3] : undefined - - if (action === 'load' && !skillUse) { - return undefined - } - - return action === 'load' ? { action, skillUse } : { action } -} - -export function observationFromEvent( - event: ToolEvent | undefined, -): IntentObservation | undefined { - if (!event || typeof event !== 'object') { - return undefined - } - - const toolName = event.tool_name ?? event.toolName - const toolInput = event.tool_input ?? event.toolArgs - - if (toolName !== 'Bash') { - return undefined - } - - const command = - typeof toolInput === 'string' - ? safeCommandFromString(toolInput) - : commandFromObject(toolInput) - - const parsed = parseIntentInvocation(command) - - if (!parsed || typeof command !== 'string') { - return undefined - } - - return { action: parsed.action, skillUse: parsed.skillUse, raw: command } -} - -export function gateDecision({ - agent, - hasLoaded, - toolName, -}: { - agent: HookAgent - hasLoaded: boolean - toolName: string -}): HookDecision { - if (EDIT_TOOLS_BY_AGENT[agent].has(toolName) && !hasLoaded) { - return { decision: 'deny', reason: GATE_DENY_REASON } - } - - return { decision: 'allow' } -} - -export function hasLoadFromObservations( - observations: Array | undefined>, -): boolean { - return observations.some((entry) => entry?.action === 'load') -} - -function commandFromObject(value: unknown): unknown { - return value && typeof value === 'object' - ? (value as { command?: unknown }).command - : undefined -} - -function safeCommandFromString(value: string): string { - try { - const parsed = JSON.parse(value) as unknown - const command = commandFromObject(parsed) - return typeof command === 'string' ? command : value - } catch { - return value - } -} diff --git a/packages/intent/src/hooks/types.ts b/packages/intent/src/hooks/types.ts index db5602ca..bc5b0816 100644 --- a/packages/intent/src/hooks/types.ts +++ b/packages/intent/src/hooks/types.ts @@ -1,23 +1,3 @@ export type HookAgent = 'claude' | 'codex' | 'copilot' export type HookInstallScope = 'project' | 'user' - -export type IntentInvocation = { - action: 'list' | 'load' - skillUse?: string -} - -export type IntentObservation = IntentInvocation & { - raw: string -} - -export type HookDecision = - | { decision: 'allow' } - | { decision: 'deny'; reason: string } - -export type ToolEvent = { - tool_name?: unknown - toolName?: unknown - tool_input?: unknown - toolArgs?: unknown -} diff --git a/packages/intent/src/session-catalog.ts b/packages/intent/src/session-catalog.ts new file mode 100644 index 00000000..4a635f06 --- /dev/null +++ b/packages/intent/src/session-catalog.ts @@ -0,0 +1,386 @@ +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { createHash } from 'node:crypto' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { resolveProjectContext } from './core/project-context.js' +import { computeSkillContentHash } from './core/lockfile/hash.js' +import { containsLocalPath } from './shared/local-path.js' +import { isGeneratedMappingSkill } from './skills/categories.js' +import { parseSkillUse } from './skills/use.js' +import { findWorkspacePackages } from './setup/workspace-patterns.js' +import type { IntentSkillList } from './core/index.js' +import type { ReadFs } from './shared/utils.js' + +const CACHE_SCHEMA_VERSION = 3 +const DEFAULT_MAX_CONTEXT_BYTES = 8_000 +const DEFAULT_MAX_SKILLS = 50 +const MIN_CONTEXT_BYTES = 512 +const MAX_DESCRIPTION_LENGTH = 180 +const FINGERPRINT_FILES = [ + 'package.json', + 'intent.lock', + 'pnpm-lock.yaml', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', + 'pnpm-workspace.yaml', + 'deno.json', + 'deno.jsonc', + 'deno.lock', +] + +interface SessionSkillSummary { + id: string + description: string +} + +export interface CatalogueVerificationEntry { + packageRoot: string + skillPath: string + contentHash: string +} + +export interface SessionCatalogue { + skills: Array + totalSkillCount: number +} + +export interface DiscoveredSessionCatalogue { + result: IntentSkillList + verification: Array +} + +interface IntentSessionCatalogueCache { + schemaVersion: number + workspaceRoot: string + policyRoot: string + dependencyFingerprint: string + catalogue: SessionCatalogue + verification: Array +} + +export interface SessionCatalogueResult { + cachePath: string + cacheStatus: 'hit' | 'miss' | 'refresh' + catalogue: SessionCatalogue +} + +export function buildSessionCatalogue( + result: IntentSkillList, + options: { maxSkills?: number } = {}, +): SessionCatalogue { + const maxSkills = options.maxSkills ?? DEFAULT_MAX_SKILLS + const allSkills = result.skills + .filter(isGeneratedMappingSkill) + .map((skill): SessionSkillSummary => { + parseSkillUse(skill.use) + const normalizedDescription = normalizeWhitespace(skill.description) + const description = containsLocalPath(normalizedDescription) + ? '' + : truncateText(normalizedDescription, MAX_DESCRIPTION_LENGTH) + return { + id: skill.use, + description: description || `Use ${skill.use}`, + } + }) + .sort((left, right) => compareOrdinal(left.id, right.id)) + return { + skills: allSkills.slice(0, maxSkills), + totalSkillCount: allSkills.length, + } +} + +export function formatSessionCatalogue( + catalogue: SessionCatalogue, + options: { maxBytes?: number } = {}, +): string { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_CONTEXT_BYTES + if (!Number.isInteger(maxBytes) || maxBytes < MIN_CONTEXT_BYTES) { + throw new RangeError( + `Session catalogue maxBytes must be an integer of at least ${MIN_CONTEXT_BYTES}.`, + ) + } + if (catalogue.skills.length === 0) { + return 'No available Intent skills.' + } + + const baseLines = ['Available Intent skills:', ''] + const footerLines = [ + '', + 'Load a matching skill with `intent load `. If none match, continue normally.', + ] + const skillLines: Array = [] + + for (const skill of catalogue.skills) { + const nextSkillLines = [ + ...skillLines, + `- ${skill.id}: ${skill.description}`, + ] + const omitted = catalogue.totalSkillCount - nextSkillLines.length + const candidateLines = [ + ...baseLines, + ...nextSkillLines, + ...(omitted > 0 ? [formatOmittedSkills(omitted)] : []), + ...footerLines, + ] + if (!fits(candidateLines, maxBytes)) break + skillLines.push(nextSkillLines.at(-1)!) + } + + const omitted = catalogue.totalSkillCount - skillLines.length + const lines = [ + ...baseLines, + ...skillLines, + ...(omitted > 0 ? [formatOmittedSkills(omitted)] : []), + ...footerLines, + ] + if (!fits(lines, maxBytes)) { + throw new RangeError( + 'Session catalogue maxBytes must be large enough for complete guidance.', + ) + } + return lines.join('\n') +} + +export function resolveCatalogueWorkspaceRoot(cwd: string): string { + const context = resolveProjectContext({ cwd }) + return normalizeRoot(context.workspaceRoot ?? context.packageRoot ?? cwd) +} + +export async function getSessionCatalogue({ + cacheDir = join(tmpdir(), 'tanstack-intent', 'catalogues'), + discover, + refresh = false, + root, + policyRoot = root, + readFs, +}: { + cacheDir?: string + discover: () => + | DiscoveredSessionCatalogue + | Promise + refresh?: boolean + root: string + policyRoot?: string + readFs?: ReadFs +}): Promise { + const workspaceRoot = normalizeRoot(root) + const normalizedPolicyRoot = normalizeRoot(policyRoot) + const dependencyFingerprint = computeCatalogueFingerprint( + workspaceRoot, + normalizedPolicyRoot, + ) + const cachePath = join( + cacheDir, + `${createHash('sha256').update(workspaceRoot).update('\0').update(normalizedPolicyRoot).digest('hex')}.json`, + ) + const cached = readCache(cachePath) + + if ( + !refresh && + cached?.workspaceRoot === workspaceRoot && + cached.policyRoot === normalizedPolicyRoot && + cached.dependencyFingerprint === dependencyFingerprint && + verifyCatalogueContent(cached.verification, readFs) + ) { + return { + cachePath, + cacheStatus: 'hit', + catalogue: cached.catalogue, + } + } + + const refreshed = await discover() + const catalogue = buildSessionCatalogue(refreshed.result) + const entry: IntentSessionCatalogueCache = { + schemaVersion: CACHE_SCHEMA_VERSION, + workspaceRoot, + policyRoot: normalizedPolicyRoot, + dependencyFingerprint, + catalogue, + verification: refreshed.verification, + } + writeCache(cachePath, entry) + + return { + cachePath, + cacheStatus: cached ? 'refresh' : 'miss', + catalogue, + } +} + +function computeCatalogueFingerprint(root: string, policyRoot: string): string { + const normalizedRoot = normalizeRoot(root) + const packageRoots = [ + normalizedRoot, + ...findWorkspacePackages(normalizedRoot), + ] + const files = [ + ...FINGERPRINT_FILES.map((file) => join(normalizedRoot, file)), + ...packageRoots.map((packageRoot) => join(packageRoot, 'package.json')), + ...policyManifestPaths(normalizedRoot, policyRoot), + ] + const hash = createHash('sha256') + hash.update(String(CACHE_SCHEMA_VERSION)) + + for (const file of [...new Set(files)].sort(compareOrdinal)) { + hash.update('\0') + hash.update(file.slice(normalizedRoot.length).replace(/\\/g, '/')) + hash.update('\0') + try { + hash.update(readFileSync(file)) + } catch { + hash.update('') + } + } + + return hash.digest('hex') +} + +function verifyCatalogueContent( + entries: ReadonlyArray, + fs?: ReadFs, +): boolean { + try { + return entries.every( + (entry) => + computeSkillContentHash({ + packageRoot: entry.packageRoot, + skillDir: entry.skillPath, + fs, + }) === entry.contentHash, + ) + } catch { + return false + } +} + +function normalizeRoot(root: string): string { + const resolved = resolve(root) + const real = existsSync(resolved) ? realpathSync.native(resolved) : resolved + const normalized = real.replace(/\\/g, '/') + return /^[A-Z]:/.test(normalized) + ? `${normalized[0]!.toLowerCase()}${normalized.slice(1)}` + : normalized +} + +function policyManifestPaths( + workspaceRoot: string, + policyRoot: string, +): Array { + const relativePolicyRoot = relative(workspaceRoot, policyRoot) + if ( + relativePolicyRoot.startsWith('..') || + relativePolicyRoot.startsWith('/') + ) { + return [join(policyRoot, 'package.json')] + } + + const manifests: Array = [] + let directory = policyRoot + while (directory !== workspaceRoot) { + manifests.push(join(directory, 'package.json')) + directory = dirname(directory) + } + manifests.push(join(workspaceRoot, 'package.json')) + return manifests +} + +function compareOrdinal(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +function truncateText(value: string, maxLength: number): string { + const codePoints = [...value] + if (codePoints.length <= maxLength) return value + return `${codePoints + .slice(0, maxLength - 3) + .join('') + .trimEnd()}...` +} + +function formatOmittedSkills(count: number): string { + return `- ${count} additional ${count === 1 ? 'skill' : 'skills'} omitted; narrow the catalogue with package.json intent.skills or intent.exclude.` +} + +function fits(lines: Array, maxBytes: number): boolean { + return Buffer.byteLength(lines.join('\n')) <= maxBytes +} + +function readCache(path: string): IntentSessionCatalogueCache | null { + try { + const value = JSON.parse(readFileSync(path, 'utf8')) as unknown + return isCacheEntry(value) ? value : null + } catch { + return null + } +} + +function isCacheEntry(value: unknown): value is IntentSessionCatalogueCache { + if (!value || typeof value !== 'object') return false + const entry = value as Partial + return ( + entry.schemaVersion === CACHE_SCHEMA_VERSION && + typeof entry.workspaceRoot === 'string' && + typeof entry.policyRoot === 'string' && + typeof entry.dependencyFingerprint === 'string' && + isCatalogue(entry.catalogue) && + Array.isArray(entry.verification) && + entry.verification.every(isVerificationEntry) + ) +} + +function isCatalogue(value: unknown): value is SessionCatalogue { + if (!value || typeof value !== 'object') return false + const catalogue = value as Partial + return ( + Array.isArray(catalogue.skills) && + catalogue.skills.every(isSkillSummary) && + typeof catalogue.totalSkillCount === 'number' + ) +} + +function isSkillSummary(value: unknown): value is SessionSkillSummary { + if (!value || typeof value !== 'object') return false + const skill = value as Partial + return typeof skill.id === 'string' && typeof skill.description === 'string' +} + +function isVerificationEntry( + value: unknown, +): value is CatalogueVerificationEntry { + if (!value || typeof value !== 'object') return false + const entry = value as Partial + return ( + typeof entry.packageRoot === 'string' && + typeof entry.skillPath === 'string' && + typeof entry.contentHash === 'string' + ) +} + +function writeCache(path: string, entry: IntentSessionCatalogueCache): void { + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp` + try { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(temporaryPath, `${JSON.stringify(entry)}\n`, { flag: 'wx' }) + renameSync(temporaryPath, path) + } catch { + try { + rmSync(temporaryPath, { force: true }) + } catch {} + } +} diff --git a/packages/intent/src/setup/workspace-patterns.ts b/packages/intent/src/setup/workspace-patterns.ts index 08005eed..fd14b20c 100644 --- a/packages/intent/src/setup/workspace-patterns.ts +++ b/packages/intent/src/setup/workspace-patterns.ts @@ -1,9 +1,18 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' -import { parse as parseYaml } from 'yaml' import { hasAnySkillFile } from '../shared/utils.js' import type { ParseError } from 'jsonc-parser' +import type { parse as ParseYaml } from 'yaml' + +const requireFromHere = createRequire(import.meta.url) +let parseYaml: typeof ParseYaml | undefined + +function getParseYaml(): typeof ParseYaml { + parseYaml ??= (requireFromHere('yaml') as { parse: typeof ParseYaml }).parse + return parseYaml +} function normalizeWorkspacePattern(pattern: string): string { return pattern.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '') @@ -72,7 +81,7 @@ function hasWorkspaceManifest(dir: string): boolean { } function readYamlFile(path: string): unknown { - return parseYaml(readFileSync(path, 'utf8')) + return getParseYaml()(readFileSync(path, 'utf8')) } function readJsonFile(path: string): unknown { diff --git a/packages/intent/src/shared/atomic-write.ts b/packages/intent/src/shared/atomic-write.ts new file mode 100644 index 00000000..da4bd292 --- /dev/null +++ b/packages/intent/src/shared/atomic-write.ts @@ -0,0 +1,25 @@ +import { + existsSync, + mkdirSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' + +export function writeTextFileAtomic(path: string, content: string): void { + const directory = dirname(path) + mkdirSync(directory, { recursive: true }) + const temporaryPath = join( + directory, + `.${basename(path)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`, + ) + try { + writeFileSync(temporaryPath, content, 'utf8') + renameSync(temporaryPath, path) + } finally { + try { + if (existsSync(temporaryPath)) unlinkSync(temporaryPath) + } catch {} + } +} diff --git a/packages/intent/src/shared/command-runner.ts b/packages/intent/src/shared/command-runner.ts index 0bd681a9..1c0ef651 100644 --- a/packages/intent/src/shared/command-runner.ts +++ b/packages/intent/src/shared/command-runner.ts @@ -1,14 +1,50 @@ -import { detectPackageManager } from '../discovery/package-manager.js' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { PackageManager } from './types.js' -export { detectPackageManager as detectIntentCommandPackageManager } +export function packageVersionToPin(version: string): string { + if (version.includes('-')) return version + + const [major, minor] = version.split('.') + if (!major || !minor) throw new Error(`Invalid package version: ${version}`) + return `${major}.${minor}` +} + +function resolveIntentPackagePin(startDir: string): string { + let dir = startDir + + for (let limit = 0; limit < 10; limit++) { + try { + const packageJson = JSON.parse( + readFileSync(join(dir, 'package.json'), 'utf8'), + ) as { name?: unknown; version?: unknown } + if ( + packageJson.name === '@tanstack/intent' && + typeof packageJson.version === 'string' + ) { + return packageVersionToPin(packageJson.version) + } + } catch {} + + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + + return 'latest' +} + +const intentPackagePin = resolveIntentPackagePin( + dirname(fileURLToPath(import.meta.url)), +) const runnerByPackageManager: Record = { - bun: 'bunx @tanstack/intent@latest', - npm: 'npx @tanstack/intent@latest', - pnpm: 'pnpm dlx @tanstack/intent@latest', - unknown: 'npx @tanstack/intent@latest', - yarn: 'yarn dlx @tanstack/intent@latest', + bun: `bunx @tanstack/intent@${intentPackagePin}`, + npm: `npx @tanstack/intent@${intentPackagePin}`, + pnpm: `pnpm dlx @tanstack/intent@${intentPackagePin}`, + unknown: `npx @tanstack/intent@${intentPackagePin}`, + yarn: `yarn dlx @tanstack/intent@${intentPackagePin}`, } export function formatIntentCommand( diff --git a/packages/intent/src/shared/display.ts b/packages/intent/src/shared/display.ts index 1cf7be52..08501e23 100644 --- a/packages/intent/src/shared/display.ts +++ b/packages/intent/src/shared/display.ts @@ -13,6 +13,7 @@ export interface SkillDisplay { loadCommand?: string type?: string path?: string + why?: string } function padColumn(text: string, width: number): string { @@ -49,6 +50,9 @@ function printSkillLine( ? (skill.type ? `[${skill.type}]` : '').padEnd(14) : '' console.log(`${nameStr}${padding}${typeCol}${skill.description}`) + if (skill.why) { + console.log(`${' '.repeat(indent + 2)}${skill.why}`) + } if (skill.loadCommand) { console.log(`${' '.repeat(indent + 2)}Load: ${skill.loadCommand}`) } diff --git a/packages/intent/src/shared/local-path.ts b/packages/intent/src/shared/local-path.ts new file mode 100644 index 00000000..6e0db508 --- /dev/null +++ b/packages/intent/src/shared/local-path.ts @@ -0,0 +1,69 @@ +const EXPLICIT_LOCAL_PATH_PATTERN = + /(?:^|[\s"'`(]|\[)(?:file:(?:\/{1,3}|[A-Za-z]:[\\/])|\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\\\\[^\\\s]+[\\/])/i +const PACKAGE_MANAGER_PATH_PATTERN = + /(?:^|[\s"'`(]|\[)[^\s"'`]*(?:node_modules|\.pnpm|\.bun|\.yarn|\.intent)[\\/]/i +const POSIX_PATH_CANDIDATE_PATTERN = /(?:^|[\s"'`(]|\[)(\/[^\s"'`)\],;]+)/g +const SYSTEM_POSIX_ROOTS = new Set([ + 'Applications', + 'Library', + 'System', + 'bin', + 'boot', + 'dev', + 'etc', + 'lib', + 'lib64', + 'private', + 'proc', + 'root', + 'run', + 'sbin', + 'sys', + 'tmp', + 'usr', + 'var', +]) +const USER_DATA_POSIX_ROOTS = new Set([ + 'Users', + 'Volumes', + 'home', + 'media', + 'mnt', + 'opt', + 'srv', + 'workspace', +]) + +export function containsLocalPath(value: string): boolean { + if ( + EXPLICIT_LOCAL_PATH_PATTERN.test(value) || + PACKAGE_MANAGER_PATH_PATTERN.test(value) + ) { + return true + } + + for (const match of value.matchAll(POSIX_PATH_CANDIDATE_PATTERN)) { + if (isLikelyLocalPosixPath(match[1]!)) return true + } + + return false +} + +function isLikelyLocalPosixPath(candidate: string): boolean { + const path = candidate.replace(/[.!?:]+$/, '') + const segments = path.slice(1).split('/').filter(Boolean) + const root = segments[0] + const leaf = segments.at(-1) ?? '' + + return ( + looksLikeFileName(leaf) || + (root !== undefined && SYSTEM_POSIX_ROOTS.has(root)) || + (root !== undefined && + USER_DATA_POSIX_ROOTS.has(root) && + segments.length >= 3) + ) +} + +function looksLikeFileName(value: string): boolean { + return value.startsWith('.') || /\.[A-Za-z0-9][\w.-]*$/.test(value) +} diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index f3d6930e..7b97369f 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -11,8 +11,16 @@ import { } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, resolve, sep } from 'node:path' -import { parse as parseYaml } from 'yaml' import type { Dirent } from 'node:fs' +import type { parse as ParseYaml } from 'yaml' + +const requireFromHere = createRequire(import.meta.url) +let parseYaml: typeof ParseYaml | undefined + +function getParseYaml(): typeof ParseYaml { + parseYaml ??= (requireFromHere('yaml') as { parse: typeof ParseYaml }).parse + return parseYaml +} /** * The subset of `node:fs` the scanner reads through. Under Yarn PnP this is @@ -402,7 +410,7 @@ export function parseFrontmatter( const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) if (!match?.[1]) return null try { - return parseYaml(match[1]) as Record + return getParseYaml()(match[1]) as Record } catch { return null } diff --git a/packages/intent/src/skills/resolver.ts b/packages/intent/src/skills/resolver.ts index 93f0142d..849a2ea1 100644 --- a/packages/intent/src/skills/resolver.ts +++ b/packages/intent/src/skills/resolver.ts @@ -8,6 +8,7 @@ import type { } from '../shared/types.js' export interface ResolveSkillResult { + kind: IntentPackage['kind'] packageName: string skillName: string path: string @@ -182,6 +183,7 @@ export function resolveSkillUse( ) ?? null return { + kind: pkg.kind, packageName, skillName: skill.name, path: skill.path, diff --git a/packages/intent/tests/catalog-api.test.ts b/packages/intent/tests/catalog-api.test.ts new file mode 100644 index 00000000..b6761e72 --- /dev/null +++ b/packages/intent/tests/catalog-api.test.ts @@ -0,0 +1,164 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getIntentCatalogContext, + runSessionCatalogueHook, +} from '../src/catalog.js' +import { computeSkillContentHash } from '../src/core/lockfile/hash.js' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' + +const roots: Array = [] + +function fixture(): { root: string; packageRoot: string; skillDir: string } { + const root = mkdtempSync(join(tmpdir(), 'intent-catalog-api-')) + const packageRoot = join(root, 'node_modules', '@fixture', 'package') + const skillDir = join(packageRoot, 'skills', 'core') + const siblingDir = join(packageRoot, 'skills', 'sibling') + roots.push(root) + mkdirSync(skillDir, { recursive: true }) + mkdirSync(siblingDir, { recursive: true }) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'catalog-consumer', + private: true, + dependencies: { '@fixture/package': '1.0.0' }, + intent: { skills: ['@fixture/package'] }, + }), + ) + writeFileSync( + join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@fixture/package', + version: '1.0.0', + intent: { version: 1, repo: 'fixture/package', docs: 'docs/' }, + }), + ) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Core package guidance\n---\n\nBody.\n', + ) + writeFileSync( + join(siblingDir, 'SKILL.md'), + '---\nname: sibling\ndescription: Sibling package guidance\n---\n', + ) + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [ + { + kind: 'npm', + id: '@fixture/package', + skills: [ + { + path: 'skills/core', + contentHash: computeSkillContentHash({ packageRoot, skillDir }), + }, + { + path: 'skills/sibling', + contentHash: computeSkillContentHash({ + packageRoot, + skillDir: siblingDir, + }), + }, + ], + }, + ], + }) + return { root, packageRoot, skillDir } +} + +afterEach(() => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('getIntentCatalogContext', () => { + it('reuses accepted context and withholds drifted skill content', async () => { + const { root, skillDir } = fixture() + + const first = await getIntentCatalogContext({ cwd: root }) + const second = await getIntentCatalogContext({ cwd: root }) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Changed guidance\n---\n', + ) + const changed = await getIntentCatalogContext({ cwd: root }) + + expect(first.cacheStatus).toBe('miss') + expect(Object.keys(first).sort()).toEqual(['cacheStatus', 'context']) + expect(first.context).toContain('@fixture/package#core') + expect(second.cacheStatus).toBe('hit') + expect(changed.cacheStatus).toBe('refresh') + expect(changed.context).not.toContain('@fixture/package#core') + expect(changed.context).toContain('@fixture/package#sibling') + }) + + it('withholds a newly discovered skill without changing accepted siblings', async () => { + const { root, packageRoot } = fixture() + const newSkillDir = join(packageRoot, 'skills', 'new-skill') + mkdirSync(newSkillDir, { recursive: true }) + writeFileSync( + join(newSkillDir, 'SKILL.md'), + '---\nname: new-skill\ndescription: New package guidance\n---\n', + ) + + const result = await getIntentCatalogContext({ cwd: root }) + + expect(result.context).toContain('@fixture/package#core') + expect(result.context).toContain('@fixture/package#sibling') + expect(result.context).not.toContain('@fixture/package#new-skill') + }) +}) + +describe('runSessionCatalogueHook', () => { + it('writes the documented Copilot output shape', async () => { + const { root } = fixture() + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + await runSessionCatalogueHook({ + agent: 'copilot', + event: { cwd: root, source: 'startup' }, + }) + + expect(JSON.parse(String(stdout.mock.calls[0]![0]))).toMatchObject({ + additionalContext: expect.stringContaining('@fixture/package#core'), + }) + expect(stderr).not.toHaveBeenCalled() + }) + + it('writes the documented Codex output shape', async () => { + const { root } = fixture() + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + await runSessionCatalogueHook({ + agent: 'codex', + event: { cwd: root, hook_event_name: 'SessionStart' }, + }) + + expect(JSON.parse(String(stdout.mock.calls[0]![0]))).toMatchObject({ + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext: expect.stringContaining('@fixture/package#core'), + }, + }) + expect(stderr).not.toHaveBeenCalled() + }) + + it('ignores non-lifecycle events', async () => { + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + + await runSessionCatalogueHook({ agent: 'claude', event: {} }) + + expect(stdout).not.toHaveBeenCalled() + }) +}) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index aedcbe47..e035be39 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1,8 +1,10 @@ import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, realpathSync, rmSync, writeFileSync, @@ -11,12 +13,19 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { INSTALL_PROMPT } from '../src/commands/install/command.js' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { serializeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { scanForIntents } from '../src/discovery/scanner.js' +import { packageVersionToPin } from '../src/shared/command-runner.js' import { isMainModule, main } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) const metaDir = join(thisDir, '..', 'meta') const packageJsonPath = join(thisDir, '..', 'package.json') +const intentPackagePin = packageVersionToPin( + (JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { version: string }) + .version, +) const realTmpdir = realpathSync(tmpdir()) function writeJson(filePath: string, data: unknown): void { @@ -222,20 +231,801 @@ describe('cli commands', () => { expect(output).toContain('--show-hidden') }) - it('prints the install prompt', async () => { + it('tells consumers to install before syncing without configuration or a lockfile', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-sync-unconfigured-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + process.chdir(root) + + const exitCode = await main(['sync']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Intent sync requires intent.install configuration and intent.lock. Run `intent install` first.', + ) + }) + + it('syncs verified links and reports changed, pending, removed, and dry-run work', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-sync-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + install: { method: 'symlink', targets: ['github', 'vscode'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + process.chdir(root) + const discovered = scanForIntents(root, { scope: 'local' }).packages + writeFileSync( + join(root, 'intent.lock'), + serializeIntentLockfile({ + lockfileVersion: 1, + sources: buildCurrentLockfileSources(discovered), + }), + ) + + expect(await main(['sync'])).toBe(0) + const linkPath = join(root, '.github', 'skills', 'npm-verified-core') + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + const state = readFileSync( + join(root, '.intent', 'install-state.json'), + 'utf8', + ) + const gitignore = readFileSync(join(root, '.gitignore'), 'utf8') + expect(gitignore).toContain('.github/skills/npm-verified-core') + expect(await main(['sync'])).toBe(0) + expect( + readFileSync(join(root, '.intent', 'install-state.json'), 'utf8'), + ).toBe(state) + + writeSkillMd( + join(root, 'node_modules', 'verified', 'skills', 'additional'), + { + name: 'additional', + description: 'Additional skill', + }, + ) + expect(await main(['sync'])).toBe(0) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + [ + 'New skills found in enabled dependencies:', + '', + 'verified 1 skill', + '', + 'Run `intent install` to review and install them.', + ].join('\n'), + ) + + writeFileSync( + join(root, 'node_modules', 'verified', 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: changed\n---\n', + ) + expect(await main(['sync'])).toBe(0) + expect(existsSync(linkPath)).toBe(false) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + [ + 'Changed skill content:', + '', + 'verified 1 skill', + '', + 'Run `intent install` to review and accept the new baseline.', + ].join('\n'), + ) + expect( + JSON.parse( + readFileSync(join(root, '.intent', 'install-state.json'), 'utf8'), + ), + ).toEqual({ version: 1, entries: [] }) + + writeInstalledIntentPackage(root, { + name: 'pending', + version: '1.0.0', + skillName: 'new', + description: 'Pending skill', + }) + expect(await main(['sync'])).toBe(0) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + [ + 'New dependencies with skills found:', + '', + 'pending 1 skill', + '', + 'Run `intent install` to review and install them, or add them to `intent.exclude`.', + ].join('\n'), + ) + + rmSync(join(root, 'node_modules', 'verified'), { + recursive: true, + force: true, + }) + expect(await main(['sync'])).toBe(0) + expect(existsSync(linkPath)).toBe(false) + + const dryRoot = mkdtempSync(join(realTmpdir, 'intent-cli-sync-dry-run-')) + tempDirs.push(dryRoot) + writeJson(join(dryRoot, 'package.json'), { + name: 'dry-app', + private: true, + intent: { + skills: ['dry-package'], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeInstalledIntentPackage(dryRoot, { + name: 'dry-package', + version: '1.0.0', + skillName: 'core', + description: 'Dry skill', + }) + const dryDiscovered = scanForIntents(dryRoot, { scope: 'local' }).packages + writeFileSync( + join(dryRoot, 'intent.lock'), + serializeIntentLockfile({ + lockfileVersion: 1, + sources: buildCurrentLockfileSources(dryDiscovered), + }), + ) + process.chdir(dryRoot) + expect(await main(['sync', '--dry-run', '--json'])).toBe(0) + expect( + existsSync(join(dryRoot, '.agents', 'skills', 'npm-dry-package-core')), + ).toBe(false) + expect(existsSync(join(dryRoot, '.intent', 'install-state.json'))).toBe( + false, + ) + }) + + it('rejects the removed install --print-prompt option', async () => { const exitCode = await main(['install', '--print-prompt']) - const output = String(logSpy.mock.calls[0]?.[0]) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Unknown option `--printPrompt`') + }) + + it('rejects install --map --no-input before writing files', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-flags-')) + tempDirs.push(root) + const entriesBefore = readdirSync(root) + process.chdir(root) + + expect(await main(['install', '--map', '--no-input'])).toBe(1) + + expect(errorSpy).toHaveBeenCalledWith( + 'Cannot combine --map and --no-input.', + ) + expect(readdirSync(root)).toEqual(entriesBefore) + }) + + it('names missing declarative config for install --no-input', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-no-config-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + process.chdir(root) + + const exitCode = await main(['install', '--no-input']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Non-interactive install requires an explicit package.json intent.skills array. Add intent.skills (use [] to deny all) before running `intent install --no-input`.', + ) + }) + + it('requires first-bootstrap config in the workspace root manifest', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-leaf-bootstrap-'), + ) + const leaf = join(root, 'packages', 'leaf') + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'workspace', + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(leaf, 'package.json'), { + name: 'leaf', + private: true, + intent: { + skills: ['verified'], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.chdir(leaf) + + expect(await main(['install', '--no-input'])).toBe(1) + + expect(errorSpy).toHaveBeenCalledWith( + 'Non-interactive install requires an explicit package.json intent.skills array. Add intent.skills (use [] to deny all) before running `intent install --no-input`.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + for (const path of [ + 'intent.lock', + '.gitignore', + '.intent', + '.agents', + '.github', + '.claude', + '.codex', + ]) { + expect(existsSync(join(root, path))).toBe(false) + } + }) + + it('names missing intent.install for install --no-input', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-no-method-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [] }, + }) + process.chdir(root) + + const exitCode = await main(['install', '--no-input']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Non-interactive install requires an explicit valid package.json intent.install object. Configure intent.install.method and intent.install.targets before running `intent install --no-input`.', + ) + }) + + it('fails cleanly when install --no-input has no package.json', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-no-package-')) + tempDirs.push(root) + process.chdir(root) + + const exitCode = await main(['install', '--no-input']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + `Non-interactive install requires package.json in ${root}.`, + ) + }) + + it('bootstraps configured policy and symlink targets with install --no-input', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-bootstrap-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified#core'], + exclude: ['verified#draft'], + install: { method: 'symlink', targets: ['agents', 'github'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + writeSkillMd(join(root, 'node_modules', 'verified', 'skills', 'draft'), { + name: 'draft', + description: 'Draft skill', + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.chdir(root) + + const exitCode = await main(['install', '--no-input']) expect(exitCode).toBe(0) - expect(logSpy).toHaveBeenCalledWith(INSTALL_PROMPT) - expect(output).toContain('tanstackIntent:') - expect(output).toContain(' - id: "@scope/package#skill-name"') - expect(output).toContain( - ' run: "npx @tanstack/intent@latest load @scope/package#skill-name"', + expect(errorSpy).not.toHaveBeenCalled() + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(true) + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'Skills will not re-sync automatically because the prepare script was not wired.', + ) + for (const target of ['.agents', '.github']) { + expect( + lstatSync( + join(root, target, 'skills', 'npm-verified-core'), + ).isSymbolicLink(), + ).toBe(true) + expect( + existsSync(join(root, target, 'skills', 'npm-verified-draft')), + ).toBe(false) + } + }) + + it('does not infer delivery targets for install --no-input', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-targets-')) + tempDirs.push(root) + mkdirSync(join(root, '.vscode')) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(0) + + expect( + lstatSync( + join(root, '.agents', 'skills', 'npm-verified-core'), + ).isSymbolicLink(), + ).toBe(true) + expect(existsSync(join(root, '.github'))).toBe(false) + }) + + it('accepts an explicit empty skill policy without synthesizing trust', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-empty-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: [], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'untrusted', + version: '1.0.0', + skillName: 'core', + description: 'Untrusted skill', + }) + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(0) + expect(await main(['install', '--no-input'])).toBe(0) + + expect(JSON.parse(readFileSync(join(root, 'intent.lock'), 'utf8'))).toEqual( + { lockfileVersion: 1, sources: [] }, + ) + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).intent + .skills, + ).toEqual([]) + expect(existsSync(join(root, '.agents', 'skills'))).toBe(false) + }) + + it('rejects incomplete configured policy before bootstrap writes', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-incomplete-policy-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['@acme/query#fetching'], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: '@acme/query', + version: '1.0.0', + skillName: 'fetching', + description: 'Query skill', + }) + writeInstalledIntentPackage(root, { + name: '@acme/router', + version: '1.0.0', + skillName: 'routing', + description: 'Router skill', + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(1) + + expect(errorSpy).toHaveBeenCalledWith( + 'Configured policy leaves "@acme/router#routing" pending. Add it to intent.skills or intent.exclude before non-interactive install.', ) - expect(output).toContain(' for: "describe the task or code area here"') - expect(output).not.toContain('skills:\n - when:') - expect(output).not.toContain('use: "@scope/package#skill-name"') + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + for (const path of ['intent.lock', '.intent', '.agents']) { + expect(existsSync(join(root, path))).toBe(false) + } + }) + + it('keeps package and lock bytes unchanged for configured content drift', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-changed-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + const discovered = scanForIntents(root, { scope: 'local' }).packages + writeFileSync( + join(root, 'intent.lock'), + serializeIntentLockfile({ + lockfileVersion: 1, + sources: buildCurrentLockfileSources(discovered), + }), + ) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + writeFileSync( + join(root, 'node_modules', 'verified', 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: Changed skill\n---\n', + ) + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(1) + + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'Changed skill content:', + ) + expect(errorSpy).toHaveBeenCalledWith( + 'Intent sync requires review before automation can continue.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('uses the workspace root config and lock for install --no-input from a leaf', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-leaf-sync-')) + const leaf = join(root, 'packages', 'leaf') + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'workspace', + private: true, + workspaces: ['packages/*'], + intent: { + skills: ['verified'], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeJson(join(leaf, 'package.json'), { + name: 'leaf', + private: true, + intent: { + skills: [], + install: { method: 'symlink', targets: ['github'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + const discovered = scanForIntents(root, { scope: 'local' }).packages + writeFileSync( + join(root, 'intent.lock'), + serializeIntentLockfile({ + lockfileVersion: 1, + sources: buildCurrentLockfileSources(discovered), + }), + ) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + writeFileSync( + join(root, 'node_modules', 'verified', 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: Changed skill\n---\n', + ) + process.chdir(leaf) + + expect(await main(['install', '--no-input'])).toBe(1) + + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'Changed skill content:', + ) + expect(errorSpy).toHaveBeenCalledWith( + 'Intent sync requires review before automation can continue.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('leaves new dependencies pending for configured install --no-input', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-pending-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + const discovered = scanForIntents(root, { scope: 'local' }).packages + writeFileSync( + join(root, 'intent.lock'), + serializeIntentLockfile({ + lockfileVersion: 1, + sources: buildCurrentLockfileSources(discovered), + }), + ) + writeInstalledIntentPackage(root, { + name: 'pending', + version: '1.0.0', + skillName: 'new', + description: 'Pending skill', + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(1) + + expect(logSpy.mock.calls.flat().join('\n')).toContain( + 'New dependencies with skills found:', + ) + expect(errorSpy).toHaveBeenCalledWith( + 'Intent sync requires review before automation can continue.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('performs no writes for first-time install --no-input --dry-run', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-dry-run-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.chdir(root) + + expect(await main(['install', '--no-input', '--dry-run'])).toBe(0) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + for (const path of [ + 'intent.lock', + '.gitignore', + '.intent', + '.agents', + '.github', + '.claude', + '.codex', + ]) { + expect(existsSync(join(root, path))).toBe(false) + } + }) + + it('bootstraps configured project-scope hooks without prompts', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-hooks-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + install: { method: 'hooks', targets: ['claude', 'codex'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(0) + + expect(existsSync(join(root, 'intent.lock'))).toBe(true) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(true) + }) + + it('rejects configured Copilot hook bootstrap before writes', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-copilot-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + install: { method: 'hooks', targets: ['claude', 'github'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(1) + + expect(errorSpy).toHaveBeenCalledWith( + 'Non-interactive install cannot bootstrap GitHub Copilot hooks because they require interactive approval for user-home access. Remove "github" from intent.install.targets or run `intent install` in a terminal.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + for (const path of ['intent.lock', '.intent', '.claude', '.codex']) { + expect(existsSync(join(root, path))).toBe(false) + } + }) + + it('uses the workspace root Copilot guard for install --no-input from a leaf', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-leaf-copilot-'), + ) + const leaf = join(root, 'packages', 'leaf') + const copilotHome = join(root, 'copilot-home') + const previousCopilotHome = process.env.COPILOT_HOME + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'workspace', + private: true, + workspaces: ['packages/*'], + intent: { + skills: ['verified'], + install: { method: 'hooks', targets: ['claude', 'github'] }, + }, + }) + writeJson(join(leaf, 'package.json'), { + name: 'leaf', + private: true, + intent: { + skills: ['verified'], + install: { method: 'hooks', targets: ['claude'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.env.COPILOT_HOME = copilotHome + process.chdir(leaf) + + try { + expect(await main(['install', '--no-input'])).toBe(1) + } finally { + if (previousCopilotHome === undefined) { + delete process.env.COPILOT_HOME + } else { + process.env.COPILOT_HOME = previousCopilotHome + } + } + + expect(errorSpy).toHaveBeenCalledWith( + 'Non-interactive install cannot bootstrap GitHub Copilot hooks because they require interactive approval for user-home access. Remove "github" from intent.install.targets or run `intent install` in a terminal.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + for (const path of [ + 'intent.lock', + '.intent', + '.claude', + '.codex', + join('copilot-home', 'hooks', 'hooks.json'), + ]) { + expect(existsSync(join(root, path))).toBe(false) + } + }) + + it('runs install --no-input through sync without prompting', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-no-input-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified'], + install: { method: 'symlink', targets: ['github'] }, + }, + }) + writeInstalledIntentPackage(root, { + name: 'verified', + version: '1.0.0', + skillName: 'core', + description: 'Verified skill', + }) + const discovered = scanForIntents(root, { scope: 'local' }).packages + writeFileSync( + join(root, 'intent.lock'), + serializeIntentLockfile({ + lockfileVersion: 1, + sources: buildCurrentLockfileSources(discovered), + }), + ) + process.chdir(root) + + const exitCode = await main(['install', '--no-input']) + + expect(errorSpy).not.toHaveBeenCalled() + expect(exitCode).toBe(0) + expect( + lstatSync( + join(root, '.github', 'skills', 'npm-verified-core'), + ).isSymbolicLink(), + ).toBe(true) + }) + + it('syncs an existing lock before requiring bootstrap-only intent.skills', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-lock-no-skills-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + install: { method: 'symlink', targets: ['agents'] }, + }, + }) + writeFileSync( + join(root, 'intent.lock'), + serializeIntentLockfile({ lockfileVersion: 1, sources: [] }), + ) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + process.chdir(root) + + expect(await main(['install', '--no-input'])).toBe(0) + + expect(errorSpy).not.toHaveBeenCalled() + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) }) it('lists excludes when none are configured', async () => { @@ -336,134 +1126,38 @@ describe('cli commands', () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-bad-action-')) tempDirs.push(root) writeJson(join(root, 'package.json'), { - name: 'app', - private: true, - }) - process.chdir(root) - - const exitCode = await main(['exclude', 'enable', '@tanstack/router']) - - expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Unknown exclude action: enable. Expected list, add, or remove.', - ) - }) - - it('writes skill loading guidance by default and is idempotent', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-')) - const isolatedGlobalRoot = mkdtempSync( - join(realTmpdir, 'intent-cli-install-empty-global-'), - ) - tempDirs.push(root, isolatedGlobalRoot) - writeInstalledIntentPackage(root, { - name: '@tanstack/query', - version: '5.0.0', - skillName: 'fetching', - description: 'Query data fetching patterns', - }) - - process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot - process.chdir(root) - - const exitCode = await main(['install']) - const agentsPath = join(root, 'AGENTS.md') - const content = readFileSync(agentsPath, 'utf8') - const output = logSpy.mock.calls.flat().join('\n') - - expect(exitCode).toBe(0) - expect(output).toContain('Created AGENTS.md with skill loading guidance.') - expect(content).toContain('## Skill Loading') - expect(content).toContain('npx @tanstack/intent@latest list') - expect(content).toContain('If a listed skill matches the task') - expect(content).toContain('before changing files') - expect(content).toContain('Monorepos:') - expect(content).toContain('Multiple matches:') - expect(content).not.toContain('--global') - expect(content).not.toContain('use: "@tanstack/query#fetching"') - expect(content).not.toContain(root) - expect(output).toContain( - 'Tip: Keep the intent-skills block near the top of AGENTS.md', - ) - - logSpy.mockClear() - - const secondExitCode = await main(['install']) - const secondOutput = logSpy.mock.calls.flat().join('\n') - - expect(secondExitCode).toBe(0) - expect(secondOutput).toContain( - 'No changes to AGENTS.md; skill loading guidance already current.', - ) - expect(readFileSync(agentsPath, 'utf8')).toBe(content) - }) - - it('prints generated skill loading guidance without writing during dry run', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-dry-run-')) - const isolatedGlobalRoot = mkdtempSync( - join(realTmpdir, 'intent-cli-install-dry-run-empty-global-'), - ) - tempDirs.push(root, isolatedGlobalRoot) - writeInstalledIntentPackage(root, { - name: '@tanstack/router', - version: '1.0.0', - skillName: 'routing', - description: 'Router patterns', - }) - - process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot - process.chdir(root) - - const exitCode = await main(['install', '--dry-run']) - const output = logSpy.mock.calls.flat().join('\n') - - expect(exitCode).toBe(0) - expect(output).toContain('Generated skill loading guidance for AGENTS.md.') - expect(output).toContain('npx @tanstack/intent@latest list') - expect(output).toContain( - 'npx @tanstack/intent@latest load #', - ) - expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) - }) - - it('prints package-manager-specific install guidance', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-install-package-runner-'), - ) - tempDirs.push(root) - writeFileSync(join(root, 'pnpm-lock.yaml'), '') - - process.chdir(root) - - const exitCode = await main(['install', '--dry-run']) - const output = logSpy.mock.calls.flat().join('\n') - - expect(exitCode).toBe(0) - expect(output).toContain('pnpm dlx @tanstack/intent@latest list') - expect(output).toContain( - 'pnpm dlx @tanstack/intent@latest load #', - ) - }) - - it('writes skill loading guidance even with no discovered skills', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-empty-')) - const isolatedGlobalRoot = mkdtempSync( - join(realTmpdir, 'intent-cli-install-empty-global-'), - ) - tempDirs.push(root, isolatedGlobalRoot) - - process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + name: 'app', + private: true, + }) process.chdir(root) - const exitCode = await main(['install']) - const output = logSpy.mock.calls.flat().join('\n') + const exitCode = await main(['exclude', 'enable', '@tanstack/router']) - expect(exitCode).toBe(0) - expect(output).toContain('Created AGENTS.md with skill loading guidance.') - expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( - 'npx @tanstack/intent@latest list', + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown exclude action: enable. Expected list, add, or remove.', ) }) + it.each([{ flags: [] }, { flags: ['--dry-run'] }])( + 'fails without writing when interactive install runs outside a TTY ($flags)', + async ({ flags }) => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-nontty-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + process.chdir(root) + + const exitCode = await main(['install', ...flags]) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Interactive installation requires a terminal. Run `intent install` in a TTY, use `intent install --map`, or configure explicit package.json intent.skills and intent.install values before using `intent install --no-input`.', + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }, + ) + it('installs hooks with the hooks install command', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-install-')) tempDirs.push(root) @@ -492,6 +1186,34 @@ describe('cli commands', () => { expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) }) + it('runs the session catalogue hook for a valid agent', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-run-')) + tempDirs.push(root) + process.chdir(root) + + const exitCode = await main(['hooks', 'run', '--agent', 'claude']) + + expect(exitCode).toBe(0) + }) + + it('requires an agent for hooks run', async () => { + const exitCode = await main(['hooks', 'run']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Missing hook agent. Expected copilot, claude, or codex.', + ) + }) + + it('fails cleanly for an invalid hooks run agent', async () => { + const exitCode = await main(['hooks', 'run', '--agent', 'cursor']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown hook agent: cursor. Expected copilot, claude, or codex.', + ) + }) + it('writes install mappings with --map and is idempotent', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-map-')) const isolatedGlobalRoot = mkdtempSync( @@ -506,21 +1228,28 @@ describe('cli commands', () => { }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['install', '--map']) const agentsPath = join(root, 'AGENTS.md') const content = readFileSync(agentsPath, 'utf8') - const output = logSpy.mock.calls.flat().join('\n') + const output = [...logSpy.mock.calls, ...errorSpy.mock.calls] + .flat() + .join('\n') expect(exitCode).toBe(0) expect(output).toContain('Created AGENTS.md with 1 mapping.') + expect(output).toContain( + 'The intent-skills block is a snapshot and does not update when dependencies change. Re-run `intent install --map` to regenerate it.', + ) expect(content).toContain('for: "Query data fetching patterns"') expect(content).toContain('id: "@tanstack/query#fetching"') expect(content).toContain( - 'run: "npx @tanstack/intent@latest load @tanstack/query#fetching"', + `run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching"`, ) expect(content).not.toContain('load:') + expect(content).not.toContain('snapshot') expect(content).not.toContain(root) logSpy.mockClear() @@ -535,6 +1264,32 @@ describe('cli commands', () => { expect(readFileSync(agentsPath, 'utf8')).toBe(content) }) + it('does not print the install mapping snapshot advisory to agents', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-map-agent-')) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-install-map-agent-empty-global-'), + ) + tempDirs.push(root, isolatedGlobalRoot) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + const exitCode = await main(['install', '--map']) + const output = [...logSpy.mock.calls, ...errorSpy.mock.calls] + .flat() + .join('\n') + + expect(exitCode).toBe(0) + expect(output).not.toContain('snapshot') + }) + it('omits unlisted packages from the install --map block', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-allowlist-')) const isolatedGlobalRoot = mkdtempSync( @@ -844,6 +1599,8 @@ describe('cli commands', () => { tempDirs.push(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + writeFileSync(join(root, 'pnpm-lock.yaml'), '') + writeJson(join(pkgDir, 'package.json'), { name: '@tanstack/query', version: '5.0.0', @@ -858,18 +1615,289 @@ describe('cli commands', () => { description: 'Query cache skill', }) + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) const output = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('PACKAGE') + expect(output).toContain('SOURCE') + expect(output).toContain('VERSION') + expect(output).toContain('SKILLS') + expect(stderr).toContain('Notices:') + expect(output).toContain( + `Load: pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching`, + ) + expect(output).toContain( + `Load: pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#query/cache`, + ) + expect(output.match(/Load:/g)).toHaveLength(2) + }) + + it('explains why human-listed skills are available', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-')) + tempDirs.push(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + + writeFileSync(join(root, 'pnpm-lock.yaml'), '') + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['@tanstack/query#fetching', '@tanstack/query#query/cache'], + }, + }) + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query fetching skill', + }) + writeSkillMd(join(pkgDir, 'skills', 'query', 'cache'), { + name: 'query/cache', + description: 'Query cache skill', + }) + + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + const exitCode = await main(['list', '--why']) + const output = logSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching', + 'Allowed by intent.skills["@tanstack/query#fetching"]', + ) + expect(output).toContain( + 'Allowed by intent.skills["@tanstack/query#query/cache"]', ) + expect(output.indexOf('Allowed by')).toBeLessThan(output.indexOf('Load:')) + }) + + it.each(['@tanstack/query#fetching', '@tanstack/query'])( + 'explains skills excluded by %s only under --why', + async (pattern) => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-list-why-excluded-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: [pattern], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list'])).toBe(0) + const defaultOutput = logSpy.mock.calls.flat().join('\n') + expect(defaultOutput).not.toContain('@tanstack/query\n') + expect(defaultOutput).not.toContain('fetching') + logSpy.mockClear() + + expect(await main(['list', '--why'])).toBe(0) + const whyOutput = logSpy.mock.calls.flat().join('\n') + expect(whyOutput).toContain('@tanstack/query\n') + expect(whyOutput).toContain('fetching (excluded)') + expect(whyOutput).toContain( + `Excluded by intent.exclude[${JSON.stringify(pattern)}]`, + ) + expect(whyOutput).not.toContain('Load:') + }, + ) + + it('names a package-level entry that allows a skill', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-package-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list', '--why'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + + expect(output).toContain('Allowed by intent.skills["@tanstack/query"]') + }) + + it.each([ + [undefined, 'Available because intent.skills is not set'], + [['*'], 'Allowed because intent.skills allows all sources'], + ])('explains permit-all configuration %#', async (skills, explanation) => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-mode-')) + tempDirs.push(root) + if (skills) { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills }, + }) + } + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list', '--why'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + + expect(output).toContain(explanation) + }) + + it('adds no output for --why in agent sessions', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-agent-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: ['@tanstack/query#fetching'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + expect(await main(['list'])).toBe(0) + const defaultOutput = logSpy.mock.calls + .map((call: Array) => call[0]) + .join('\n') + logSpy.mockClear() + + expect(await main(['list', '--why'])).toBe(0) + const whyOutput = logSpy.mock.calls + .map((call: Array) => call[0]) + .join('\n') + + expect(whyOutput).toBe(defaultOutput) + }) + + it.each([false, true])( + 'does not reveal policy-concealed skills under --why %#', + async (showHidden) => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-list-why-concealed-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['@tanstack/query'], + exclude: ['@tanstack/router#routing'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/router', + version: '1.0.0', + skillName: 'routing', + description: 'Router navigation patterns', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect( + await main(['list', '--why', ...(showHidden ? ['--show-hidden'] : [])]), + ).toBe(0) + const combinedOutput = [ + ...logSpy.mock.calls.flat(), + ...errorSpy.mock.calls.flat(), + ].join('\n') + + expect(combinedOutput).not.toContain('routing') + expect(combinedOutput).not.toContain( + 'intent.exclude["@tanstack/router#routing"]', + ) + }, + ) + + it('prints compact list guidance for agents', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-agent-')) + tempDirs.push(root) + const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + + writeFileSync(join(root, 'pnpm-lock.yaml'), '') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(pkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query fetching skill', + }) + writeSkillMd(join(pkgDir, 'skills', 'query', 'cache'), { + name: 'query/cache', + description: 'Query cache skill', + }) + + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls + .map((call: Array) => `${String(call[0] ?? '')}\n`) + .join('') + const stderr = errorSpy.mock.calls.flat().join('\n') + const loadHeader = `Load a skill with \`pnpm dlx @tanstack/intent@${intentPackagePin} load \`.` + + expect(exitCode).toBe(0) + expect(output).not.toContain('PACKAGE') + expect(output).not.toContain('SOURCE') + expect(output).not.toContain('VERSION') + expect(output).not.toContain('SKILLS') + expect(stderr).not.toContain('Notices:') + expect(stderr).not.toContain('intent.skills is not set') + expect(output.split(loadHeader)).toHaveLength(2) expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#query/cache', + `1 intent-enabled packages, 2 skills\n\n${loadHeader}`, + ) + expect(output).not.toContain( + `1 intent-enabled packages, 2 skills\n\n\n${loadHeader}`, ) + expect(output).not.toContain('Load:') + expect(output).toContain('fetching') + expect(output).toContain('query/cache') }) it('reveals hidden skill sources for human list output when requested', async () => { @@ -907,6 +1935,37 @@ describe('cli commands', () => { expect(stderr).toContain('Add to opt in') }) + it('explains already-visible hidden sources without revealing more', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-why-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeInstalledIntentPackage(root, { + name: 'get-tsconfig', + version: '4.0.0', + skillName: 'config', + description: 'TypeScript config lookup', + }) + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + expect(await main(['list', '--show-hidden', '--why'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + + expect(output).toContain(' get-tsconfig (1 skill)') + expect(output).toContain(' Hidden because not listed in intent.skills') + expect(output.match(/get-tsconfig/g)).toHaveLength(1) + }) + it('does not reveal hidden skill sources to agent list output', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-hidden-agent-')) tempDirs.push(root) @@ -939,7 +1998,7 @@ describe('cli commands', () => { expect(combined).toContain( 'Hidden skill sources are not revealed in agent sessions. Run this command outside the agent session to review candidates.', ) - expect(combined).toContain( + expect(combined).not.toContain( '1 discovered skill source with 1 skill is hidden', ) expect(combined).not.toContain('get-tsconfig') @@ -988,9 +2047,9 @@ describe('cli commands', () => { }) it.each([ - ['pnpm-lock.yaml', 'pnpm dlx @tanstack/intent@latest'], - ['yarn.lock', 'yarn dlx @tanstack/intent@latest'], - ['bun.lock', 'bunx @tanstack/intent@latest'], + ['pnpm-lock.yaml', `pnpm dlx @tanstack/intent@${intentPackagePin}`], + ['yarn.lock', `yarn dlx @tanstack/intent@${intentPackagePin}`], + ['bun.lock', `bunx @tanstack/intent@${intentPackagePin}`], ])( 'prints %s load commands for human list output', async (lockfile, runner) => { @@ -1006,6 +2065,7 @@ describe('cli commands', () => { description: 'Query fetching skill', }) + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) @@ -1069,6 +2129,7 @@ describe('cli commands', () => { }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) @@ -1128,6 +2189,7 @@ describe('cli commands', () => { }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list', '--no-notices']) @@ -1306,6 +2368,7 @@ describe('cli commands', () => { }) process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list', '--global']) @@ -1314,7 +2377,7 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Global fetching skill') expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching --global', + `Load: npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching --global`, ) expect(output).not.toContain(globalPkgDir) }) @@ -1930,16 +2993,23 @@ describe('cli commands', () => { description: 'Query v5 skill', }) + process.env.INTENT_AUDIENCE = 'agent' process.chdir(root) const exitCode = await main(['list']) - const output = logSpy.mock.calls.flat().join('\n') + const output = logSpy.mock.calls + .map((call: Array) => `${String(call[0] ?? '')}\n`) + .join('') + const loadHeader = `Load a skill with \`npx @tanstack/intent@${intentPackagePin} load \`.` expect(exitCode).toBe(0) expect(output).toContain('Version conflicts:') expect(output).toContain('@tanstack/query -> using 5.0.0') expect(output).toContain(`chosen: ${queryV5Dir}`) expect(output).toContain(`also found: 4.0.0 at ${queryV4Dir}`) + expect(output).toContain( + `also found: 4.0.0 at ${queryV4Dir}\n\n${loadHeader}`, + ) }) it('validates a well-formed skills directory', async () => { diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts new file mode 100644 index 00000000..3a09e2df --- /dev/null +++ b/packages/intent/tests/consumer-install.test.ts @@ -0,0 +1,1144 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runInteractiveInstall } from '../src/commands/install/command.js' +import { + detectInstallTargets, + readIntentConsumerConfig, +} from '../src/commands/install/config.js' +import { runConsumerInstall } from '../src/commands/install/consumer.js' +import { + createClackInstallerPrompter, + groupSkillOptions, +} from '../src/commands/install/prompts.js' +import { runSyncCommand } from '../src/commands/sync/command.js' +import { readInstallState } from '../src/commands/sync/state.js' +import { readIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { scanForIntents } from '../src/discovery/scanner.js' +import type * as ClackPrompts from '@clack/prompts' +import type { InstallerPrompter } from '../src/commands/install/consumer.js' + +const clackPromptMocks = vi.hoisted(() => ({ + intro: vi.fn(), + multiselect: vi.fn<() => Promise>(), + select: + vi.fn< + (options: { options: Array<{ value: string }> }) => Promise + >(), +})) + +vi.mock('@clack/prompts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + intro: clackPromptMocks.intro, + multiselect: clackPromptMocks.multiselect, + select: clackPromptMocks.select, + } +}) + +const roots: Array = [] + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2), 'utf8') +} + +function createProject(): string { + const root = realpathSync( + mkdtempSync(join(tmpdir(), 'intent-consumer-install-')), + ) + roots.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + devDependencies: { '@tanstack/intent': '0.4.0' }, + }) + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + writeJson(join(packageRoot, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + const skillRoot = join(packageRoot, 'skills', 'fetching') + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + '---\nname: fetching\ndescription: Query fetching patterns\n---\n', + 'utf8', + ) + return root +} + +function addSkillPackage( + root: string, + name: string, + skills: Array, +): void { + const packageRoot = join(root, 'node_modules', ...name.split('/')) + writeJson(join(packageRoot, 'package.json'), { + name, + version: '1.0.0', + intent: { version: 1, repo: `test/${name}`, docs: 'docs/' }, + }) + for (const skill of skills) { + const skillRoot = join(packageRoot, 'skills', skill) + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + `---\nname: ${skill}\ndescription: ${skill} guidance\n---\n`, + 'utf8', + ) + } +} + +function createPrompts( + overrides: Partial = {}, +): InstallerPrompter { + return { + advisory: () => {}, + complete: () => {}, + selectMethod: () => Promise.resolve('symlink'), + selectTargets: () => Promise.resolve(['agents']), + confirmSymlink: () => Promise.resolve(true), + confirmUserScopeHooks: () => Promise.resolve(true), + selectSkills: () => Promise.resolve({ mode: 'all-found' }), + confirmInstall: () => Promise.resolve('install'), + ...overrides, + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('consumer install', () => { + it('detects configured agent targets from project-owned signals', () => { + const root = createProject() + mkdirSync(join(root, '.claude')) + writeFileSync(join(root, '.cursorrules'), '', 'utf8') + + expect(detectInstallTargets(root)).toEqual(['cursor', 'claude']) + }) + + it('detects no agent targets in a bare project', () => { + expect(detectInstallTargets(createProject())).toEqual([]) + }) + + it('does not detect GitHub Copilot from the .github directory alone', () => { + const root = createProject() + mkdirSync(join(root, '.github')) + + expect(detectInstallTargets(root)).toEqual([]) + }) + + it('preselects detected targets while keeping every target toggleable', async () => { + const root = createProject() + mkdirSync(join(root, '.claude')) + writeFileSync(join(root, '.cursorrules'), '', 'utf8') + clackPromptMocks.multiselect.mockResolvedValueOnce([]) + + await createClackInstallerPrompter().selectTargets( + 'symlink', + detectInstallTargets(root), + ) + + expect(clackPromptMocks.multiselect).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: ['cursor', 'claude'], + options: expect.arrayContaining([ + expect.objectContaining({ value: 'agents' }), + expect.objectContaining({ value: 'github' }), + expect.objectContaining({ value: 'vscode' }), + expect.objectContaining({ value: 'cursor' }), + expect.objectContaining({ value: 'codex' }), + expect.objectContaining({ value: 'claude' }), + ]), + }), + ) + }) + + it('groups selectable skills by package', () => { + const root = createProject() + const discovered = scanForIntents(root, { scope: 'local' }).packages + + expect(groupSkillOptions(discovered)).toEqual({ + '@tanstack/query': [ + { + value: '@tanstack/query#fetching', + label: 'fetching', + hint: 'Query fetching patterns', + }, + ], + }) + }) + + it('offers only implemented interactive install methods', async () => { + clackPromptMocks.select.mockResolvedValueOnce('symlink') + + await createClackInstallerPrompter().selectMethod() + + expect(clackPromptMocks.select).toHaveBeenCalledOnce() + const [{ options }] = clackPromptMocks.select.mock.calls[0]! + expect(options.map((option) => option.value)).toEqual(['symlink', 'hooks']) + }) + + it('selects the method before requesting applicable targets', async () => { + const root = createProject() + const calls: Array = [] + const prompts = createPrompts({ + selectMethod: () => { + calls.push('method') + return Promise.resolve('symlink') + }, + selectTargets: (method) => { + calls.push(`targets:${method}`) + return Promise.resolve(null) + }, + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect(calls).toEqual(['method', 'targets:symlink']) + }) + + it('runs the full interview for an unconfigured project', async () => { + const root = createProject() + const selectMethod = vi.fn(() => Promise.resolve('symlink' as const)) + const selectTargets = vi.fn(() => + Promise.resolve>(['agents']), + ) + const selectSkills = vi.fn(() => + Promise.resolve({ mode: 'all-found' as const }), + ) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ selectMethod, selectTargets, selectSkills }), + root, + }) + + expect(selectMethod).toHaveBeenCalledOnce() + expect(selectTargets).toHaveBeenCalledOnce() + expect(selectSkills).toHaveBeenCalledOnce() + }) + + it('reports an already-configured project as up to date without interviewing', async () => { + const root = createProject() + const discovered = scanForIntents(root, { scope: 'local' }).packages + await runConsumerInstall({ discovered, prompts: createPrompts(), root }) + const complete = vi.fn() + const selectMethod = vi.fn(() => + Promise.reject(new Error('method must not run')), + ) + const selectTargets = vi.fn(() => + Promise.reject(new Error('targets must not run')), + ) + const selectSkills = vi.fn(() => + Promise.reject(new Error('skills must not run')), + ) + const prompts = createPrompts({ + complete, + selectMethod, + selectTargets, + selectSkills, + }) + + await runConsumerInstall({ discovered, prompts, root }) + + expect(complete).toHaveBeenCalledWith('Project is up to date.') + expect(selectMethod).not.toHaveBeenCalled() + expect(selectTargets).not.toHaveBeenCalled() + expect(selectSkills).not.toHaveBeenCalled() + }) + + it('reports a new skill and enters review without re-interviewing delivery', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const skillRoot = join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'mutations', + ) + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + '---\nname: mutations\ndescription: Query mutation patterns\n---\n', + 'utf8', + ) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const selectSkills = vi.fn(() => + Promise.resolve({ mode: 'all-found' as const }), + ) + const confirmInstall = vi.fn(() => Promise.resolve('install' as const)) + const prompts = createPrompts({ + selectMethod: () => Promise.reject(new Error('method must not run')), + selectTargets: () => Promise.reject(new Error('targets must not run')), + selectSkills, + confirmInstall, + }) + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect(log.mock.calls.flat().join('\n')).toContain( + 'Install changes: 0 new dependencies, 1 new skill, 0 changed, 0 removed.', + ) + } finally { + log.mockRestore() + } + expect(selectSkills).toHaveBeenCalledOnce() + expect(confirmInstall).toHaveBeenCalledOnce() + }) + + it('installs confirmed skills with policy, lock state, and managed links', async () => { + const root = createProject() + const advisory = vi.fn() + const prompts = createPrompts({ advisory }) + + await runInteractiveInstall({ + cwd: root, + prompts, + }) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ + skills: ['@tanstack/query'], + exclude: [], + install: { method: 'symlink', targets: ['agents'] }, + }) + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, + ).toEqual({ prepare: 'intent sync' }) + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + kind: 'npm', + id: '@tanstack/query', + skills: [ + { + path: 'skills/fetching', + contentHash: expect.stringMatching(/^sha256-[a-f0-9]{64}$/), + }, + ], + }, + ], + }, + }) + const link = join(root, '.agents', 'skills', 'npm-tanstack-query-fetching') + expect(lstatSync(link).isSymbolicLink()).toBe(true) + expect(readInstallState(root)).toMatchObject({ + status: 'found', + state: { + entries: [{ path: '.agents/skills/npm-tanstack-query-fetching' }], + }, + }) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect(advisory).not.toHaveBeenCalled() + }) + + it('installs hooks with policy and lock state without links or prepare', async () => { + const root = createProject() + const prompts = createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['claude', 'codex']), + confirmSymlink: () => + Promise.reject(new Error('hooks must not request symlink consent')), + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ + skills: ['@tanstack/query'], + exclude: [], + install: { method: 'hooks', targets: ['claude', 'codex'] }, + }) + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, + ).toBeUndefined() + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + id: '@tanstack/query', + skills: [{ path: 'skills/fetching' }], + }, + ], + }, + }) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(true) + expect(existsSync(join(root, '.agents'))).toBe(false) + expect(readInstallState(root)).toEqual({ status: 'missing' }) + }) + + it('installs selected GitHub hooks at user scope after confirmation', async () => { + const root = createProject() + const copilotHome = join(root, 'copilot-home') + const previousCopilotHome = process.env.COPILOT_HOME + const confirmUserScopeHooks = vi.fn(() => Promise.resolve(true)) + let output = '' + const prompts = createPrompts({ + complete(message) { + output = message + }, + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github']), + confirmUserScopeHooks, + }) + process.env.COPILOT_HOME = copilotHome + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + } finally { + if (previousCopilotHome === undefined) { + delete process.env.COPILOT_HOME + } else { + process.env.COPILOT_HOME = previousCopilotHome + } + } + + expect(confirmUserScopeHooks).toHaveBeenCalledOnce() + expect(existsSync(join(copilotHome, 'hooks', 'hooks.json'))).toBe(true) + expect(output).toContain('Installed hook agents: copilot.') + }) + + it('skips declined Copilot hooks while installing project hooks', async () => { + const root = createProject() + const copilotHome = join(root, 'copilot-home') + const previousCopilotHome = process.env.COPILOT_HOME + let output = '' + const prompts = createPrompts({ + complete(message) { + output = message + }, + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github', 'claude', 'codex']), + confirmUserScopeHooks: () => Promise.resolve(false), + }) + process.env.COPILOT_HOME = copilotHome + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + } finally { + if (previousCopilotHome === undefined) { + delete process.env.COPILOT_HOME + } else { + process.env.COPILOT_HOME = previousCopilotHome + } + } + + expect(existsSync(join(copilotHome, 'hooks', 'hooks.json'))).toBe(false) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(true) + expect(output).toContain('Installed hook agents: claude, codex.') + expect(output).toContain( + 'Copilot was skipped because home-directory access was declined.', + ) + }) + + it('writes nothing when installation is cancelled', async () => { + const root = createProject() + const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') + const prompts = createPrompts({ + confirmInstall: () => Promise.resolve(null), + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.agents'))).toBe(false) + }) + + it('locks and links selected skills without excluding unchecked siblings', async () => { + const root = createProject() + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const sibling = join(packageRoot, 'skills', 'mutations') + mkdirSync(sibling, { recursive: true }) + writeFileSync( + join(sibling, 'SKILL.md'), + '---\nname: mutations\ndescription: Query mutation patterns\n---\n', + 'utf8', + ) + const prompts = createPrompts({ + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['@tanstack/query#fetching'], + }), + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + const config = readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ) + expect(config.skills).toEqual(['@tanstack/query#fetching']) + expect(config.exclude).toEqual([]) + const lock = readIntentLockfile(join(root, 'intent.lock')) + expect( + lock.status === 'found' ? lock.lockfile.sources[0]?.skills : [], + ).toEqual([ + { + path: 'skills/fetching', + contentHash: expect.stringMatching(/^sha256-[a-f0-9]{64}$/), + }, + ]) + expect( + existsSync( + join(root, '.agents', 'skills', 'npm-tanstack-query-mutations'), + ), + ).toBe(false) + }) + + it('prints the complete plan without writing during dry run', async () => { + const root = createProject() + const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + const prompts = createPrompts() + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + dryRun: true, + prompts, + root, + }) + output = log.mock.calls.flat().join('\n') + } finally { + log.mockRestore() + } + + expect(output).toContain( + 'Would install 1 skill to Shared .agents directory using symlink.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.agents'))).toBe(false) + }) + + it('prints the hooks plan without writing or requesting home access', async () => { + const root = createProject() + const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + const prompts = createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github']), + confirmUserScopeHooks: () => + Promise.reject(new Error('dry run must not request home access')), + }) + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + dryRun: true, + prompts, + root, + }) + output = log.mock.calls.flat().join('\n') + } finally { + log.mockRestore() + } + + expect(output).toContain( + 'Would install 1 skill to GitHub Copilot using hooks.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.copilot'))).toBe(false) + }) + + it('installs without Intent as a project development dependency', async () => { + const root = createProject() + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + const advisory = vi.fn() + const prompts = createPrompts({ advisory }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ + skills: ['@tanstack/query'], + exclude: [], + install: { method: 'symlink', targets: ['agents'] }, + }) + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, + ).toBeUndefined() + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + id: '@tanstack/query', + skills: [{ path: 'skills/fetching' }], + }, + ], + }, + }) + expect(advisory).toHaveBeenCalledWith( + 'Skills will not re-sync automatically because the prepare script was not wired. intent.lock records the accepted skill baseline, but nothing will check it automatically. Add @tanstack/intent as a devDependency to enable both.', + ) + }) + + it('stops without skill selection when discovery is empty', async () => { + const root = createProject() + rmSync(join(root, 'node_modules', '@tanstack', 'query'), { + recursive: true, + force: true, + }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + const prompts = createPrompts({ + complete(message) { + output = message + }, + selectSkills: () => + Promise.reject(new Error('skill selection must not run')), + }) + + try { + await runConsumerInstall({ discovered: [], prompts, root }) + } finally { + log.mockRestore() + } + + expect(output).toContain('No intent-enabled skills found.') + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + }) + + it('does not write configuration when a delivery target conflicts', async () => { + const root = createProject() + const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') + const target = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + mkdirSync(target, { recursive: true }) + + await expect( + runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }), + ).rejects.toThrow('Install target conflicts') + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(lstatSync(target).isDirectory()).toBe(true) + }) + + it('restarts choices when final confirmation goes back', async () => { + const root = createProject() + const targets = [['agents'], ['cursor']] as const + let pass = 0 + const prompts = createPrompts({ + selectTargets: () => + Promise.resolve([...targets[pass]!] as Array<'agents' | 'cursor'>), + confirmInstall: () => { + pass += 1 + return Promise.resolve(pass === 1 ? 'back' : 'install') + }, + }) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + + expect( + readIntentConsumerConfig(readFileSync(join(root, 'package.json'), 'utf8')) + .install, + ).toEqual({ method: 'symlink', targets: ['cursor'] }) + expect( + existsSync( + join(root, '.cursor', 'skills', 'npm-tanstack-query-fetching'), + ), + ).toBe(true) + expect(existsSync(join(root, '.agents'))).toBe(false) + }) + + it('reviews and installs selected skills from new dependencies', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, '@tanstack/new-package', ['first', 'second']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['@tanstack/new-package#first'], + }), + }, + }, + ) + + const config = readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ) + expect(config.skills).toEqual([ + '@tanstack/new-package#first', + '@tanstack/query', + ]) + expect(config.exclude).toEqual([]) + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { id: '@tanstack/new-package', skills: [{ path: 'skills/first' }] }, + { id: '@tanstack/query' }, + ], + }, + }) + expect( + existsSync( + join(root, '.agents', 'skills', 'npm-tanstack-new-package-first'), + ), + ).toBe(true) + expect( + existsSync( + join(root, '.agents', 'skills', 'npm-tanstack-new-package-second'), + ), + ).toBe(false) + }) + + it('adds accepted skills beside an existing skill-level entry', async () => { + const root = createProject() + addSkillPackage(root, 'demo-pkg', ['alpha']) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.intent.skills = ['demo-pkg#alpha'] + packageJson.intent.exclude = ['@tanstack/query'] + writeJson(packageJsonPath, packageJson) + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['demo-pkg#beta'], + }), + }, + }, + ) + + expect( + readIntentConsumerConfig(readFileSync(packageJsonPath, 'utf8')).skills, + ).toEqual(['demo-pkg#alpha', 'demo-pkg#beta']) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-beta')), + ).toBe(true) + }) + + it('keeps an existing package-level entry when accepting a new skill', async () => { + const root = createProject() + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.intent.skills = ['demo-pkg'] + packageJson.intent.exclude = ['@tanstack/query'] + writeJson(packageJsonPath, packageJson) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['demo-pkg#beta'], + }), + }, + }, + ) + + expect( + readIntentConsumerConfig(readFileSync(packageJsonPath, 'utf8')).skills, + ).toEqual(['demo-pkg']) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-beta')), + ).toBe(true) + }) + + it('writes a package-level entry when all skills in a new package are accepted', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ + mode: 'individual', + enabled: ['demo-pkg#alpha', 'demo-pkg#beta'], + }), + }, + }, + ) + + const config = readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ) + expect(config.skills).toContain('demo-pkg') + expect(config.skills).not.toContain('demo-pkg#alpha') + expect(config.skills).not.toContain('demo-pkg#beta') + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-beta')), + ).toBe(true) + }) + + it('excludes new dependencies without changing the lock', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'declined-package', ['declined']) + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('exclude'), + selectSkills: () => Promise.resolve(null), + }, + }, + ) + + expect( + readIntentConsumerConfig(readFileSync(join(root, 'package.json'), 'utf8')) + .exclude, + ).toEqual(['declined-package']) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('excludes only new skills from a partially allowed package', async () => { + const root = createProject() + addSkillPackage(root, 'demo-pkg', ['alpha']) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.intent.skills = ['@tanstack/query', 'demo-pkg#alpha'] + writeJson(packageJsonPath, packageJson) + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('exclude'), + selectSkills: () => Promise.resolve(null), + }, + }, + ) + + const config = readIntentConsumerConfig( + readFileSync(packageJsonPath, 'utf8'), + ) + expect(config.exclude).toContain('demo-pkg#beta') + expect(config.exclude).not.toContain('demo-pkg') + + await runSyncCommand({ cwd: root }, { review: 'reminder' }) + + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + }) + + it('fails non-interactive sync for a pending dependency without accepting it', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'pending-package', ['pending']) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await expect( + runSyncCommand({ cwd: root }, { review: 'fail' }), + ).rejects.toThrow( + 'Intent sync requires review before automation can continue.', + ) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('fails non-interactive sync for a new skill in an enabled package without accepting it', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const skillRoot = join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'mutation', + ) + mkdirSync(skillRoot, { recursive: true }) + writeFileSync( + join(skillRoot, 'SKILL.md'), + '---\nname: mutation\ndescription: Mutation guidance\n---\n', + 'utf8', + ) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await expect( + runSyncCommand({ cwd: root }, { review: 'fail' }), + ).rejects.toThrow( + 'Intent sync requires review before automation can continue.', + ) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('fails non-interactive sync for changed content without accepting it', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + writeFileSync( + join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'SKILL.md', + ), + '---\nname: fetching\ndescription: Changed guidance\n---\n', + 'utf8', + ) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await expect( + runSyncCommand({ cwd: root }, { review: 'fail' }), + ).rejects.toThrow( + 'Intent sync requires review before automation can continue.', + ) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('leaves new dependencies pending when review is deferred', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'later-package', ['later']) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('later'), + selectSkills: () => Promise.resolve(null), + }, + }, + ) + + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('keeps prepare sync prompt-free and reminder-only', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + addSkillPackage(root, 'prepare-package', ['prepare-skill']) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const previousLifecycle = process.env.npm_lifecycle_event + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + process.env.npm_lifecycle_event = 'prepare' + + try { + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => + Promise.reject(new Error('prepare must not prompt')), + selectSkills: () => + Promise.reject(new Error('prepare must not select skills')), + }, + }, + ) + output = log.mock.calls.flat().join('\n') + } finally { + log.mockRestore() + if (previousLifecycle === undefined) { + delete process.env.npm_lifecycle_event + } else { + process.env.npm_lifecycle_event = previousLifecycle + } + } + + expect(output).toContain('New dependencies with skills found:') + expect(output).toContain('prepare-package 1 skill') + expect(output).toContain('Run `intent install` to review and install them') + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) +}) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 0989b1fb..3a74fd47 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -15,6 +15,8 @@ import { loadIntentSkill, resolveIntentSkill, } from '../src/core/index.js' +import { computeSkillContentHash } from '../src/core/lockfile/hash.js' +import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' const realTmpdir = realpathSync(tmpdir()) @@ -469,6 +471,105 @@ describe('loadIntentSkill', () => { }) }) + it('loads only exact skill content accepted by intent.lock', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const skillDir = join(packageRoot, 'skills', 'fetching') + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [ + { + kind: 'npm', + id: '@tanstack/query', + skills: [ + { + path: 'skills/fetching', + contentHash: computeSkillContentHash({ packageRoot, skillDir }), + }, + ], + }, + ], + }) + + expect( + loadIntentSkill('@tanstack/query#fetching', { cwd: root }).skillName, + ).toBe('fetching') + + writeFileSync(join(skillDir, 'SKILL.md'), 'changed content', 'utf8') + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": installed content does not match intent.lock.', + ) + expect(() => + resolveIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": installed content does not match intent.lock.', + ) + }) + + it('refuses a skill missing from an existing intent.lock', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [], + }) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": skill is not accepted in intent.lock.', + ) + expect(() => + resolveIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": skill is not accepted in intent.lock.', + ) + }) + + it('does not accept a lock entry for the wrong source kind', () => { + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const skillDir = join(packageRoot, 'skills', 'fetching') + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [ + { + kind: 'workspace', + id: '@tanstack/query', + skills: [ + { + path: 'skills/fetching', + contentHash: computeSkillContentHash({ packageRoot, skillDir }), + }, + ], + }, + ], + }) + + expect(() => + loadIntentSkill('@tanstack/query#fetching', { cwd: root }), + ).toThrow( + 'Cannot load skill use "@tanstack/query#fetching": skill is not accepted in intent.lock.', + ) + }) + it('does not change process cwd when loading from an explicit cwd', () => { writeInstalledIntentPackage(root, { name: '@tanstack/query', @@ -610,6 +711,53 @@ describe('loadIntentSkill', () => { ) }) + it('loads an exact workspace skill accepted by intent.lock', () => { + const appDir = join(root, 'packages', 'app') + const routerDir = join(root, 'packages', 'router-core') + const skillDir = join(routerDir, 'skills', 'router-core', 'auth-and-guards') + writeJson(join(root, 'package.json'), { + name: 'test-monorepo', + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(appDir, 'package.json'), { name: '@test/app' }) + writeJson(join(routerDir, 'package.json'), { + name: '@tanstack/router-core', + version: '1.0.0', + intent: { version: 1, repo: 'TanStack/router', docs: 'docs/' }, + }) + writeSkillMd({ + dir: skillDir, + frontmatter: { + name: 'router-core/auth-and-guards', + description: 'Router auth and guards', + }, + }) + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [ + { + kind: 'workspace', + id: '@tanstack/router-core', + skills: [ + { + path: 'skills/router-core/auth-and-guards', + contentHash: computeSkillContentHash({ + packageRoot: routerDir, + skillDir, + }), + }, + ], + }, + ], + }) + + expect( + loadIntentSkill('@tanstack/router-core#auth-and-guards', { cwd: appDir }) + .skillName, + ).toBe('router-core/auth-and-guards') + }) + it('loads a package-prefixed workspace skill by short name', () => { const appDir = join(root, 'packages', 'app') const routerDir = join(root, 'packages', 'router-core') @@ -930,6 +1078,71 @@ describe('loadIntentSkill — kind-mismatch late gate', () => { ) }) + it('refuses a skill granted only to the workspace kind when the npm copy resolves', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: ['@tanstack/query#alpha', 'workspace:@tanstack/query#fetching'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'alpha', + description: 'Alpha', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + let thrown: unknown + try { + thrown = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + } catch (err) { + thrown = err + } + + expect(thrown).toBeInstanceOf(IntentCoreError) + expect((thrown as IntentCoreError).code).toBe('skill-not-listed') + }) + + it('refuses the same skill identically via the full-scan fallback', () => { + writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: ['@tanstack/query#alpha', 'workspace:@tanstack/query#fetching'], + }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'alpha', + description: 'Alpha', + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + let thrown: unknown + try { + thrown = loadIntentSkill('@tanstack/query#fetching', { cwd: root }) + } catch (err) { + thrown = err + } + + expect(thrown).toBeInstanceOf(IntentCoreError) + expect((thrown as IntentCoreError).code).toBe('skill-not-listed') + }) + it('refuses an npm-installed package listed only as workspace:, via the full-scan fallback', () => { writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') writeJson(join(root, 'package.json'), { diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts new file mode 100644 index 00000000..326dccbb --- /dev/null +++ b/packages/intent/tests/hash.test.ts @@ -0,0 +1,154 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { computeSkillContentHash } from '../src/core/lockfile/hash.js' + +const roots: Array = [] + +function skillRoot(): { root: string; skill: string } { + const root = mkdtempSync(join(tmpdir(), 'intent-hash-')) + const skill = join(root, 'skills', 'fetching') + mkdirSync(skill, { recursive: true }) + writeFileSync(join(skill, 'SKILL.md'), 'Fetch\r\n') + roots.push(root) + return { root, skill } +} + +afterEach(() => { + roots + .splice(0) + .forEach((path) => rmSync(path, { recursive: true, force: true })) +}) + +describe('computeSkillContentHash', () => { + it('pins the lockfile content digest framing', () => { + const { root, skill } = skillRoot() + writeFileSync( + join(skill, 'SKILL.md'), + Buffer.from('---\nname: pinned\ndescription: Pinned hash fixture\n---\n'), + ) + mkdirSync(join(skill, 'references')) + writeFileSync(join(skill, 'references', 'zeta.md'), Buffer.from('Zeta\n')) + writeFileSync( + join(skill, 'references', 'alpha.md'), + Buffer.from('Alpha\r\n'), + ) + + // Changing this value invalidates every existing intent.lock, so digest framing changes must be deliberate. + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toBe( + 'sha256-985f0fe3329f5eb4cbf3202c9d34da0c53d404292423a15a25d914b7fadc6ce7', + ) + }) + + it('normalizes text line endings', () => { + const { root, skill } = skillRoot() + const baseline = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + writeFileSync(join(skill, 'SKILL.md'), 'Fetch\n') + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toBe(baseline) + writeFileSync(join(skill, 'SKILL.md'), 'Fetch\r') + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toBe(baseline) + }) + + it.each(['references', 'assets', 'scripts'])( + 'includes files under %s', + (directory) => { + const { root, skill } = skillRoot() + mkdirSync(join(skill, directory)) + writeFileSync(join(skill, directory, 'resource.txt'), 'One') + const baseline = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + + writeFileSync(join(skill, directory, 'resource.txt'), 'Two') + + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).not.toBe(baseline) + }, + ) + + it('ignores unrelated sibling files', () => { + const { root, skill } = skillRoot() + const baseline = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + writeFileSync(join(skill, 'notes.md'), 'Not a supported resource') + + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toBe(baseline) + }) + + it('preserves binary bytes', () => { + const { root, skill } = skillRoot() + writeFileSync(join(skill, 'SKILL.md'), Buffer.from([0xff, 13, 10])) + const binary = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + writeFileSync(join(skill, 'SKILL.md'), Buffer.from([0xff, 10])) + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).not.toBe(binary) + }) + + it('rejects an oversized skill file', () => { + const { root, skill } = skillRoot() + writeFileSync(join(skill, 'SKILL.md'), Buffer.alloc(4 * 1024 * 1024 + 1)) + + expect(() => + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toThrow('Hash file size limit exceeded') + }) + + it('follows in-bound links and rejects dangling or escaping links when links are supported', () => { + const { root, skill } = skillRoot() + const target = join(root, 'shared.md') + writeFileSync(target, 'Shared') + mkdirSync(join(skill, 'references')) + try { + symlinkSync(target, join(skill, 'references', 'linked.md')) + } catch { + return + } + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toMatch(/^sha256-/) + symlinkSync( + join(root, 'missing.md'), + join(skill, 'references', 'dangling.md'), + ) + expect(() => + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toThrow() + rmSync(join(skill, 'references', 'dangling.md')) + const outside = mkdtempSync(join(tmpdir(), 'intent-hash-outside-')) + roots.push(outside) + writeFileSync(join(outside, 'outside.md'), 'Outside') + symlinkSync( + join(outside, 'outside.md'), + join(skill, 'references', 'outside.md'), + ) + expect(() => + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toThrow() + }) +}) diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 82e89853..e95e5196 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -7,17 +7,24 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { spawnSync } from 'node:child_process' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { HOOK_AGENT_ADAPTERS } from '../src/hooks/adapters.js' import { - buildHookRunnerScript, formatHookInstallResult, runInstallHooks, } from '../src/hooks/install.js' +import { packageVersionToPin } from '../src/shared/command-runner.js' const tempDirs: Array = [] +const packageJson = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), + 'utf8', + ), +) as { version: string } +const intentPackagePin = packageVersionToPin(packageJson.version) afterEach(() => { for (const dir of tempDirs.splice(0)) { @@ -36,6 +43,14 @@ function readJson(filePath: string): Record { } describe('hook installer', () => { + it('pins stable versions to major.minor', () => { + expect(packageVersionToPin('0.4.2')).toBe('0.4') + }) + + it('pins prerelease versions exactly', () => { + expect(packageVersionToPin('0.4.0-next.1')).toBe('0.4.0-next.1') + }) + it('declares supported scopes in the adapter registry', () => { expect(HOOK_AGENT_ADAPTERS.claude.supportedScopes.has('project')).toBe(true) expect(HOOK_AGENT_ADAPTERS.codex.supportedScopes.has('project')).toBe(true) @@ -45,8 +60,12 @@ describe('hook installer', () => { expect(HOOK_AGENT_ADAPTERS.copilot.supportedScopes.has('user')).toBe(true) }) - it('installs project-scoped Claude and Codex hooks and skips Copilot', () => { + it('installs project-scoped Claude and Codex catalogues without edit gates', () => { const root = tempRoot('intent-hooks-project-') + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ packageManager: 'pnpm@10.0.0' }), + ) const results = runInstallHooks({ root, scope: 'project' }) @@ -74,39 +93,25 @@ describe('hook installer', () => { 'startup|resume|clear|compact', ) expect(claudeConfig.hooks.SessionStart[0].hooks[0]).toMatchObject({ - command: 'node', - args: ['${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs'], - type: 'command', - }) - expect(claudeConfig.hooks.PreToolUse).toHaveLength(1) - expect(claudeConfig.hooks.PreToolUse[0].matcher).toBe( - 'Bash|Write|Edit|MultiEdit|NotebookEdit', - ) - expect(claudeConfig.hooks.PreToolUse[0].hooks[0]).toMatchObject({ - command: 'node', - args: ['${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs'], + command: `pnpm dlx @tanstack/intent@${intentPackagePin} hooks run --agent claude`, type: 'command', }) + expect(claudeConfig.hooks.PreToolUse).toEqual([]) const codexConfig = readJson(join(root, '.codex', 'hooks.json')) expect(codexConfig.hooks.SessionStart[0].matcher).toBe( 'startup|resume|clear|compact', ) - expect(codexConfig.hooks.SessionStart[0].hooks[0].command).toContain( - '.intent/hooks/intent-codex-gate.mjs', - ) - expect(codexConfig.hooks.PreToolUse[0].matcher).toBe( - 'Bash|apply_patch|Edit|Write', - ) - expect(codexConfig.hooks.PreToolUse[0].hooks[0].command).toContain( - '.intent/hooks/intent-codex-gate.mjs', + expect(codexConfig.hooks.SessionStart[0].hooks[0].command).toBe( + `pnpm dlx @tanstack/intent@${intentPackagePin} hooks run --agent codex`, ) + expect(codexConfig.hooks.PreToolUse).toEqual([]) expect( - existsSync(join(root, '.intent', 'hooks', 'intent-claude-gate.mjs')), - ).toBe(true) + existsSync(join(root, '.intent', 'hooks', 'intent-claude-catalog.mjs')), + ).toBe(false) expect( - existsSync(join(root, '.intent', 'hooks', 'intent-codex-gate.mjs')), - ).toBe(true) + existsSync(join(root, '.intent', 'hooks', 'intent-codex-catalog.mjs')), + ).toBe(false) }) it('installs user-scoped Copilot hooks into the selected home', () => { @@ -125,12 +130,12 @@ describe('hook installer', () => { expect(result).toMatchObject({ agent: 'copilot', status: 'created' }) const config = readJson(join(copilotHome, 'hooks', 'hooks.json')) const sessionCommand = config.hooks.SessionStart[0].command as string - const command = config.hooks.PreToolUse[0].command as string - expect(sessionCommand).toContain(join(homeDir, '.tanstack')) - expect(sessionCommand).toContain('intent-copilot-gate.mjs') - expect(command).toContain(join(homeDir, '.tanstack')) - expect(command).toContain('intent-copilot-gate.mjs') + expect(sessionCommand).toBe( + `npx @tanstack/intent@${intentPackagePin} hooks run --agent copilot`, + ) + expect(sessionCommand).toContain('@tanstack/intent') + expect(config.hooks.PreToolUse).toEqual([]) expect( existsSync( join( @@ -138,16 +143,24 @@ describe('hook installer', () => { '.tanstack', 'intent', 'hooks', - 'intent-copilot-gate.mjs', + 'intent-copilot-catalog.mjs', ), ), - ).toBe(true) + ).toBe(false) }) - it('updates only the Intent hook group on repeated installs', () => { + it('updates only the Intent hook group and is unchanged on repeated installs', () => { const root = tempRoot('intent-hooks-update-') const settingsPath = join(root, '.claude', 'settings.json') + const legacyScriptPath = join( + root, + '.intent', + 'hooks', + 'intent-claude-gate.mjs', + ) mkdirSync(join(root, '.claude'), { recursive: true }) + mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) + writeFileSync(legacyScriptPath, 'legacy gate') writeFileSync( settingsPath, JSON.stringify( @@ -180,8 +193,9 @@ describe('hook installer', () => { const config = readJson(settingsPath) expect(config.hooks.SessionStart).toHaveLength(1) - expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse).toHaveLength(1) expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('echo keep') + expect(existsSync(legacyScriptPath)).toBe(true) expect(second[0]).toMatchObject({ status: 'unchanged' }) }) @@ -217,13 +231,10 @@ describe('hook installer', () => { const config = readJson(settingsPath) expect(config.hooks.SessionStart).toHaveLength(1) - expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse).toHaveLength(1) expect(config.hooks.PreToolUse[0].hooks).toEqual([ { type: 'command', command: 'echo keep' }, ]) - expect(config.hooks.PreToolUse[1].hooks[0].args[0]).toContain( - 'intent-claude-gate.mjs', - ) }) it('replaces direct Copilot Intent hook entries on reinstall', () => { @@ -258,11 +269,7 @@ describe('hook installer', () => { const config = readJson(hooksPath) expect(config.hooks.SessionStart).toHaveLength(1) - expect(config.hooks.PreToolUse).toHaveLength(2) - expect(config.hooks.PreToolUse[0]).toEqual({ command: 'echo keep' }) - expect(config.hooks.PreToolUse[1].command).toContain( - 'intent-copilot-gate.mjs', - ) + expect(config.hooks.PreToolUse).toEqual([{ command: 'echo keep' }]) }) it('preserves hooks that only mention an Intent gate outside command fields', () => { @@ -300,210 +307,11 @@ describe('hook installer', () => { const config = readJson(hooksPath) expect(config.hooks.SessionStart).toHaveLength(1) - expect(config.hooks.PreToolUse).toHaveLength(2) - expect(config.hooks.PreToolUse[0]).toMatchObject({ + expect(config.hooks.PreToolUse).toHaveLength(1) + expect(config.hooks.PreToolUse[0]).toEqual({ command: 'echo keep', note: 'mentions intent-copilot-gate.mjs in documentation', }) - expect(config.hooks.PreToolUse[1].command).toContain( - 'intent-copilot-gate.mjs', - ) - }) - - it('builds a runner script with command-free denial text', () => { - const script = buildHookRunnerScript('claude') - - expect(script).toContain('const AGENT = "claude"') - expect(script).toContain('permissionDecision') - expect(script).not.toMatch(/Blocked:.*intent\s+(list|load)/i) - expect(script).not.toContain('@tanstack/intent/core') - expect(script).not.toContain('createRequire') - }) - - it('runs the generated gate script through the load then edit cycle', () => { - const root = tempRoot('intent-hooks-runner-') - const scriptPath = join(root, 'intent-claude-gate.mjs') - writeFileSync(scriptPath, buildHookRunnerScript('claude')) - - const beforeLoad = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - const load = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Bash', - tool_input: { command: 'intent load @tanstack/router#routing' }, - }) - const afterLoad = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - - expect(beforeLoad.status).toBe(0) - expect(JSON.parse(beforeLoad.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) - expect(load.status).toBe(0) - expect(load.stdout).toBe('') - expect(afterLoad.status).toBe(0) - expect(afterLoad.stdout).toBe('') - }) - - it.each(['claude', 'codex', 'copilot'] as const)( - 'emits session catalog context for %s', - (agent) => { - const root = tempRoot(`intent-hooks-session-catalog-${agent}-`) - const catalogCommand = writeFakeIntentListCommand(root) - const scriptPath = join( - root, - '.intent', - 'hooks', - `intent-${agent}-gate.mjs`, - ) - mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) - writeFileSync(scriptPath, buildHookRunnerScript(agent, catalogCommand)) - - const result = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'SessionStart', - session_id: 'session-a', - source: 'startup', - }) - - expect(result.status).toBe(0) - const output = JSON.parse(result.stdout) - const context = - agent === 'copilot' - ? output.additionalContext - : output.hookSpecificOutput.additionalContext - expect(context).toContain('TanStack Intent skills are available') - expect(context).toContain( - '- @tanstack/router#routing: Router routing guidance', - ) - expect(context).toContain('load that full skill guidance') - expect(context).not.toContain('intent load ') - if (agent !== 'copilot') { - expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart') - } - }, - ) - - it('does not unlock edits after session catalog context', () => { - const root = tempRoot('intent-hooks-session-catalog-gate-') - const catalogCommand = writeFakeIntentListCommand(root) - const scriptPath = join(root, '.intent', 'hooks', 'intent-claude-gate.mjs') - mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) - writeFileSync(scriptPath, buildHookRunnerScript('claude', catalogCommand)) - - const sessionStart = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'SessionStart', - session_id: 'session-a', - source: 'startup', - }) - const edit = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - - expect(sessionStart.status).toBe(0) - expect(JSON.parse(sessionStart.stdout)).toMatchObject({ - hookSpecificOutput: { hookEventName: 'SessionStart' }, - }) - expect(edit.status).toBe(0) - expect(JSON.parse(edit.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) - }) - - it('continues silently when session catalog loading fails', () => { - const root = tempRoot('intent-hooks-session-catalog-missing-') - const scriptPath = join(root, '.intent', 'hooks', 'intent-claude-gate.mjs') - mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) - writeFileSync( - scriptPath, - buildHookRunnerScript( - 'claude', - `${quoteShell(process.execPath)} ${quoteShell(join(root, 'missing.mjs'))}`, - ), - ) - - const result = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'SessionStart', - session_id: 'session-a', - source: 'startup', - }) - - expect(result.status).toBe(0) - expect(result.stdout).toBe('') - }) - - it('does not unlock edits after non-executed load text', () => { - const root = tempRoot('intent-hooks-non-executed-load-') - const scriptPath = join(root, 'intent-claude-gate.mjs') - writeFileSync(scriptPath, buildHookRunnerScript('claude')) - - const echoLoad = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Bash', - tool_input: { command: 'echo intent load @tanstack/router#routing' }, - }) - const afterEcho = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - - expect(echoLoad.status).toBe(0) - expect(echoLoad.stdout).toBe('') - expect(afterEcho.status).toBe(0) - expect(JSON.parse(afterEcho.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) - }) - - it('unlocks edits after a load in an or-chain command', () => { - const root = tempRoot('intent-hooks-runner-or-chain-') - const scriptPath = join(root, 'intent-claude-gate.mjs') - writeFileSync(scriptPath, buildHookRunnerScript('claude')) - - const load = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Bash', - tool_input: { - command: 'npm test || intent load @tanstack/router#routing', - }, - }) - const afterLoad = runHookScript(scriptPath, { - cwd: root, - hook_event_name: 'PreToolUse', - session_id: 'session-a', - tool_name: 'Edit', - tool_input: { file_path: join(root, 'src.ts') }, - }) - - expect(load.status).toBe(0) - expect(load.stdout).toBe('') - expect(afterLoad.status).toBe(0) - expect(afterLoad.stdout).toBe('') }) it('formats skipped install results', () => { @@ -521,39 +329,3 @@ describe('hook installer', () => { ) }) }) - -function runHookScript(scriptPath: string, event: Record) { - return spawnSync(process.execPath, [scriptPath], { - encoding: 'utf8', - input: JSON.stringify(event), - }) -} - -function writeFakeIntentListCommand(root: string): string { - const scriptPath = join(root, 'fake-intent-list.mjs') - writeFileSync( - scriptPath, - `if (process.env.INTENT_AUDIENCE !== 'agent') { - process.exit(1) -} - -console.log(JSON.stringify({ - conflicts: [], - debug: { scan: { packageJsonReadCount: 3 } }, - packages: [{ name: '@tanstack/router' }], - skills: [ - { - description: 'Router routing guidance', - use: '@tanstack/router#routing', - }, - ], - warnings: [], - })) -`, - ) - return `${quoteShell(process.execPath)} ${quoteShell(scriptPath)}` -} - -function quoteShell(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` -} diff --git a/packages/intent/tests/hooks.test.ts b/packages/intent/tests/hooks.test.ts deleted file mode 100644 index b7a9e66f..00000000 --- a/packages/intent/tests/hooks.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { formatClaudePreToolUseOutput } from '../src/hooks/agents/claude.js' -import { formatCodexPreToolUseOutput } from '../src/hooks/agents/codex.js' -import { formatCopilotPreToolUseOutput } from '../src/hooks/agents/copilot.js' -import { - EDIT_TOOLS_BY_AGENT, - GATE_DENY_REASON, - gateDecision, - hasLoadFromObservations, - observationFromEvent, - parseIntentInvocation, -} from '../src/hooks/policy.js' - -describe('intent hook policy', () => { - it('parses intent load and list invocations across runners', () => { - expect( - parseIntentInvocation( - 'npx @tanstack/intent@latest load @tanstack/router#routing', - ), - ).toEqual({ action: 'load', skillUse: '@tanstack/router#routing' }) - expect(parseIntentInvocation('intent list')).toEqual({ action: 'list' }) - expect( - parseIntentInvocation('cd packages/app && intent load @tanstack/x#y'), - ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) - expect( - parseIntentInvocation('npm test || intent load @tanstack/x#y'), - ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) - }) - - it('ignores non-intent commands and incomplete load commands', () => { - expect(parseIntentInvocation('npm run build')).toBeUndefined() - expect( - parseIntentInvocation('echo intent load @tanstack/router#routing'), - ).toBeUndefined() - expect( - parseIntentInvocation('# intent load @tanstack/router#routing'), - ).toBeUndefined() - expect(parseIntentInvocation('intent load')).toBeUndefined() - expect(parseIntentInvocation(undefined)).toBeUndefined() - }) - - it('observes intent commands only from Bash tool calls', () => { - expect( - observationFromEvent({ - tool_name: 'Bash', - tool_input: { command: 'intent load @tanstack/router#routing' }, - }), - ).toEqual({ - action: 'load', - skillUse: '@tanstack/router#routing', - raw: 'intent load @tanstack/router#routing', - }) - expect( - observationFromEvent({ - toolName: 'Bash', - toolArgs: JSON.stringify({ command: 'intent list' }), - }), - ).toEqual({ action: 'list', raw: 'intent list', skillUse: undefined }) - expect( - observationFromEvent({ - tool_name: 'Edit', - tool_input: { command: 'intent load @tanstack/router#routing' }, - }), - ).toBeUndefined() - }) - - it('denies edit tools until a load is observed', () => { - expect( - gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: false }), - ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) - expect( - gateDecision({ agent: 'claude', toolName: 'Write', hasLoaded: false }), - ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) - expect( - gateDecision({ - agent: 'codex', - toolName: 'apply_patch', - hasLoaded: false, - }), - ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) - expect( - gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: true }), - ).toEqual({ decision: 'allow' }) - expect( - gateDecision({ agent: 'codex', toolName: 'Bash', hasLoaded: false }), - ).toEqual({ decision: 'allow' }) - expect(EDIT_TOOLS_BY_AGENT.copilot.has('Write')).toBe(true) - expect(EDIT_TOOLS_BY_AGENT.claude.has('Edit')).toBe(true) - expect(EDIT_TOOLS_BY_AGENT.codex.has('apply_patch')).toBe(true) - }) - - it('detects a prior load from observation records', () => { - expect(hasLoadFromObservations([{ action: 'list' }])).toBe(false) - expect( - hasLoadFromObservations([{ action: 'list' }, { action: 'load' }]), - ).toBe(true) - }) - - it('keeps the deny reason free of parseable intent commands', () => { - expect(parseIntentInvocation(GATE_DENY_REASON)).toBeUndefined() - expect(/intent\s+(list|load)/i.test(GATE_DENY_REASON)).toBe(false) - }) -}) - -describe('intent hook agent adapters', () => { - const deny = { decision: 'deny' as const, reason: GATE_DENY_REASON } - - it('formats Copilot PreToolUse denial output', () => { - expect(formatCopilotPreToolUseOutput(deny)).toEqual({ - permissionDecision: 'deny', - permissionDecisionReason: GATE_DENY_REASON, - }) - expect(formatCopilotPreToolUseOutput({ decision: 'allow' })).toBeUndefined() - }) - - it('formats Claude PreToolUse denial output', () => { - expect(formatClaudePreToolUseOutput(deny)).toEqual({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: GATE_DENY_REASON, - }, - }) - expect(formatClaudePreToolUseOutput({ decision: 'allow' })).toBeUndefined() - }) - - it('formats Codex PreToolUse denial output', () => { - expect(formatCodexPreToolUseOutput(deny)).toEqual({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: GATE_DENY_REASON, - }, - }) - expect(formatCodexPreToolUseOutput({ decision: 'allow' })).toBeUndefined() - }) -}) diff --git a/packages/intent/tests/install-config.test.ts b/packages/intent/tests/install-config.test.ts new file mode 100644 index 00000000..eb07425b --- /dev/null +++ b/packages/intent/tests/install-config.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + INSTALL_TARGETS, + installTargetsForMethod, + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from '../src/commands/install/config.js' +import type { + InstallMethod, + IntentConsumerConfig, + IntentInstallPreferences, +} from '../src/commands/install/config.js' + +describe('installer configuration', () => { + it('provides neutral install targets without detected or selected state', () => { + expect(INSTALL_TARGETS).toEqual([ + { id: 'agents', label: 'Shared .agents directory' }, + { id: 'github', label: 'GitHub Copilot' }, + { id: 'vscode', label: 'VS Code' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'codex', label: 'Codex' }, + { id: 'claude', label: 'Claude Code' }, + ]) + }) + + it('filters targets by the selected delivery method', () => { + expect( + installTargetsForMethod('symlink').map((target) => target.id), + ).toEqual(['agents', 'github', 'vscode', 'cursor', 'codex', 'claude']) + expect(installTargetsForMethod('hooks').map((target) => target.id)).toEqual( + ['github', 'codex', 'claude'], + ) + }) + + it('rejects an install method unsupported by a selected target', () => { + const preferences: IntentInstallPreferences = { + method: 'hooks', + targets: ['github'], + } + const method: InstallMethod = preferences.method + expect(method).toBe('hooks') + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "hooks", "targets": ["vscode"] } } }', + ), + ).toThrow('not supported') + }) + + it('rejects duplicate install targets', () => { + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "symlink", "targets": ["agents", "agents"] } } }', + ), + ).toThrow('Duplicate') + }) + + it('rejects unknown install fields', () => { + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "symlink", "targets": [], "extra": true } } }', + ), + ).toThrow('Unknown') + }) + + it('rejects unknown targets, methods, and wrong target types', () => { + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "symlink", "targets": ["unknown"] } } }', + ), + ).toThrow('Unknown install target') + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "unknown", "targets": ["github"] } } }', + ), + ).toThrow('Unknown install method') + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "symlink", "targets": "github" } } }', + ), + ).toThrow('array of strings') + }) + + it('updates JSONC fields without changing unrelated formatting', () => { + const source = + '\ufeff{\r\n\t// keep this comment\r\n\t"name": "app",\r\n\t"intent": {\r\n\t\t"skills": ["old"],\r\n\t},\r\n}\r\n' + const updated = updateIntentConsumerConfigText(source, { + skills: ['@tanstack/query'], + exclude: ['@other/pkg'], + install: { method: 'hooks', targets: ['github'] }, + }) + + expect(updated.startsWith('\ufeff')).toBe(true) + expect(updated).toContain('\t// keep this comment\r\n') + expect(updated).toContain('\t"name": "app"') + expect(updated).toContain('\r\n') + expect(updated.endsWith('\r\n')).toBe(true) + expect(readIntentConsumerConfig(updated)).toEqual({ + skills: ['@tanstack/query'], + exclude: ['@other/pkg'], + install: { method: 'hooks', targets: ['github'] }, + }) + }) + + it('returns byte-identical JSONC for an unchanged request', () => { + const source = + '{\n // formatting stays\n "intent": {\n "skills": ["pkg"],\n "exclude": []\n }\n}\n' + const requested: IntentConsumerConfig = { skills: ['pkg'], exclude: [] } + expect(updateIntentConsumerConfigText(source, requested)).toBe(source) + }) + + it('preserves unchanged array formatting when another field changes', () => { + const source = `{ + "intent": { + "skills": [ + "first", + "second" + ], + "exclude": [] + } +} +` + + const updated = updateIntentConsumerConfigText(source, { + skills: ['first', 'second'], + exclude: ['ignored'], + }) + + expect(updated).toContain( + '"skills": [\n "first",\n "second"\n ]', + ) + }) + + it('rejects blank exclude entries', () => { + expect(() => + readIntentConsumerConfig('{"intent":{"exclude":[" "]}}'), + ).toThrow('must not contain blank entries') + }) +}) diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts new file mode 100644 index 00000000..1e9a0c21 --- /dev/null +++ b/packages/intent/tests/install-plan.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from 'vitest' +import { + buildInstallDeltaInventory, + buildSkillSelectionPlan, + summarizeInstallDeltaInventory, +} from '../src/commands/install/plan.js' +import type { + InventoryLockStatus, + InventoryPolicyStatus, +} from '../src/commands/install/plan.js' +import type { IntentLockfileSource } from '../src/core/lockfile/lockfile.js' +import type { IntentPackage } from '../src/shared/types.js' + +function pkg( + name: string, + skills: Array, + kind: IntentPackage['kind'] = 'npm', +): IntentPackage { + return { + name, + version: '1.0.0', + kind, + source: 'local', + packageRoot: name, + intent: { version: 1, repo: '', docs: '' }, + skills: skills.map((name) => ({ + name, + path: `skills/${name}/SKILL.md`, + description: '', + })), + } +} + +const discovered = [ + pkg('@other/core', ['second']), + pkg('@tanstack/query', ['zeta', 'alpha']), + pkg('workspace-query', ['local'], 'workspace'), +] + +describe('installer selection planning', () => { + it('classifies discovered skills from configured policy without rewriting it', () => { + const skills = ['workspace:workspace-query', '@tanstack/query#zeta'] + const exclude = [ + '@other/core', + '@tanstack/query#alpha', + 'workspace-query#local', + ] + + const plan = buildSkillSelectionPlan(discovered, { + mode: 'configured-policy', + skills, + exclude, + }) + + expect(plan.skills).toBe(skills) + expect(plan.exclude).toBe(exclude) + expect(plan.packages.flatMap((entry) => entry.skills)).toEqual([ + { id: '@other/core#second', status: 'excluded' }, + { id: '@tanstack/query#alpha', status: 'excluded' }, + { id: '@tanstack/query#zeta', status: 'enabled' }, + { id: 'workspace:workspace-query#local', status: 'excluded' }, + ]) + }) + + it('rejects a configured policy that leaves a discovered skill pending', () => { + expect(() => + buildSkillSelectionPlan( + [pkg('@acme/query', ['fetching']), pkg('@acme/router', ['routing'])], + { + mode: 'configured-policy', + skills: ['@acme/query#fetching'], + exclude: [], + }, + ), + ).toThrow( + 'Configured policy leaves "@acme/router#routing" pending. Add it to intent.skills or intent.exclude before non-interactive install.', + ) + }) + + it('uses exact discovered source identities for all-found', () => { + expect( + buildSkillSelectionPlan(discovered, { mode: 'all-found' }), + ).toMatchObject({ + skills: ['@other/core', '@tanstack/query', 'workspace:workspace-query'], + exclude: [], + }) + }) + + it('adds explicit exclusions for scope nonmatches', () => { + const plan = buildSkillSelectionPlan(discovered, { + mode: 'scope', + scope: '@tanstack/*', + }) + expect(plan.skills).toEqual(['@tanstack/*']) + expect(plan.exclude).toEqual(['@other/core', 'workspace-query']) + expect(plan.packages.flatMap((entry) => entry.skills)).toEqual([ + { id: '@other/core#second', status: 'excluded' }, + { id: '@tanstack/query#alpha', status: 'enabled' }, + { id: '@tanstack/query#zeta', status: 'enabled' }, + { id: 'workspace:workspace-query#local', status: 'excluded' }, + ]) + }) + + it('allows checked skills and excludes unchecked packages for individual selection', () => { + const plan = buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['@tanstack/query#alpha'], + }) + expect(plan.skills).toEqual(['@tanstack/query#alpha']) + expect(plan.exclude).toEqual(['@other/core', 'workspace-query']) + }) + + it('writes a bare package exclusion when no skills are selected', () => { + const plan = buildSkillSelectionPlan([pkg('disabled', ['one', 'two'])], { + mode: 'individual', + enabled: [], + }) + + expect(plan.skills).toEqual([]) + expect(plan.exclude).toEqual(['disabled']) + }) + + it('rejects malformed, duplicate, and unknown individual selections', () => { + expect(() => + buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['not-an-id'], + }), + ).toThrow('Unknown') + expect(() => + buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['@tanstack/query#alpha', '@tanstack/query#alpha'], + }), + ).toThrow('Duplicate') + }) + + it('rejects a selection whose exclusion would hide an enabled skill', () => { + const sameName = [ + pkg('shared', ['npm-skill']), + pkg('shared', ['workspace-skill'], 'workspace'), + ] + expect( + buildSkillSelectionPlan(sameName, { mode: 'all-found' }).skills, + ).toEqual(['shared', 'workspace:shared']) + expect(() => + buildSkillSelectionPlan(sameName, { + mode: 'individual', + enabled: ['workspace:shared#workspace-skill'], + }), + ).toThrow('would also hide "workspace:shared#workspace-skill"') + }) + + it('writes kind-aware skill allows when both kinds select a subset', () => { + const sameName = [ + pkg('shared', ['keep', 'drop']), + pkg('shared', ['keep', 'drop'], 'workspace'), + ] + const plan = buildSkillSelectionPlan(sameName, { + mode: 'individual', + enabled: ['shared#keep', 'workspace:shared#keep'], + }) + + expect(plan.skills).toEqual(['shared#keep', 'workspace:shared#keep']) + expect(plan.exclude).toEqual([]) + }) + + it('writes a shared package exclusion when both kinds are fully disabled', () => { + const sameName = [ + pkg('shared', ['one']), + pkg('shared', ['two'], 'workspace'), + pkg('other', ['keep']), + ] + const plan = buildSkillSelectionPlan(sameName, { + mode: 'individual', + enabled: ['other#keep'], + }) + + expect(plan.skills).toEqual(['other']) + expect(plan.exclude).toEqual(['shared']) + }) + + it('uses kind-aware workspace grammar for partial skill allows', () => { + const plan = buildSkillSelectionPlan( + [pkg('workspace-only', ['enabled', 'excluded'], 'workspace')], + { + mode: 'individual', + enabled: ['workspace:workspace-only#enabled'], + }, + ) + + expect(plan.skills).toEqual(['workspace:workspace-only#enabled']) + expect(plan.exclude).toEqual([]) + }) + + it('rejects duplicate discovered sources and skills', () => { + expect(() => + buildSkillSelectionPlan( + [pkg('duplicate', ['one']), pkg('duplicate', ['two'])], + { + mode: 'all-found', + }, + ), + ).toThrow('Duplicate discovered source') + expect(() => + buildSkillSelectionPlan([pkg('duplicate', ['one', 'one'])], { + mode: 'all-found', + }), + ).toThrow('Duplicate discovered skill') + }) +}) + +describe('installer delta inventory', () => { + it('summarizes pending, new, changed, and removed entries', () => { + expect( + summarizeInstallDeltaInventory({ + packages: [ + { + name: 'pkg', + kind: 'npm', + skills: [ + { id: 'pkg#pending', policy: 'pending', lock: null }, + { id: 'pkg#new', policy: 'enabled', lock: 'new' }, + { id: 'pkg#changed', policy: 'enabled', lock: 'changed' }, + { id: 'pkg#accepted', policy: 'enabled', lock: 'accepted' }, + ], + }, + ], + removed: [{ kind: 'npm', id: 'gone', path: null }], + }), + ).toEqual({ + newDependencies: [{ name: 'pkg', skillCount: 1 }], + newSkills: [{ name: 'pkg', skillCount: 1 }], + changed: [{ name: 'pkg', skillCount: 1 }], + removed: 1, + }) + }) + + it('classifies changed skills independently and reports removed lock entries', () => { + const accepted: InventoryLockStatus = 'accepted' + const enabled: InventoryPolicyStatus = 'enabled' + expect([enabled, accepted]).toEqual(['enabled', 'accepted']) + const packages = [pkg('pkg', ['alpha', 'beta'])] + const current: Array = [ + { + kind: 'npm', + id: 'pkg', + skills: [ + { path: 'skills/alpha', contentHash: 'changed' }, + { path: 'skills/beta', contentHash: 'accepted' }, + ], + }, + ] + const inventory = buildInstallDeltaInventory( + packages, + current, + { + status: 'found', + lockfile: { + lockfileVersion: 1, + sources: [ + { + kind: 'npm', + id: 'pkg', + skills: [ + { path: 'skills/alpha', contentHash: 'old' }, + { path: 'skills/beta', contentHash: 'accepted' }, + { path: 'skills/removed', contentHash: 'removed' }, + ], + }, + { + kind: 'workspace', + id: 'gone', + skills: [{ path: 'skills/old', contentHash: 'removed' }], + }, + ], + }, + }, + { skills: ['pkg'], exclude: [] }, + ) + expect(inventory.packages[0]!.skills).toEqual([ + { id: 'pkg#alpha', policy: 'enabled', lock: 'changed' }, + { id: 'pkg#beta', policy: 'enabled', lock: 'accepted' }, + ]) + expect(inventory.removed).toEqual([ + { kind: 'npm', id: 'pkg', path: 'skills/removed' }, + { kind: 'workspace', id: 'gone', path: null }, + ]) + }) + + it('marks enabled sources as new without a lock and leaves pending policy unaccepted', () => { + const inventory = buildInstallDeltaInventory( + [pkg('a', ['one']), pkg('b', ['two'])], + [ + { + kind: 'npm', + id: 'a', + skills: [{ path: 'skills/one', contentHash: 'a' }], + }, + { + kind: 'npm', + id: 'b', + skills: [{ path: 'skills/two', contentHash: 'b' }], + }, + ], + { status: 'missing' }, + { skills: ['a'], exclude: [] }, + ) + expect(inventory.packages.map((entry) => entry.skills[0])).toEqual([ + { id: 'a#one', policy: 'enabled', lock: 'new' }, + { id: 'b#two', policy: 'pending', lock: null }, + ]) + }) + + it('marks a skill outside a skill-level allow entry as pending', () => { + const inventory = buildInstallDeltaInventory( + [pkg('pkg', ['alpha', 'beta'])], + [], + { status: 'missing' }, + { skills: ['pkg#alpha'], exclude: [] }, + ) + + expect(inventory.packages[0]!.skills).toEqual([ + { id: 'pkg#alpha', policy: 'enabled', lock: 'new' }, + { id: 'pkg#beta', policy: 'pending', lock: null }, + ]) + }) + + it('treats an explicit empty skill policy as deny-all', () => { + const inventory = buildInstallDeltaInventory( + [pkg('pkg', ['alpha', 'beta'])], + [], + { status: 'missing' }, + { skills: [], exclude: [] }, + ) + + expect(inventory.packages[0]!.skills).toEqual([ + { id: 'pkg#alpha', policy: 'excluded', lock: null }, + { id: 'pkg#beta', policy: 'excluded', lock: null }, + ]) + }) + + it('records a declined package as excluded rather than pending', () => { + const discovered = [pkg('keep', ['one']), pkg('decline', ['two'])] + const plan = buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['keep#one'], + }) + const inventory = buildInstallDeltaInventory( + discovered, + [], + { status: 'missing' }, + { skills: plan.skills, exclude: plan.exclude }, + ) + const policies = new Map( + inventory.packages.flatMap((entry) => + entry.skills.map((skill) => [skill.id, skill.policy]), + ), + ) + + expect(policies.get('keep#one')).toBe('enabled') + expect(policies.get('decline#two')).toBe('excluded') + }) +}) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 67112f5e..52b8c6ed 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -6,7 +6,8 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { buildIntentSkillGuidanceBlock, @@ -15,6 +16,7 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from '../src/commands/install/guidance.js' +import { packageVersionToPin } from '../src/shared/command-runner.js' import type { IntentPackage, ScanResult, @@ -22,6 +24,13 @@ import type { } from '../src/shared/types.js' const tempDirs: Array = [] +const packageJson = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), + 'utf8', + ), +) as { version: string } +const intentPackagePin = packageVersionToPin(packageJson.version) afterEach(() => { for (const dir of tempDirs.splice(0)) { @@ -89,7 +98,7 @@ const exampleBlock = ` # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching" ` @@ -100,7 +109,9 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(0) expect(generated.block).toContain('## Skill Loading') - expect(generated.block).toContain('npx @tanstack/intent@latest list') + expect(generated.block).toContain( + `npx @tanstack/intent@${intentPackagePin} list`, + ) expect(generated.block).toContain('If a listed skill matches the task') expect(generated.block).toContain('before changing files') expect(generated.block).toContain('Monorepos:') @@ -112,9 +123,11 @@ describe('install writer block builder', () => { it('builds package-manager-specific loading guidance', () => { const generated = buildIntentSkillGuidanceBlock('pnpm') - expect(generated.block).toContain('pnpm dlx @tanstack/intent@latest list') expect(generated.block).toContain( - 'pnpm dlx @tanstack/intent@latest load #', + `pnpm dlx @tanstack/intent@${intentPackagePin} list`, + ) + expect(generated.block).toContain( + `pnpm dlx @tanstack/intent@${intentPackagePin} load #`, ) }) @@ -154,13 +167,13 @@ describe('install writer block builder', () => { # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching patterns" - id: "@tanstack/query#mutations" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#mutations" + run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#mutations" for: "Mutation patterns" - id: "@tanstack/router#routing" - run: "pnpm dlx @tanstack/intent@latest load @tanstack/router#routing" + run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/router#routing" for: "Routing patterns" `) @@ -191,7 +204,7 @@ tanstackIntent: expect(generated.block).toContain('id: "@tanstack/query#global-fetching"') expect(generated.block).toContain('id: "@tanstack/query#pnpm-fetching"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#global-fetching"', + `run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#global-fetching"`, ) expect(generated.block).not.toContain('/home/sarah') expect(generated.block).not.toContain('node_modules/.pnpm') @@ -230,12 +243,12 @@ tanstackIntent: expect(generated.block).toContain('for: "Core skill"') expect(generated.block).toContain('id: "@tanstack/query#core"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core"', + `run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#core"`, ) expect(generated.block).toContain('for: "Sub-skill"') expect(generated.block).toContain('id: "@tanstack/query#core/fetching"') expect(generated.block).toContain( - 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core/fetching"', + `run: "pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#core/fetching"`, ) expect(generated.block).not.toContain('Reference material') expect(generated.block).not.toContain('Maintainer task') @@ -466,6 +479,36 @@ describe('install writer verification', () => { ).toEqual({ errors: [], ok: true }) }) + it.each([ + 'intent load @tanstack/query#fetching', + 'npx @tanstack/intent@latest load @tanstack/query#fetching', + 'npx @tanstack/intent@0.4 load @tanstack/query#fetching', + 'npx @tanstack/intent@0.4.0-next.1 load @tanstack/query#fetching', + ])( + 'accepts a guidance command that extracts its skill use: %s', + (command) => { + const root = tempRoot() + const agentsPath = join(root, 'AGENTS.md') + const block = ` +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query#fetching" + run: "${command}" + for: "Query data fetching" + +` + writeFileSync(agentsPath, block) + + expect( + verifyIntentSkillsBlockFile({ + expectedBlock: block, + expectedMappingCount: 1, + targetPath: agentsPath, + }), + ).toEqual({ errors: [], ok: true }) + }, + ) + it('accepts a written compact block', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') @@ -473,7 +516,7 @@ describe('install writer verification', () => { # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching" ` @@ -542,7 +585,7 @@ tanstackIntent: const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. +# Skill mappings - load \`use\` with \`npx @tanstack/intent@${intentPackagePin} load \`. skills: - when: "Global query skill" load: "/home/sarah/.npm-global/lib/node_modules/@tanstack/query/skills/global/SKILL.md" @@ -569,7 +612,7 @@ skills: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" ` writeFileSync(agentsPath, block) @@ -592,7 +635,7 @@ tanstackIntent: const block = ` # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + - run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching" ` @@ -617,7 +660,7 @@ tanstackIntent: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query" - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Query data fetching" ` @@ -642,7 +685,7 @@ tanstackIntent: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "npx @tanstack/intent@latest load @tanstack/router#routing" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/router#routing" for: "Query data fetching" ` @@ -667,7 +710,7 @@ tanstackIntent: # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: - id: "@tanstack/query#fetching" - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + run: "npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching" for: "Edit /Users/sarah/project/src files" ` diff --git a/packages/intent/tests/integration/catalog-bundle.test.ts b/packages/intent/tests/integration/catalog-bundle.test.ts new file mode 100644 index 00000000..a5a63de6 --- /dev/null +++ b/packages/intent/tests/integration/catalog-bundle.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const staticImportPattern = + /\b(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g + +function getStaticImports(source: string): Array { + return [...source.matchAll(staticImportPattern)].map((match) => match[1]!) +} + +describe('catalog bundle', () => { + it('does not statically import yaml or semver', () => { + const distDir = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../dist', + ) + const pending = [resolve(distDir, 'catalog.mjs')] + const visited = new Set() + + while (pending.length > 0) { + const modulePath = pending.pop()! + if (visited.has(modulePath)) continue + visited.add(modulePath) + + for (const specifier of getStaticImports( + readFileSync(modulePath, 'utf8'), + )) { + expect(specifier).not.toBe('yaml') + expect(specifier).not.toBe('semver') + + if (specifier.startsWith('.')) { + pending.push(resolve(dirname(modulePath), specifier)) + } + } + } + }) +}) diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 5bbede6f..202395bb 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -8,8 +8,11 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js' import { main } from '../../src/cli.js' +import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js' +import { buildCurrentLockfileSources } from '../../src/core/lockfile/lockfile-state.js' +import { parseSkillSources } from '../../src/core/skill-sources.js' +import { applySourcePolicy } from '../../src/core/source-policy.js' const realTmpdir = realpathSync(tmpdir()) @@ -43,19 +46,28 @@ const EXCLUDED = '@scope/excluded' describe('source policy — all four surfaces filter excluded and unlisted', () => { let root: string let originalCwd: string + let previousIntentAudience: string | undefined + let errorSpy: ReturnType let logSpy: ReturnType beforeEach(() => { originalCwd = process.cwd() + previousIntentAudience = process.env.INTENT_AUDIENCE + delete process.env.INTENT_AUDIENCE root = mkdtempSync(join(realTmpdir, 'intent-g4-')) logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { process.chdir(originalCwd) vi.restoreAllMocks() delete process.env.INTENT_GLOBAL_NODE_MODULES + if (previousIntentAudience === undefined) { + delete process.env.INTENT_AUDIENCE + } else { + process.env.INTENT_AUDIENCE = previousIntentAudience + } rmSync(root, { recursive: true, force: true }) }) @@ -70,10 +82,10 @@ describe('source policy — all four surfaces filter excluded and unlisted', () writeIntentPackage(root, EXCLUDED, 'core') } - it('list surfaces only the listed package', () => { + it('list names the unlisted package for a human audience', () => { writeStandaloneFixture() - const result = listIntentSkills({ cwd: root }) + const result = listIntentSkills({ cwd: root, audience: 'human' }) expect(result.packages.map((pkg) => pkg.name)).toEqual([LISTED]) expect(result.notices.some((notice) => notice.includes(UNLISTED))).toBe( @@ -87,6 +99,153 @@ describe('source policy — all four surfaces filter excluded and unlisted', () ) }) + it('list withholds the unlisted package name from an agent audience', () => { + writeStandaloneFixture() + + const result = listIntentSkills({ cwd: root, audience: 'agent' }) + + expect(result.packages.map((pkg) => pkg.name)).toEqual([LISTED]) + expect(result.notices.some((notice) => notice.includes(UNLISTED))).toBe( + false, + ) + expect( + result.notices.some((notice) => + notice.includes('not listed in intent.skills'), + ), + ).toBe(true) + }) + + it.each([ + ['one skill', '@scope/hidden-one', ['only'], '1 skill'], + ['multiple skills', '@scope/hidden-many', ['first', 'second'], '2 skills'], + ])( + 'list --show-hidden prints a fully unlisted package with %s', + async (_case, hiddenPackage, hiddenSkills, count) => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [LISTED] }, + }) + writeIntentPackage(root, LISTED, 'core') + for (const hiddenSkill of hiddenSkills) { + writeIntentPackage(root, hiddenPackage, hiddenSkill) + } + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + const exitCode = await main(['list', '--show-hidden']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain(` ${hiddenPackage} (${count})`) + }, + ) + + it('list --show-hidden names skills hidden by a skill-level entry', async () => { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [`${LISTED}#a`] }, + }) + writeIntentPackage(root, LISTED, 'a') + writeIntentPackage(root, LISTED, 'b') + writeIntentPackage(root, LISTED, 'c') + process.env.INTENT_AUDIENCE = 'human' + process.chdir(root) + + const exitCode = await main(['list', '--show-hidden']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain(` ${LISTED} (2 skills not listed: b, c)`) + }) + + it('list --show-hidden redacts hidden source details for an agent audience', async () => { + const hiddenPackage = '@scope/agent-secret-package' + const hiddenSkill = 'agent-secret-skill' + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [LISTED] }, + }) + writeIntentPackage(root, LISTED, 'core') + writeIntentPackage(root, hiddenPackage, hiddenSkill) + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + const exitCode = await main(['list', '--show-hidden']) + const output = [ + ...logSpy.mock.calls.flat(), + ...errorSpy.mock.calls.flat(), + ].join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'Hidden skill sources are not revealed in agent sessions. Run this command outside the agent session to review candidates.', + ) + expect(output).not.toContain(hiddenPackage) + expect(output).not.toContain(hiddenSkill) + }) + + it('documents the current empty source retained for an unmatched skill entry', () => { + const packageName = '@scope/empty-source' + const packageRoot = join(root, 'node_modules', '@scope', 'empty-source') + const skillPath = join(packageRoot, 'skills', 'b', 'SKILL.md') + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [`${packageName}#a`] }, + }) + writeIntentPackage(root, packageName, 'b') + + const listed = listIntentSkills({ cwd: root, audience: 'human' }) + const policy = applySourcePolicy( + { + packages: [ + { + name: packageName, + version: '1.0.0', + intent: { version: 1, repo: 'owner/repo', docs: 'docs/' }, + skills: [ + { + name: 'b', + path: skillPath, + description: `${packageName} b`, + }, + ], + packageRoot, + kind: 'npm', + source: 'local', + }, + ], + }, + { + config: parseSkillSources([`${packageName}#a`]), + excludeMatchers: [], + }, + ) + const notices = [ + `1 skill from listed packages is not listed in intent.skills: ${packageName}#b. Add to opt in.`, + `"${packageName}#a" is declared in intent.skills but was not discovered.`, + ] + + expect(listed.packages).toMatchObject([ + { name: packageName, skillCount: 0 }, + ]) + expect(listed.notices).toEqual(notices) + expect(policy).toMatchObject({ + hiddenSourceCount: 1, + hiddenSources: [ + { name: packageName, skillCount: 1, hiddenSkills: ['b'] }, + ], + packages: [{ name: packageName, skills: [] }], + notices, + }) + expect(buildCurrentLockfileSources(policy.packages)).toEqual([ + { kind: 'npm', id: packageName, skills: [] }, + ]) + }) + it('list and load accept packages matched by an allowlist glob', () => { writeJson(join(root, 'package.json'), { name: 'app', diff --git a/packages/intent/tests/local-path.test.ts b/packages/intent/tests/local-path.test.ts new file mode 100644 index 00000000..29d00f1a --- /dev/null +++ b/packages/intent/tests/local-path.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { containsLocalPath } from '../src/shared/local-path.js' + +describe('containsLocalPath', () => { + it.each([ + 'C:\\Users\\person\\project\\SKILL.md', + '/Users/person/project/SKILL.md', + '/home/person/project/package.json', + './packages/router/SKILL.md', + 'node_modules/@scope/package/skills/core/SKILL.md', + 'file:///workspace/project/SKILL.md', + ])('detects local path %s', (value) => { + expect(containsLocalPath(value)).toBe(true) + }) + + it.each([ + '/users/:id', + '/posts/:slug', + 'Use the router/search API', + '@scope/package#skill', + ])('preserves non-filesystem value %s', (value) => { + expect(containsLocalPath(value)).toBe(false) + }) +}) diff --git a/packages/intent/tests/lockfile-diff.test.ts b/packages/intent/tests/lockfile-diff.test.ts new file mode 100644 index 00000000..018aeb56 --- /dev/null +++ b/packages/intent/tests/lockfile-diff.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { diffLockfileSources } from '../src/core/lockfile/lockfile-diff.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../src/core/lockfile/lockfile.js' + +const source = ( + skills: IntentLockfileSource['skills'], +): IntentLockfileSource => ({ kind: 'npm', id: 'example', skills }) + +describe('diffLockfileSources', () => { + it('distinguishes a missing lockfile', () => { + expect(diffLockfileSources([], { status: 'missing' })).toMatchObject({ + lockfile: 'missing', + isClean: false, + }) + }) + + it('diffs sources and individual skills independently regardless of ordering', () => { + const locked: ReadIntentLockfileResult = { + status: 'found' as const, + lockfile: { + lockfileVersion: 1 as const, + sources: [ + source([ + { path: 'skills/first', contentHash: 'one' }, + { path: 'skills/second', contentHash: 'two' }, + ]), + { kind: 'workspace', id: 'removed', skills: [] }, + ], + }, + } + const result = diffLockfileSources( + [ + source([ + { path: 'skills/third', contentHash: 'three' }, + { path: 'skills/second', contentHash: 'two' }, + { path: 'skills/first', contentHash: 'changed' }, + ]), + { kind: 'workspace', id: 'new', skills: [] }, + ], + locked, + ) + expect(result.addedSources).toEqual([ + { kind: 'workspace', id: 'new', skills: [] }, + ]) + expect(result.removedSources).toEqual([ + { kind: 'workspace', id: 'removed', skills: [] }, + ]) + expect(result.changedSources).toEqual([ + { + kind: 'npm', + id: 'example', + addedSkills: [{ path: 'skills/third', contentHash: 'three' }], + removedSkills: [], + changedSkills: [ + { + path: 'skills/first', + lockedContentHash: 'one', + currentContentHash: 'changed', + }, + ], + }, + ]) + }) + + it('reports a removed skill without treating it as changed', () => { + const locked: ReadIntentLockfileResult = { + status: 'found', + lockfile: { + lockfileVersion: 1, + sources: [ + source([ + { path: 'skills/first', contentHash: 'one' }, + { path: 'skills/removed', contentHash: 'old' }, + ]), + ], + }, + } + + expect( + diffLockfileSources( + [source([{ path: 'skills/first', contentHash: 'one' }])], + locked, + ).changedSources, + ).toEqual([ + { + kind: 'npm', + id: 'example', + addedSkills: [], + removedSkills: [{ path: 'skills/removed', contentHash: 'old' }], + changedSkills: [], + }, + ]) + }) +}) diff --git a/packages/intent/tests/lockfile-state.test.ts b/packages/intent/tests/lockfile-state.test.ts new file mode 100644 index 00000000..bfc0e588 --- /dev/null +++ b/packages/intent/tests/lockfile-state.test.ts @@ -0,0 +1,127 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import type { IntentPackage } from '../src/shared/types.js' + +const roots: Array = [] + +function packageFixture(kind: 'npm' | 'workspace' = 'npm'): { + pkg: IntentPackage + first: string + second: string +} { + const root = mkdtempSync(join(tmpdir(), 'intent-lock-state-')) + const first = join(root, 'skills', 'first', 'SKILL.md') + const second = join(root, 'skills', 'second', 'SKILL.md') + mkdirSync(join(root, 'skills', 'first'), { recursive: true }) + mkdirSync(join(root, 'skills', 'second'), { recursive: true }) + writeFileSync(first, 'First') + writeFileSync(second, 'Second') + roots.push(root) + return { + pkg: { + name: 'example', + version: '1.0.0', + kind, + source: 'local', + packageRoot: root, + intent: { version: 1, repo: '', docs: '' }, + skills: [ + { name: 'second', path: second, description: '' }, + { name: 'first', path: 'skills/first/SKILL.md', description: '' }, + ], + }, + first, + second, + } +} + +afterEach(() => { + roots + .splice(0) + .forEach((path) => rmSync(path, { recursive: true, force: true })) +}) + +describe('buildCurrentLockfileSources', () => { + it('builds independent hashes with package-relative skill directories', () => { + const { pkg, first } = packageFixture() + const initial = buildCurrentLockfileSources([pkg]) + writeFileSync(first, 'Changed') + const updated = buildCurrentLockfileSources([pkg]) + expect(initial[0]!.skills.map((skill) => skill.path)).toEqual([ + 'skills/first', + 'skills/second', + ]) + expect(updated[0]!.skills[0]!.contentHash).not.toBe( + initial[0]!.skills[0]!.contentHash, + ) + expect(updated[0]!.skills[1]!.contentHash).toBe( + initial[0]!.skills[1]!.contentHash, + ) + }) + + it('keeps npm and workspace sources with the same id distinct', () => { + const npm = packageFixture('npm').pkg + const workspace = { + ...packageFixture('workspace').pkg, + packageRoot: npm.packageRoot, + skills: npm.skills, + } + expect( + buildCurrentLockfileSources([workspace, npm]).map( + (source) => source.kind, + ), + ).toEqual(['npm', 'workspace']) + }) + + it('resolves npm and workspace paths rewritten for loading', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'intent-lock-project-')) + roots.push(projectRoot) + const npmRoot = join(projectRoot, 'node_modules', '@scope', 'package') + const workspaceRoot = join(projectRoot, 'packages', 'workspace') + const npmSkill = join(npmRoot, 'skills', 'npm-skill', 'SKILL.md') + const workspaceSkill = join( + workspaceRoot, + 'skills', + 'workspace-skill', + 'SKILL.md', + ) + mkdirSync(join(npmRoot, 'skills', 'npm-skill'), { recursive: true }) + mkdirSync(join(workspaceRoot, 'skills', 'workspace-skill'), { + recursive: true, + }) + writeFileSync(npmSkill, 'Npm') + writeFileSync(workspaceSkill, 'Workspace') + + const npm = packageFixture().pkg + npm.name = '@scope/package' + npm.packageRoot = npmRoot + npm.skills = [ + { + name: 'npm-skill', + path: 'node_modules/@scope/package/skills/npm-skill/SKILL.md', + description: '', + }, + ] + const workspace = packageFixture('workspace').pkg + workspace.name = 'workspace-package' + workspace.packageRoot = workspaceRoot + workspace.skills = [ + { + name: 'workspace-skill', + path: 'packages/workspace/skills/workspace-skill/SKILL.md', + description: '', + }, + ] + + expect(buildCurrentLockfileSources([workspace, npm])).toMatchObject([ + { kind: 'npm', skills: [{ path: 'skills/npm-skill' }] }, + { + kind: 'workspace', + skills: [{ path: 'skills/workspace-skill' }], + }, + ]) + }) +}) diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts new file mode 100644 index 00000000..f33a286c --- /dev/null +++ b/packages/intent/tests/lockfile.test.ts @@ -0,0 +1,133 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + parseIntentLockfile, + readIntentLockfile, + serializeIntentLockfile, + writeIntentLockfile, +} from '../src/core/lockfile/lockfile.js' + +const roots: Array = [] + +function root(): string { + const path = mkdtempSync(join(tmpdir(), 'intent-lockfile-')) + roots.push(path) + return path +} + +afterEach(() => { + roots + .splice(0) + .forEach((path) => rmSync(path, { recursive: true, force: true })) +}) + +describe('intent lockfile', () => { + it('serializes sources and skills in ordinal order', () => { + const serialized = serializeIntentLockfile({ + lockfileVersion: 1, + sources: [ + { + kind: 'workspace', + id: 'z', + skills: [{ path: 'skills/z', contentHash: 'sha256-z' }], + }, + { + kind: 'npm', + id: 'a', + skills: [ + { path: 'skills/z', contentHash: 'sha256-z' }, + { path: 'skills/a', contentHash: 'sha256-a' }, + ], + }, + ], + }) + + expect(serialized).toBe( + `${JSON.stringify( + { + lockfileVersion: 1, + sources: [ + { + kind: 'npm', + id: 'a', + skills: [ + { path: 'skills/a', contentHash: 'sha256-a' }, + { path: 'skills/z', contentHash: 'sha256-z' }, + ], + }, + { + kind: 'workspace', + id: 'z', + skills: [{ path: 'skills/z', contentHash: 'sha256-z' }], + }, + ], + }, + null, + 2, + )}\n`, + ) + }) + + it('rejects undeclared fields, invalid source identity, and duplicate paths', () => { + expect(() => + parseIntentLockfile('{"lockfileVersion":1,"sources":[],"extra":true}'), + ).toThrow() + expect(() => + parseIntentLockfile('{"lockfileVersion":2,"sources":[]}'), + ).toThrow() + expect(() => + parseIntentLockfile('{"lockfileVersion":1,"sources":"bad"}'), + ).toThrow() + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"git","id":"a","skills":[]}]}', + ), + ).toThrow() + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"npm","id":"a","skills":[]},{"kind":"npm","id":"a","skills":[]}]}', + ), + ).toThrow() + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"npm","id":"a","skills":[{"path":"skills/a","contentHash":"x"},{"path":"skills/a","contentHash":"y"}]}]}', + ), + ).toThrow() + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"npm","id":"a","skills":[{"path":"../skills/a","contentHash":"x"}]}]}', + ), + ).toThrow() + }) + + it('names an upgrade path for lockfiles a newer Intent wrote', () => { + expect(() => + parseIntentLockfile('{"lockfileVersion":2,"sources":[]}'), + ).toThrow(/lockfileVersion 2.*Upgrade @tanstack\/intent/s) + expect(() => + parseIntentLockfile( + '{"lockfileVersion":1,"sources":[{"kind":"git","id":"a","skills":[]}]}', + ), + ).toThrow(/contains a "git" source.*Upgrade @tanstack\/intent/s) + }) + + it('reads missing locks and atomically writes canonical content', () => { + const path = join(root(), 'nested', 'intent.lock') + expect(readIntentLockfile(path)).toEqual({ status: 'missing' }) + writeIntentLockfile(path, { lockfileVersion: 1, sources: [] }) + writeIntentLockfile(path, { + lockfileVersion: 1, + sources: [{ kind: 'npm', id: 'example', skills: [] }], + }) + expect(readIntentLockfile(path)).toEqual({ + status: 'found', + lockfile: { + lockfileVersion: 1, + sources: [{ kind: 'npm', id: 'example', skills: [] }], + }, + }) + expect(readFileSync(path, 'utf8')).toContain('"id": "example"') + }) +}) diff --git a/packages/intent/tests/released-config-compat.test.ts b/packages/intent/tests/released-config-compat.test.ts new file mode 100644 index 00000000..7b15b529 --- /dev/null +++ b/packages/intent/tests/released-config-compat.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, it } from 'vitest' +import { compileExcludePatterns } from '../src/core/excludes.js' +import { + ALLOW_ALL_NOTICE, + EMPTY_NOTE, + MIGRATION_NOTICE, + applySourcePolicy, + checkLoadAllowed, + compileSkillSourcePolicy, +} from '../src/core/source-policy.js' +import { parseSkillSources } from '../src/core/skill-sources.js' +import type { IntentPackage, SkillEntry } from '../src/shared/types.js' + +function skill(name: string): SkillEntry { + return { name, path: `/pkg/skills/${name}/SKILL.md`, description: name } +} + +function pkg( + name: string, + skillNames: Array, + kind: IntentPackage['kind'] = 'npm', +): IntentPackage { + return { + name, + version: '1.0.0', + intent: { version: 1, repo: 'owner/repo', docs: '' }, + skills: skillNames.map(skill), + packageRoot: `/root/node_modules/${name}`, + kind, + source: 'local', + } +} + +function config(value: unknown) { + return parseSkillSources(value) +} + +describe('released 0.3.6 package.json config shapes remain policy-compatible', () => { + it('keeps an absent intent.skills in migration show-all mode', () => { + const releasedIntent = { exclude: [] } + const packages = [pkg('alpha', ['a']), pkg('@scope/beta', ['b', 'c'])] + const sourcePolicy = compileSkillSourcePolicy(config(undefined)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { packages }, + { config: config(undefined), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([ + ['alpha', ['a']], + ['@scope/beta', ['b', 'c']], + ]) + expect(result.notices).toEqual([MIGRATION_NOTICE]) + expect( + checkLoadAllowed( + 'alpha#a', + { packageName: 'alpha', skillName: 'a' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + '@scope/beta#c', + { packageName: '@scope/beta', skillName: 'c' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + }) + + it('keeps intent.skills empty as deny-all', () => { + const releasedIntent = { skills: [], exclude: [] } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { packages: [pkg('alpha', ['a']), pkg('@scope/beta', ['b'])] }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect(result.packages).toEqual([]) + expect(result.notices).toEqual([EMPTY_NOTE]) + expect( + checkLoadAllowed( + 'alpha#a', + { packageName: 'alpha', skillName: 'a' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-not-listed') + }) + + it('keeps intent.skills star as allow-all', () => { + const releasedIntent = { skills: ['*'], exclude: [] } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { packages: [pkg('alpha', ['a']), pkg('@scope/beta', ['b', 'c'])] }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([ + ['alpha', ['a']], + ['@scope/beta', ['b', 'c']], + ]) + expect(result.notices).toEqual([ALLOW_ALL_NOTICE]) + expect( + checkLoadAllowed( + '@scope/beta#c', + { packageName: '@scope/beta', skillName: 'c' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + }) + + it('keeps a bare package name allowing every skill in that package', () => { + const releasedIntent = { skills: ['pkg'], exclude: [] } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { packages: [pkg('pkg', ['a', 'b']), pkg('other', ['c'])] }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([['pkg', ['a', 'b']]]) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: other. Add to opt in.', + ]) + expect( + checkLoadAllowed( + 'pkg#b', + { packageName: 'pkg', skillName: 'b' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + 'other#c', + { packageName: 'other', skillName: 'c' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-not-listed') + }) + + it('keeps a scoped package name allowing every skill in that package', () => { + const releasedIntent = { skills: ['@scope/pkg'], exclude: [] } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { + packages: [pkg('@scope/pkg', ['a', 'b']), pkg('@scope/other', ['c'])], + }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([['@scope/pkg', ['a', 'b']]]) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @scope/other. Add to opt in.', + ]) + expect( + checkLoadAllowed( + '@scope/pkg#a', + { packageName: '@scope/pkg', skillName: 'a' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + '@scope/other#c', + { packageName: '@scope/other', skillName: 'c' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-not-listed') + }) + + it('keeps a workspace-prefixed package entry kind-specific during discovery', () => { + const releasedIntent = { skills: ['workspace:pkg'], exclude: [] } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { + packages: [ + pkg('pkg', ['workspace-skill'], 'workspace'), + pkg('pkg', ['npm-skill']), + ], + }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.kind, + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([['workspace', 'pkg', ['workspace-skill']]]) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: pkg. Add to opt in.', + ]) + expect( + checkLoadAllowed( + 'pkg#workspace-skill', + { packageName: 'pkg', skillName: 'workspace-skill' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + }) + + it('keeps a scoped glob allowing every package in that scope', () => { + const releasedIntent = { skills: ['@scope/*'], exclude: [] } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/alpha', ['a']), + pkg('@scope/beta', ['b']), + pkg('@other/gamma', ['c']), + ], + }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([ + ['@scope/alpha', ['a']], + ['@scope/beta', ['b']], + ]) + expect(result.notices).toEqual([ + '1 discovered package ships skills but is not listed in intent.skills: @other/gamma. Add to opt in.', + ]) + expect( + checkLoadAllowed( + '@scope/beta#b', + { packageName: '@scope/beta', skillName: 'b' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + '@other/gamma#c', + { packageName: '@other/gamma', skillName: 'c' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-not-listed') + }) + + it('keeps a package-level exclude hiding the package', () => { + const releasedIntent = { skills: ['*'], exclude: ['blocked'] } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { packages: [pkg('allowed', ['a']), pkg('blocked', ['b'])] }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([['allowed', ['a']]]) + expect(result.notices).toEqual([ALLOW_ALL_NOTICE]) + expect( + checkLoadAllowed( + 'allowed#a', + { packageName: 'allowed', skillName: 'a' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + 'blocked#b', + { packageName: 'blocked', skillName: 'b' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-excluded') + }) + + it('keeps a skill-level exclude hiding one skill and leaving its siblings', () => { + const releasedIntent = { skills: ['pkg'], exclude: ['pkg#b'] } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { packages: [pkg('pkg', ['a', 'b', 'c'])] }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([['pkg', ['a', 'c']]]) + expect(result.notices).toEqual([]) + expect( + checkLoadAllowed( + 'pkg#a', + { packageName: 'pkg', skillName: 'a' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + 'pkg#b', + { packageName: 'pkg', skillName: 'b' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('skill-excluded') + }) + + it('keeps the released installer partial-selection shape equivalent to selecting one skill', () => { + const releasedIntent = { + skills: ['pkg'], + exclude: ['pkg#b', 'pkg#c'], + } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { packages: [pkg('pkg', ['a', 'b', 'c'])] }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([['pkg', ['a']]]) + expect(result.notices).toEqual([]) + expect( + checkLoadAllowed( + 'pkg#a', + { packageName: 'pkg', skillName: 'a' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + 'pkg#b', + { packageName: 'pkg', skillName: 'b' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('skill-excluded') + expect( + checkLoadAllowed( + 'pkg#c', + { packageName: 'pkg', skillName: 'c' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('skill-excluded') + }) + + it('keeps a realistic released config combining names, workspace entries, globs, and excludes', () => { + const releasedIntent = { + skills: ['plain', '@scope/*', 'workspace:local'], + exclude: ['plain#b', '@scope/blocked', '@scope/tools#dangerous'], + } + const sourcePolicy = compileSkillSourcePolicy(config(releasedIntent.skills)) + const excludeMatchers = compileExcludePatterns(releasedIntent.exclude) + const result = applySourcePolicy( + { + packages: [ + pkg('plain', ['a', 'b']), + pkg('@scope/alpha', ['x']), + pkg('@scope/tools', ['safe', 'dangerous']), + pkg('@scope/blocked', ['hidden']), + pkg('local', ['workspace-skill'], 'workspace'), + pkg('local', ['npm-skill']), + pkg('unlisted', ['z']), + ], + }, + { config: config(releasedIntent.skills), excludeMatchers }, + ) + + expect( + result.packages.map((intentPackage) => [ + intentPackage.kind, + intentPackage.name, + intentPackage.skills.map((entry) => entry.name), + ]), + ).toEqual([ + ['npm', 'plain', ['a']], + ['npm', '@scope/alpha', ['x']], + ['npm', '@scope/tools', ['safe']], + ['workspace', 'local', ['workspace-skill']], + ]) + expect(result.notices).toEqual([ + '2 discovered packages ship skills but are not listed in intent.skills: local, unlisted. Add to opt in.', + ]) + expect( + checkLoadAllowed( + 'plain#a', + { packageName: 'plain', skillName: 'a' }, + { sourcePolicy, excludeMatchers }, + ), + ).toBeNull() + expect( + checkLoadAllowed( + 'plain#b', + { packageName: 'plain', skillName: 'b' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('skill-excluded') + expect( + checkLoadAllowed( + '@scope/blocked#hidden', + { packageName: '@scope/blocked', skillName: 'hidden' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-excluded') + expect( + checkLoadAllowed( + '@scope/tools#dangerous', + { packageName: '@scope/tools', skillName: 'dangerous' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('skill-excluded') + expect( + checkLoadAllowed( + 'unlisted#z', + { packageName: 'unlisted', skillName: 'z' }, + { sourcePolicy, excludeMatchers }, + )?.code, + ).toBe('package-not-listed') + }) +}) diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 1ffb28e6..4388366f 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -88,6 +88,7 @@ describe('resolveSkillUse', () => { expect(resolveSkillUse('@tanstack/query#core', scanResult([pkg]))).toEqual({ conflict: null, + kind: 'npm', packageName: '@tanstack/query', packageRoot: 'node_modules/@tanstack/query', path: 'node_modules/@tanstack/query/skills/core/SKILL.md', diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 970016c3..f8e5ce73 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -1999,3 +1999,77 @@ describe('package manager detection', () => { expect(() => scanForIntents(root)).toThrow('Deno without node_modules') }) }) + +describe('discovered sources (characterization)', () => { + function writeSkillPackage(dir: string, name: string, skill: string): void { + createDir(dir, 'skills', skill) + writeJson(join(dir, 'package.json'), { + name, + version: '1.0.0', + intent: { version: 1, repo: `example/${skill}`, docs: 'docs/' }, + }) + writeSkillMd(join(dir, 'skills', skill), { + name: skill, + description: `${skill} guidance`, + }) + } + + function discovered(): Array { + return scanForIntents(root) + .packages.map((pkg) => `${pkg.kind}:${pkg.name}`) + .sort() + } + + it('finds direct and transitive dependencies in a hoisted layout', () => { + writeFileSync(join(root, 'package-lock.json'), '{}') + writeJson(join(root, 'package.json'), { + name: 'consumer', + private: true, + dependencies: { '@scope/direct': '1.0.0' }, + }) + + const direct = createDir(root, 'node_modules', '@scope', 'direct') + writeSkillPackage(direct, '@scope/direct', 'direct-skill') + writeJson(join(direct, 'package.json'), { + name: '@scope/direct', + version: '1.0.0', + intent: { version: 1, repo: 'example/direct', docs: 'docs/' }, + dependencies: { transitive: '1.0.0' }, + }) + + writeSkillPackage( + createDir(root, 'node_modules', 'transitive'), + 'transitive', + 'transitive-skill', + ) + + expect(discovered()).toEqual(['npm:@scope/direct', 'npm:transitive']) + }) + + it('does not surface the workspace root as a skill source', () => { + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeSkillPackage(root, 'my-repo', 'root-skill') + createDir(root, 'node_modules') + + expect(discovered()).toEqual([]) + }) + + it('finds a workspace package reached through its node_modules link', () => { + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, 'package.json'), { name: 'my-repo', private: true }) + + const ui = createDir(root, 'packages', 'ui') + writeSkillPackage(ui, '@my/ui', 'ui-skill') + + createDir(root, 'node_modules', '@my') + symlinkSync(ui, join(root, 'node_modules', '@my', 'ui'), 'dir') + + expect(discovered()).toEqual(['workspace:@my/ui']) + }) +}) diff --git a/packages/intent/tests/session-catalog.test.ts b/packages/intent/tests/session-catalog.test.ts new file mode 100644 index 00000000..2149ac89 --- /dev/null +++ b/packages/intent/tests/session-catalog.test.ts @@ -0,0 +1,282 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildSessionCatalogue, + formatSessionCatalogue, + getSessionCatalogue, +} from '../src/session-catalog.js' +import { computeSkillContentHash } from '../src/core/lockfile/hash.js' +import { nodeReadFs } from '../src/shared/utils.js' +import type { IntentSkillList } from '../src/core/index.js' + +const roots: Array = [] + +function tempRoot(name: string): string { + const root = mkdtempSync(join(tmpdir(), name)) + roots.push(root) + return root +} + +function result( + skills: Array<{ use: string; description: string }>, + warnings: Array = [], + notices: Array = [], +): IntentSkillList { + return { + packageManager: 'pnpm', + skills: skills.map((skill) => ({ + ...skill, + packageName: skill.use.split('#')[0]!, + packageRoot: '/workspace/node_modules/package', + packageVersion: '1.0.0', + packageSource: 'local', + skillName: skill.use.split('#')[1]!, + })), + packages: [ + { + name: '@fixture/package', + version: '1.0.0', + source: 'local', + packageRoot: '/workspace/node_modules/package', + skillCount: skills.length, + }, + ], + hiddenSourceCount: 0, + hiddenSources: [], + warnings, + notices, + conflicts: [], + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('session catalogue formatting', () => { + it('sorts, bounds, and redacts agent context', () => { + const catalogue = buildSessionCatalogue( + result( + [ + { use: '@fixture/package#z', description: 'Z guidance' }, + { + use: '@fixture/package#a', + description: 'Read C:\\Users\\person\\secret.txt', + }, + ], + ['Warning from /Users/person/project/package.json'], + ), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue.skills.map((skill) => skill.id)).toEqual([ + '@fixture/package#a', + '@fixture/package#z', + ]) + expect(context).toContain('@fixture/package#a: Use @fixture/package#a') + expect(catalogue.skills[0]?.description).toBe('Use @fixture/package#a') + expect(context).not.toContain('person') + expect(Buffer.byteLength(context)).toBeLessThanOrEqual(8_000) + }) + + it('reports omitted skills within a UTF-8 byte budget', () => { + const catalogue = buildSessionCatalogue( + result( + Array.from({ length: 60 }, (_, index) => ({ + use: `@fixture/package#skill-${String(index).padStart(2, '0')}`, + description: `Guidance ${'界'.repeat(100)}`, + })), + ), + ) + const context = formatSessionCatalogue(catalogue, { maxBytes: 1_200 }) + + expect(catalogue.skills).toHaveLength(50) + expect(catalogue.totalSkillCount).toBe(60) + expect(Buffer.byteLength(context)).toBeLessThanOrEqual(1_200) + expect(context).toMatch(/additional skills omitted/) + }) + + it('preserves application route paths in descriptions', () => { + const catalogue = buildSessionCatalogue( + result([ + { + use: '@fixture/package#routes', + description: 'Use /users/:id and /posts/:slug routes', + }, + ]), + ) + + expect(formatSessionCatalogue(catalogue)).toContain( + 'Use /users/:id and /posts/:slug routes', + ) + }) + + it('excludes warnings and human-facing notices', () => { + const catalogue = buildSessionCatalogue( + result([], ['Agent warning'], ['Maintainer notice']), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue).toEqual({ skills: [], totalSkillCount: 0 }) + expect(context).not.toContain('Agent warning') + expect(context).not.toContain('Maintainer notice') + }) + + it('excludes warnings regardless of warning count', () => { + const catalogue = buildSessionCatalogue( + result( + [], + Array.from({ length: 12 }, (_, index) => `Warning ${index + 1}`), + ['Maintainer notice'], + ), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue).toEqual({ skills: [], totalSkillCount: 0 }) + expect(context).not.toContain('Warning 1') + expect(context).not.toContain('additional warnings omitted') + expect(context).not.toContain('Maintainer notice') + }) + + it('omits diagnostics when only notices are present', () => { + const catalogue = buildSessionCatalogue( + result([], [], ['Maintainer notice']), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue).toEqual({ skills: [], totalSkillCount: 0 }) + expect(context).not.toContain('Warnings:') + expect(context).not.toContain('Maintainer notice') + }) +}) + +describe('session catalogue cache', () => { + it('recomputes a persisted catalogue from an older schema version', async () => { + const root = tempRoot('intent-catalog-stale-schema-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { + result: result([ + { + use: '@fixture/package#core', + description: + discoveries === 1 ? 'Cached guidance' : 'Recomputed guidance', + }, + ]), + verification: [], + } + }, + } + const first = await getSessionCatalogue(options) + const persisted = JSON.parse(readFileSync(first.cachePath, 'utf8')) as { + schemaVersion: number + } + writeFileSync( + first.cachePath, + JSON.stringify({ + ...persisted, + schemaVersion: persisted.schemaVersion - 1, + }), + ) + + const recomputed = await getSessionCatalogue(options) + + expect(recomputed.cacheStatus).toBe('miss') + expect(recomputed.catalogue.skills).toEqual([ + { + id: '@fixture/package#core', + description: 'Recomputed guidance', + }, + ]) + expect(discoveries).toBe(2) + }) + + it('reuses valid content and refreshes after accepted skill drift', async () => { + const root = tempRoot('intent-catalog-cache-') + const cacheDir = join(root, 'cache') + const skillDir = join(root, 'skills', 'core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync(join(root, 'package.json'), '{}') + writeFileSync(join(skillDir, 'SKILL.md'), 'First\n') + let discoveries = 0 + let fileOpens = 0 + const readFs = { + ...nodeReadFs, + openSync: ( + ...args: Parameters> + ) => { + fileOpens += 1 + return nodeReadFs.openSync!(...args) + }, + } + + const get = () => + getSessionCatalogue({ + cacheDir, + root, + readFs, + discover: () => { + discoveries += 1 + return { + result: result([ + { use: '@fixture/package#core', description: 'Core guidance' }, + ]), + verification: [ + { + packageRoot: root, + skillPath: 'skills/core', + contentHash: computeSkillContentHash({ + packageRoot: root, + skillDir, + }), + }, + ], + } + }, + }) + + expect((await get()).cacheStatus).toBe('miss') + const opensAfterMiss = fileOpens + expect((await get()).cacheStatus).toBe('hit') + expect(fileOpens).toBeGreaterThan(opensAfterMiss) + writeFileSync(join(skillDir, 'SKILL.md'), 'Changed\n') + expect((await get()).cacheStatus).toBe('refresh') + expect(discoveries).toBe(2) + }) + + it('treats a malformed cache entry as a miss', async () => { + const root = tempRoot('intent-catalog-malformed-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: [] } + }, + } + const first = await getSessionCatalogue(options) + writeFileSync(first.cachePath, '{partial') + + expect((await getSessionCatalogue(options)).cacheStatus).toBe('miss') + expect(discoveries).toBe(2) + }) +}) diff --git a/packages/intent/tests/skill-path.test.ts b/packages/intent/tests/skill-path.test.ts new file mode 100644 index 00000000..ca971199 --- /dev/null +++ b/packages/intent/tests/skill-path.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { validateSkillPaths } from '../src/core/skill-path.js' + +describe('validateSkillPaths', () => { + it('accepts canonical package-relative directories and rejects unsafe forms', () => { + expect( + validateSkillPaths(['skills/fetching', 'skills/query-core']), + ).toEqual(['skills/fetching', 'skills/query-core']) + expect(() => validateSkillPaths(['../skills/fetching'])).toThrow() + expect(() => validateSkillPaths(['skills\\fetching'])).toThrow() + expect(() => validateSkillPaths(['C:/skills/fetching'])).toThrow() + expect(() => + validateSkillPaths(['//server/share/skills/fetching']), + ).toThrow() + expect(() => validateSkillPaths(['/skills/fetching'])).toThrow() + expect(() => validateSkillPaths(['skills//fetching'])).toThrow() + expect(() => validateSkillPaths(['skills/./fetching'])).toThrow() + expect(() => validateSkillPaths(['skills/\0fetching'])).toThrow() + expect(() => + validateSkillPaths(['skills/fetching', 'skills/fetching']), + ).toThrow() + }) +}) diff --git a/packages/intent/tests/skill-sources.test.ts b/packages/intent/tests/skill-sources.test.ts index d80ec2cf..da8dffca 100644 --- a/packages/intent/tests/skill-sources.test.ts +++ b/packages/intent/tests/skill-sources.test.ts @@ -255,12 +255,133 @@ describe('parseSkillSources — wildcard composition', () => { }) }) -describe('parseSkillSources — id validation', () => { - it('rejects skill-level granularity (#) in an npm entry', () => { - const error = expectParseError(['@scope/pkg#skill']) - expect(error.issues[0]?.message).toContain('skill-level granularity') +describe('parseSkillSources — skill-level entries', () => { + it('parses a skill selector on an npm source', () => { + expect(parseSkillSources(['@scope/pkg#fetching'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: '@scope/pkg#fetching', + id: '@scope/pkg', + kind: 'npm', + skill: 'fetching', + }, + ], + }) + }) + + it('parses a skill selector on a workspace source', () => { + expect(parseSkillSources(['workspace:@scope/pkg#core'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: 'workspace:@scope/pkg#core', + id: '@scope/pkg', + kind: 'workspace', + skill: 'core', + }, + ], + }) + }) + + it('parses a skill selector alongside a package glob', () => { + expect(parseSkillSources(['@scope/*#core'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: '@scope/*#core', + pattern: '@scope/*', + kind: 'npm', + skill: 'core', + }, + ], + }) + }) + + it('parses a glob in a skill selector', () => { + expect(parseSkillSources(['@scope/pkg#fetch-*'])).toEqual({ + mode: 'explicit', + sources: [ + { + raw: '@scope/pkg#fetch-*', + id: '@scope/pkg', + kind: 'npm', + skill: 'fetch-*', + }, + ], + }) + }) + + it('collapses an all-skills selector to a package-level entry', () => { + expect(parseSkillSources(['@scope/pkg#*', 'workspace:other#**'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: '@scope/pkg#*', id: '@scope/pkg', kind: 'npm' }, + { raw: 'workspace:other#**', id: 'other', kind: 'workspace' }, + ], + }) + }) + + it('keeps two skill entries for the same package distinct', () => { + expect(parseSkillSources(['pkg#a', 'pkg#b'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: 'pkg#a', id: 'pkg', kind: 'npm', skill: 'a' }, + { raw: 'pkg#b', id: 'pkg', kind: 'npm', skill: 'b' }, + ], + }) + }) + + it('dedups the same package and skill selector', () => { + expect(parseSkillSources(['pkg#a', ' pkg#a '])).toEqual({ + mode: 'explicit', + sources: [{ raw: 'pkg#a', id: 'pkg', kind: 'npm', skill: 'a' }], + }) + }) + + it('rejects a skill entry whose package is already allowed in full', () => { + const error = expectParseError(['@scope/*', '@scope/a#x']) + expect(error.issues[0]?.raw).toBe('@scope/a#x') + expect(error.issues[0]?.message).toContain('already allows every skill') + }) + + it('rejects a skill entry beside an exact package entry', () => { + const error = expectParseError(['pkg', 'pkg#a']) + expect(error.issues[0]?.message).toContain('already allows every skill') }) + it('keeps a skill entry beside a package entry of a different kind', () => { + expect(parseSkillSources(['workspace:pkg', 'pkg#a'])).toEqual({ + mode: 'explicit', + sources: [ + { raw: 'workspace:pkg', id: 'pkg', kind: 'workspace' }, + { raw: 'pkg#a', id: 'pkg', kind: 'npm', skill: 'a' }, + ], + }) + }) + + it('rejects a missing skill name after "#"', () => { + const error = expectParseError(['@scope/pkg#']) + expect(error.issues[0]?.message).toContain('missing a skill name') + }) + + it('rejects a missing package name before "#"', () => { + const error = expectParseError(['#skill']) + expect(error.issues[0]?.message).toContain('missing a package name') + }) + + it('rejects more than one "#"', () => { + const error = expectParseError(['pkg#a#b']) + expect(error.issues[0]?.message).toContain('more than one "#"') + }) + + it('rejects whitespace inside a skill name', () => { + const error = expectParseError(['pkg#a b']) + expect(error.issues[0]?.message).toContain('cannot contain whitespace') + }) +}) + +describe('parseSkillSources — id validation', () => { it('rejects internal whitespace in a package name', () => { const error = expectParseError(['a b']) expect(error.issues[0]?.message).toContain('cannot contain whitespace') diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 1f6e3554..7113d3ec 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -11,12 +11,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { compileExcludePatterns, compileWildcardPattern, + findPackageExcludeMatch, + findSkillExcludeMatch, } from '../src/core/excludes.js' import { ALLOW_ALL_NOTICE, EMPTY_NOTE, MIGRATION_NOTICE, applySourcePolicy, + checkLoadAllowed, + compileSkillSourcePolicy, readSkillSourcesConfig, } from '../src/core/source-policy.js' import { parseSkillSources } from '../src/core/skill-sources.js' @@ -234,6 +238,221 @@ describe('applySourcePolicy — allowlist matrix', () => { ) expect(input.skills.map((s) => s.name)).toEqual(['keep', 'drop']) }) + + it('records only exclusions permitted by intent.skills', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/allowed', ['keep', 'drop']), + pkg('@scope/concealed', ['secret']), + ], + }, + { + config: config(['@scope/allowed']), + excludeMatchers: compileExcludePatterns([ + '@scope/allowed#drop', + '@scope/concealed#secret', + ]), + }, + ) + + expect( + result.excludedSkills.map( + ({ package: package_, skill: entry, pattern }) => + `${package_.name}#${entry.name}:${pattern}`, + ), + ).toEqual(['@scope/allowed#drop:@scope/allowed#drop']) + }) +}) + +describe('compiled policy explanations', () => { + it('returns the package-level entry that permits a skill', () => { + const policy = compileSkillSourcePolicy(config(['@scope/a'])) + + expect(policy.explainPermits('@scope/a')).toMatchObject({ + permitted: true, + source: { raw: '@scope/a' }, + }) + expect(policy.explainPermitsSkill('@scope/a', 'x')).toMatchObject({ + permitted: true, + source: { raw: '@scope/a' }, + }) + }) + + it('returns the skill-level entry that permits a skill', () => { + const policy = compileSkillSourcePolicy(config(['@scope/a#x'])) + + expect(policy.explainPermitsSkill('@scope/a', 'x')).toMatchObject({ + permitted: true, + source: { raw: '@scope/a#x', skill: 'x' }, + }) + expect(policy.explainPermitsSkill('@scope/a', 'y')).toEqual({ + permitted: false, + source: null, + }) + }) + + it.each([ + [undefined, true], + [['*'], true], + [[], false], + ])( + 'explains policy mode %# without inventing a responsible entry', + (value, permitted) => { + const policy = compileSkillSourcePolicy(config(value)) + + expect(policy.explainPermits('@scope/a')).toEqual({ + permitted, + source: null, + }) + expect(policy.explainPermitsSkill('@scope/a', 'x')).toEqual({ + permitted, + source: null, + }) + }, + ) + + it('returns the exclusion pattern that matched', () => { + const matchers = compileExcludePatterns(['@scope/a', '@scope/b#fetch-*']) + + expect(findPackageExcludeMatch('@scope/a', matchers)?.pattern).toBe( + '@scope/a', + ) + expect( + findSkillExcludeMatch('@scope/b', 'fetch-one', matchers)?.pattern, + ).toBe('@scope/b#fetch-*') + expect(findSkillExcludeMatch('@scope/b', 'other', matchers)).toBeUndefined() + }) +}) + +function skillNames(packages: Array): Array> { + return packages.map((p) => p.skills.map((s) => s.name)) +} + +describe('applySourcePolicy — skill-level allowlist entries', () => { + it('surfaces only the named skill from a listed package', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x', 'y'])] }, + { config: config(['@scope/a#x']), excludeMatchers: [] }, + ) + expect(names(result.packages)).toEqual(['@scope/a']) + expect(skillNames(result.packages)).toEqual([['x']]) + }) + + it('reports skills a listed package ships that no entry allows', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x', 'y', 'z'])] }, + { config: config(['@scope/a#x']), excludeMatchers: [] }, + ) + + expect(result.hiddenSources).toEqual([ + { name: '@scope/a', skillCount: 2, hiddenSkills: ['y', 'z'] }, + ]) + expect(result.notices).toEqual([ + '2 skills from listed packages are not listed in intent.skills: @scope/a#y, @scope/a#z. Add to opt in.', + ]) + }) + + it('does not report excluded skills as hidden', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x', 'y'])] }, + { + config: config(['@scope/a']), + excludeMatchers: compileExcludePatterns(['@scope/a#y']), + }, + ) + + expect(result.hiddenSources).toEqual([]) + expect(result.notices).toEqual([]) + }) + + it('matches a glob in the skill selector', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['fetch-one', 'fetch-two', 'other'])] }, + { config: config(['@scope/a#fetch-*']), excludeMatchers: [] }, + ) + expect(skillNames(result.packages)).toEqual([['fetch-one', 'fetch-two']]) + }) + + it('keeps a skill entry kind-specific', () => { + const result = applySourcePolicy( + { + packages: [ + pkg('@scope/a', ['x'], 'workspace'), + pkg('@scope/a', ['x'], 'npm'), + ], + }, + { config: config(['workspace:@scope/a#x']), excludeMatchers: [] }, + ) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]?.kind).toBe('workspace') + }) + + it('matches a prefixed skill by its short alias', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/ui', ['ui/theme', 'ui/layout'])] }, + { config: config(['@scope/ui#theme']), excludeMatchers: [] }, + ) + expect(skillNames(result.packages)).toEqual([['ui/theme']]) + }) + + it('reports a skill entry that matched no discovered skill', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x'])] }, + { config: config(['@scope/a#nope']), excludeMatchers: [] }, + ) + expect(result.notices).toEqual([ + '1 skill from listed packages is not listed in intent.skills: @scope/a#x. Add to opt in.', + '"@scope/a#nope" is declared in intent.skills but was not discovered.', + ]) + }) + + it('still lets an exclude hide a skill that a skill entry allows', () => { + const result = applySourcePolicy( + { packages: [pkg('@scope/a', ['x', 'y'])] }, + { + config: config(['@scope/a#x']), + excludeMatchers: compileExcludePatterns(['@scope/a#x']), + }, + ) + expect(skillNames(result.packages)).toEqual([[]]) + }) +}) + +describe('checkLoadAllowed — skill-level allowlist entries', () => { + const use = '@scope/a#y' + const parsed = { packageName: '@scope/a', skillName: 'y' } + + it('allows a skill named by a skill-level entry', () => { + expect( + checkLoadAllowed( + '@scope/a#x', + { packageName: '@scope/a', skillName: 'x' }, + { + sourcePolicy: compileSkillSourcePolicy(config(['@scope/a#x'])), + excludeMatchers: [], + }, + ), + ).toBeNull() + }) + + it('refuses a skill the entry does not name, without claiming the package is unlisted', () => { + const refusal = checkLoadAllowed(use, parsed, { + sourcePolicy: compileSkillSourcePolicy(config(['@scope/a#x'])), + excludeMatchers: [], + }) + expect(refusal?.code).toBe('skill-not-listed') + expect(refusal?.message).toContain('"@scope/a#y"') + expect(refusal?.message).not.toContain('package "@scope/a" is not listed') + }) + + it('still refuses a package that is not listed at all', () => { + const refusal = checkLoadAllowed(use, parsed, { + sourcePolicy: compileSkillSourcePolicy(config(['@other/b#x'])), + excludeMatchers: [], + }) + expect(refusal?.code).toBe('package-not-listed') + }) }) describe('applySourcePolicy — permit-all and empty modes', () => { diff --git a/packages/intent/tests/sync.test.ts b/packages/intent/tests/sync.test.ts new file mode 100644 index 00000000..584dba78 --- /dev/null +++ b/packages/intent/tests/sync.test.ts @@ -0,0 +1,253 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { updateIntentGitignore } from '../src/commands/sync/gitignore.js' +import { reconcileManagedLinks } from '../src/commands/sync/links.js' +import { wireIntentSyncPrepare } from '../src/commands/sync/prepare.js' +import { + parseInstallState, + readInstallState, + serializeInstallState, + writeInstallState, +} from '../src/commands/sync/state.js' +import { + createSyncAliases, + resolveSyncTargetDirectories, +} from '../src/commands/sync/targets.js' + +const tempDirs: Array = [] + +function tempRoot(name: string): string { + const root = mkdtempSync(join(tmpdir(), name)) + tempDirs.push(root) + return root +} + +afterEach(() => { + for (const root of tempDirs.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +describe('sync targets and aliases', () => { + it('deduplicates github and vscode target directories deterministically', () => { + expect( + resolveSyncTargetDirectories('/project', ['vscode', 'github', 'agents']), + ).toEqual([ + { id: 'agents', path: join('/project', '.agents/skills') }, + { id: 'vscode', path: join('/project', '.github/skills') }, + ]) + }) + + it('normalizes aliases and hashes every collision', () => { + const aliases = createSyncAliases([ + { kind: 'npm', id: '@scope/a.b', skill: 'one/two' }, + { kind: 'npm', id: 'scope/a-b', skill: 'one/two' }, + { kind: 'workspace', id: '@scope/pkg', skill: 'core' }, + ]) + expect(aliases.map((entry) => entry.alias)).toEqual([ + expect.stringMatching(/^npm-scope-a-b-one-two-[a-f0-9]{8}$/), + expect.stringMatching(/^npm-scope-a-b-one-two-[a-f0-9]{8}$/), + 'workspace-scope-pkg-core', + ]) + expect(aliases[0]!.alias).not.toBe(aliases[1]!.alias) + }) +}) + +describe('sync state', () => { + const state = { + version: 1 as const, + entries: [ + { + targetDirectory: '.github/skills', + path: '.github/skills/b', + alias: 'b', + source: { kind: 'npm' as const, id: 'pkg' }, + skillPath: 'skills/b', + linkTarget: '/source/b', + }, + { + targetDirectory: '.github/skills', + path: '.github/skills/a', + alias: 'a', + source: { kind: 'npm' as const, id: 'pkg' }, + skillPath: 'skills/a', + linkTarget: '/source/a', + }, + ], + } + + it('strictly parses and deterministically serializes entries', () => { + const serialized = serializeInstallState(state) + expect(serialized.indexOf('"path": ".github/skills/a"')).toBeLessThan( + serialized.indexOf('"path": ".github/skills/b"'), + ) + expect( + parseInstallState(serialized)?.entries.map((entry) => entry.alias), + ).toEqual(['a', 'b']) + expect( + parseInstallState('{"version":1,"entries":[],"extra":true}'), + ).toBeNull() + }) + + it('writes atomically only when state changes and reports malformed state', () => { + const root = tempRoot('intent-sync-state-') + expect(writeInstallState(root, state)).toBe(true) + expect(writeInstallState(root, state)).toBe(false) + expect(readInstallState(root)).toMatchObject({ status: 'found' }) + writeFileSync(join(root, '.intent', 'install-state.json'), '{bad', 'utf8') + expect(readInstallState(root)).toEqual({ status: 'malformed' }) + }) +}) + +describe('managed sync links', () => { + function expected(root: string) { + const packageRoot = join(root, 'node_modules', 'pkg') + const source = join(packageRoot, 'skills', 'core') + const path = join(root, '.github', 'skills', 'npm-pkg-core') + mkdirSync(source, { recursive: true }) + return { + path, + targetDirectory: '.github/skills', + alias: 'npm-pkg-core', + source: { kind: 'npm' as const, id: 'pkg' }, + skillPath: 'skills/core', + sourceDirectory: source, + packageRoot, + } + } + + it('creates, leaves unchanged, repairs owned links, and cleans owned stale links', () => { + const root = tempRoot('intent-sync-links-') + const link = expected(root) + const first = reconcileManagedLinks({ + dryRun: false, + expected: [link], + stateResult: { status: 'missing' }, + }) + expect(first.created).toEqual([link.path]) + expect(lstatSync(link.path).isSymbolicLink()).toBe(true) + const second = reconcileManagedLinks({ + dryRun: false, + expected: [link], + stateResult: { + status: 'found', + state: { version: 1, entries: first.entries }, + }, + }) + expect(second.unchanged).toEqual([link.path]) + unlinkSync(link.path) + const repaired = reconcileManagedLinks({ + dryRun: false, + expected: [link], + stateResult: { + status: 'found', + state: { version: 1, entries: first.entries }, + }, + }) + expect(repaired.created).toEqual([link.path]) + const cleanup = reconcileManagedLinks({ + dryRun: false, + expected: [], + stateResult: { + status: 'found', + state: { version: 1, entries: repaired.entries }, + }, + }) + expect(cleanup.removed).toEqual([link.path]) + expect(existsSync(link.path)).toBe(false) + }) + + it('does not replace unmanaged links and makes dry runs non-writing', () => { + const root = tempRoot('intent-sync-conflict-') + const link = expected(root) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + symlinkSync(join(root, 'somewhere-else'), link.path, 'dir') + const conflict = reconcileManagedLinks({ + dryRun: false, + expected: [link], + stateResult: { status: 'missing' }, + }) + expect(conflict.conflicts).toEqual([link.path]) + const dryRunLink = { + ...link, + path: join(root, '.github', 'skills', 'dry-run'), + } + const dryRun = reconcileManagedLinks({ + dryRun: true, + expected: [dryRunLink], + stateResult: { status: 'missing' }, + }) + expect(dryRun.created).toEqual([dryRunLink.path]) + expect(existsSync(dryRunLink.path)).toBe(false) + }) + + it('treats an unreadable owned link target as a conflict', () => { + const root = tempRoot('intent-sync-unreadable-') + const link = expected(root) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + symlinkSync(join(root, 'missing'), link.path, 'dir') + + const result = reconcileManagedLinks({ + dryRun: false, + expected: [link], + stateResult: { + status: 'found', + state: { + version: 1, + entries: [ + { + targetDirectory: link.targetDirectory, + path: link.path, + alias: link.alias, + source: link.source, + skillPath: link.skillPath, + linkTarget: link.sourceDirectory, + }, + ], + }, + }, + }) + + expect(result.conflicts).toEqual([link.path]) + expect(result.repaired).toEqual([]) + }) +}) + +describe('sync managed text', () => { + it('updates only the exact gitignore block while preserving CRLF', () => { + const updated = updateIntentGitignore('node_modules/\r\n', [ + '.github/skills/a', + '.intent/install-state.json', + ]) + expect(updated).toContain('node_modules/\r\n# intent skill links:start\r\n') + expect(updated).toContain('.github/skills/a\r\n') + expect( + updateIntentGitignore(updated, [ + '.github/skills/a', + '.intent/install-state.json', + ]), + ).toBe(updated) + }) + + it('adds and preserves an idempotent prepare sync command', () => { + expect(wireIntentSyncPrepare('{"name":"app"}\n')).toContain( + '"prepare": "intent sync"', + ) + expect(wireIntentSyncPrepare('{"scripts":{"prepare":"build"}}')).toContain( + 'build && intent sync', + ) + const existing = + '{\r\n "scripts": { "prepare": "build && intent sync" }\r\n}\r\n' + expect(wireIntentSyncPrepare(existing)).toBe(existing) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 546d7aeb..93c363cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: packages/intent: dependencies: + '@clack/prompts': + specifier: 1.7.0 + version: 1.7.0 cac: specifier: ^6.7.14 version: 6.7.14 @@ -191,6 +194,14 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@codspeed/core@5.5.0': resolution: {integrity: sha512-5FbjNlxSVOfemB85orEecikZiTz0C8aZYUfCkt5HY6QLLd1mqkrHAfekEJw0gkHcgCjNgD6DVp2TXm0V/xtt4w==} @@ -2218,9 +2229,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3394,6 +3414,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4136,6 +4159,18 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@codspeed/core@5.5.0': dependencies: axios: 1.13.2 @@ -6043,8 +6078,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7355,6 +7400,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} smol-toml@1.6.1: {}