From 9a04a8b3e5436b9b929e8c147c19a370662b39a3 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 19 Jul 2026 20:39:35 -0700 Subject: [PATCH 01/37] fix(intent): remove session-wide hook edit gate --- packages/intent/src/cli.ts | 2 +- packages/intent/src/hooks/adapters.ts | 10 +- packages/intent/src/hooks/agents/claude.ts | 25 --- packages/intent/src/hooks/agents/codex.ts | 25 --- packages/intent/src/hooks/agents/copilot.ts | 19 --- packages/intent/src/hooks/install.ts | 175 ++++---------------- packages/intent/src/hooks/policy.ts | 113 ------------- packages/intent/src/hooks/types.ts | 20 --- packages/intent/tests/hooks-install.test.ts | 152 +++++------------ packages/intent/tests/hooks.test.ts | 137 --------------- 10 files changed, 76 insertions(+), 602 deletions(-) delete mode 100644 packages/intent/src/hooks/agents/claude.ts delete mode 100644 packages/intent/src/hooks/agents/codex.ts delete mode 100644 packages/intent/src/hooks/agents/copilot.ts delete mode 100644 packages/intent/src/hooks/policy.ts delete mode 100644 packages/intent/tests/hooks.test.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index d317b9d7..065aa4dc 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -150,7 +150,7 @@ function createCli(): CAC { 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]', diff --git a/packages/intent/src/hooks/adapters.ts b/packages/intent/src/hooks/adapters.ts index 472b36e6..604f2643 100644 --- a/packages/intent/src/hooks/adapters.ts +++ b/packages/intent/src/hooks/adapters.ts @@ -36,13 +36,13 @@ export const HOOK_AGENT_ADAPTERS: Record = { ? join(root, '.claude', 'settings.json') : join(homeDir, '.claude', 'settings.json'), scriptPath: project - ? join(root, HOOK_SCRIPT_DIR, 'intent-claude-gate.mjs') + ? join(root, HOOK_SCRIPT_DIR, 'intent-claude-catalog.mjs') : join( homeDir, '.tanstack', 'intent', 'hooks', - 'intent-claude-gate.mjs', + 'intent-claude-catalog.mjs', ), } }, @@ -58,13 +58,13 @@ export const HOOK_AGENT_ADAPTERS: Record = { ? join(root, '.codex', 'hooks.json') : join(homeDir, '.codex', 'hooks.json'), scriptPath: project - ? join(root, HOOK_SCRIPT_DIR, 'intent-codex-gate.mjs') + ? join(root, HOOK_SCRIPT_DIR, 'intent-codex-catalog.mjs') : join( homeDir, '.tanstack', 'intent', 'hooks', - 'intent-codex-gate.mjs', + 'intent-codex-catalog.mjs', ), } }, @@ -84,7 +84,7 @@ export const HOOK_AGENT_ADAPTERS: Record = { '.tanstack', 'intent', 'hooks', - 'intent-copilot-gate.mjs', + 'intent-copilot-catalog.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..37697428 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -1,11 +1,16 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { homedir } from 'node:os' import { dirname, relative } from 'node:path' 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 +32,6 @@ export type InstallHooksOptions = { scope?: string } -const GATE_STATUS_MESSAGE = 'Checking Intent guidance' const CATALOG_STATUS_MESSAGE = 'Loading Intent skill catalog' export function runInstallHooks({ @@ -66,21 +70,13 @@ export function buildHookRunnerScript( '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 { 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() @@ -92,24 +88,11 @@ 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 (!isSessionStartEvent(event)) return - 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())) + const additionalContext = await createSessionCatalogContext(rootForEvent(event)) + if (additionalContext) { + process.stdout.write(JSON.stringify(sessionStartOutput(additionalContext))) } } @@ -216,87 +199,6 @@ function sessionStartOutput(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, - }, - } -} ` } @@ -357,6 +259,7 @@ function installAgentHook({ scriptPath, buildHookRunnerScript(agent, catalogCommand), ) + removeLegacyGateScript(scriptPath) const configStatus = updateJsonConfig(configPath, (config) => upsertAdapterHooks({ config, @@ -440,7 +343,7 @@ function upsertClaudeHooks( command: 'node', args: [ project - ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs' + ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-catalog.mjs' : scriptPath, ], timeout: 10, @@ -448,22 +351,7 @@ function upsertClaudeHooks( }, ], }) - 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 } } @@ -479,26 +367,14 @@ function upsertCodexHooks( { type: 'command', command: project - ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' + ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-catalog.mjs"' : `node ${quoteShell(scriptPath)}`, 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 } } @@ -510,9 +386,7 @@ function upsertCopilotHooks( hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { command: `node ${quoteShell(scriptPath)}`, }) - hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { - command: `node ${quoteShell(scriptPath)}`, - }) + hooks.PreToolUse = removeIntentHooks(arrayValue(hooks.PreToolUse)) return { ...config, hooks } } @@ -520,7 +394,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 { @@ -548,11 +426,16 @@ function isIntentHook(value: unknown): boolean { } function isIntentGateScriptReference(value: string): boolean { - return /(?:^|[\s"'/])(?:old-)?intent-(claude|codex|copilot)-gate\.mjs(?:$|[?#\s"'])/i.test( + return /(?:^|[\s"'/])(?:old-)?intent-(claude|codex|copilot)-(?:gate|catalog)\.mjs(?:$|[?#\s"'])/i.test( value, ) } +function removeLegacyGateScript(scriptPath: string): void { + const legacyPath = scriptPath.replace(/-catalog\.mjs$/, '-gate.mjs') + if (legacyPath !== scriptPath) rmSync(legacyPath, { force: true }) +} + function updateJsonConfig( filePath: string, update: (config: Record) => Record, 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/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 82e89853..c0cc0bcb 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -45,7 +45,7 @@ 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-') const results = runInstallHooks({ root, scope: 'project' }) @@ -75,37 +75,24 @@ describe('hook installer', () => { ) 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'], + args: ['${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-catalog.mjs'], 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', + '.intent/hooks/intent-codex-catalog.mjs', ) + expect(codexConfig.hooks.PreToolUse).toEqual([]) expect( - existsSync(join(root, '.intent', 'hooks', 'intent-claude-gate.mjs')), + existsSync(join(root, '.intent', 'hooks', 'intent-claude-catalog.mjs')), ).toBe(true) expect( - existsSync(join(root, '.intent', 'hooks', 'intent-codex-gate.mjs')), + existsSync(join(root, '.intent', 'hooks', 'intent-codex-catalog.mjs')), ).toBe(true) }) @@ -125,12 +112,10 @@ 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).toContain('intent-copilot-catalog.mjs') + expect(config.hooks.PreToolUse).toEqual([]) expect( existsSync( join( @@ -138,7 +123,7 @@ describe('hook installer', () => { '.tanstack', 'intent', 'hooks', - 'intent-copilot-gate.mjs', + 'intent-copilot-catalog.mjs', ), ), ).toBe(true) @@ -147,7 +132,15 @@ describe('hook installer', () => { it('updates only the Intent hook group 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 +173,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(false) expect(second[0]).toMatchObject({ status: 'unchanged' }) }) @@ -217,13 +211,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 +249,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,29 +287,27 @@ 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', () => { + it('builds a catalogue-only runner script', () => { 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('permissionDecision') + expect(script).not.toContain('PreToolUse') + expect(script).not.toContain('tanstack-intent-hooks') expect(script).not.toContain('@tanstack/intent/core') expect(script).not.toContain('createRequire') }) - it('runs the generated gate script through the load then edit cycle', () => { + it('ignores edit events before and after a load command', () => { const root = tempRoot('intent-hooks-runner-') - const scriptPath = join(root, 'intent-claude-gate.mjs') + const scriptPath = join(root, 'intent-claude-catalog.mjs') writeFileSync(scriptPath, buildHookRunnerScript('claude')) const beforeLoad = runHookScript(scriptPath, { @@ -348,9 +333,7 @@ describe('hook installer', () => { }) expect(beforeLoad.status).toBe(0) - expect(JSON.parse(beforeLoad.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) + expect(beforeLoad.stdout).toBe('') expect(load.status).toBe(0) expect(load.stdout).toBe('') expect(afterLoad.status).toBe(0) @@ -366,7 +349,7 @@ describe('hook installer', () => { root, '.intent', 'hooks', - `intent-${agent}-gate.mjs`, + `intent-${agent}-catalog.mjs`, ) mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) writeFileSync(scriptPath, buildHookRunnerScript(agent, catalogCommand)) @@ -396,10 +379,15 @@ describe('hook installer', () => { }, ) - it('does not unlock edits after session catalog context', () => { + it('does not emit an edit decision after session catalogue context', () => { const root = tempRoot('intent-hooks-session-catalog-gate-') const catalogCommand = writeFakeIntentListCommand(root) - const scriptPath = join(root, '.intent', 'hooks', 'intent-claude-gate.mjs') + const scriptPath = join( + root, + '.intent', + 'hooks', + 'intent-claude-catalog.mjs', + ) mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) writeFileSync(scriptPath, buildHookRunnerScript('claude', catalogCommand)) @@ -422,9 +410,7 @@ describe('hook installer', () => { hookSpecificOutput: { hookEventName: 'SessionStart' }, }) expect(edit.status).toBe(0) - expect(JSON.parse(edit.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) + expect(edit.stdout).toBe('') }) it('continues silently when session catalog loading fails', () => { @@ -450,62 +436,6 @@ describe('hook installer', () => { 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', () => { expect( formatHookInstallResult({ @@ -555,5 +485,5 @@ console.log(JSON.stringify({ } function quoteShell(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'` + return JSON.stringify(value) } 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() - }) -}) From 58a8161de0f37dc29222ec95cdbb5fa1b73cbb60 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 19 Jul 2026 21:13:47 -0700 Subject: [PATCH 02/37] feat(lock): add per-skill lock primitives --- benchmarks/intent/lockfile-hash.bench.ts | 25 ++ packages/intent/src/core/lockfile/hash.ts | 230 ++++++++++++++++++ .../intent/src/core/lockfile/lockfile-diff.ts | 115 +++++++++ .../src/core/lockfile/lockfile-state.ts | 102 ++++++++ packages/intent/src/core/lockfile/lockfile.ts | 194 +++++++++++++++ packages/intent/src/core/skill-path.ts | 39 +++ packages/intent/tests/hash.test.ts | 133 ++++++++++ packages/intent/tests/lockfile-diff.test.ts | 97 ++++++++ packages/intent/tests/lockfile-state.test.ts | 127 ++++++++++ packages/intent/tests/lockfile.test.ts | 122 ++++++++++ packages/intent/tests/skill-path.test.ts | 23 ++ 11 files changed, 1207 insertions(+) create mode 100644 benchmarks/intent/lockfile-hash.bench.ts create mode 100644 packages/intent/src/core/lockfile/hash.ts create mode 100644 packages/intent/src/core/lockfile/lockfile-diff.ts create mode 100644 packages/intent/src/core/lockfile/lockfile-state.ts create mode 100644 packages/intent/src/core/lockfile/lockfile.ts create mode 100644 packages/intent/src/core/skill-path.ts create mode 100644 packages/intent/tests/hash.test.ts create mode 100644 packages/intent/tests/lockfile-diff.test.ts create mode 100644 packages/intent/tests/lockfile-state.test.ts create mode 100644 packages/intent/tests/lockfile.test.ts create mode 100644 packages/intent/tests/skill-path.test.ts 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/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts new file mode 100644 index 00000000..76ce8a4a --- /dev/null +++ b/packages/intent/src/core/lockfile/hash.ts @@ -0,0 +1,230 @@ +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 } + +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(Buffer.from(String(path.length), 'ascii')) + hash.update(Buffer.from([0])) + hash.update(path) + hash.update(Buffer.from([0])) + hash.update(Buffer.from(String(entry.content.length), 'ascii')) + hash.update(Buffer.from([0])) + hash.update(entry.content) + hash.update(Buffer.from([0])) + } + 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 = resolveInPackage( + fs, + resolve(packageRoot), + fs.realpathSync(resolve(packageRoot)), + 'package root', + ) + 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 entries: Array = [] + let entryCount = 0 + let 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) + totalBytes += content.length + if (totalBytes > HASH_LIMITS.maxTotalBytes) + throw new Error('Hash total size limit exceeded.') + if (entries.length + 1 > HASH_LIMITS.maxFileCount) + throw new Error('Hash file count limit exceeded.') + 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), + )) { + entryCount += 1 + if (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(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..3dcc9812 --- /dev/null +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -0,0 +1,194 @@ +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' +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 (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( + `Invalid intent.lock source kind: ${String(source.kind)}.`, + ) + } + 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 { + const directory = dirname(filePath) + mkdirSync(directory, { recursive: true }) + const tempPath = join( + directory, + `.${basename(filePath)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`, + ) + try { + writeFileSync(tempPath, serializeIntentLockfile(lockfile), 'utf8') + renameSync(tempPath, filePath) + } finally { + try { + if (existsSync(tempPath)) unlinkSync(tempPath) + } catch {} + } +} 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/tests/hash.test.ts b/packages/intent/tests/hash.test.ts new file mode 100644 index 00000000..498bcf1d --- /dev/null +++ b/packages/intent/tests/hash.test.ts @@ -0,0 +1,133 @@ +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('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/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..8dbea33e --- /dev/null +++ b/packages/intent/tests/lockfile.test.ts @@ -0,0 +1,122 @@ +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('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/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() + }) +}) From 543b83afdcc6278947db24d138d200db42ed109f Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 19 Jul 2026 21:54:35 -0700 Subject: [PATCH 03/37] feat(catalog): add lock-aware cached skill catalogues --- benchmarks/intent/catalog.bench.ts | 56 +++ knip.json | 8 +- packages/intent/package.json | 8 +- packages/intent/src/catalog-lock.ts | 107 +++++ packages/intent/src/catalog.ts | 122 +++++ packages/intent/src/cli.ts | 16 + packages/intent/src/commands/catalog.ts | 26 + packages/intent/src/discovery/scanner.ts | 4 + packages/intent/src/session-catalog.ts | 449 ++++++++++++++++++ packages/intent/src/shared/local-path.ts | 69 +++ packages/intent/tests/catalog-api.test.ts | 143 ++++++ packages/intent/tests/local-path.test.ts | 24 + packages/intent/tests/session-catalog.test.ts | 188 ++++++++ 13 files changed, 1217 insertions(+), 3 deletions(-) create mode 100644 benchmarks/intent/catalog.bench.ts create mode 100644 packages/intent/src/catalog-lock.ts create mode 100644 packages/intent/src/catalog.ts create mode 100644 packages/intent/src/commands/catalog.ts create mode 100644 packages/intent/src/session-catalog.ts create mode 100644 packages/intent/src/shared/local-path.ts create mode 100644 packages/intent/tests/catalog-api.test.ts create mode 100644 packages/intent/tests/local-path.test.ts create mode 100644 packages/intent/tests/session-catalog.test.ts diff --git a/benchmarks/intent/catalog.bench.ts b/benchmarks/intent/catalog.bench.ts new file mode 100644 index 00000000..349ab83b --- /dev/null +++ b/benchmarks/intent/catalog.bench.ts @@ -0,0 +1,56 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, bench, describe } from 'vitest' +import { + createCliRunner, + createConsoleSilencer, + createTempDir, + writeFile, + writeJson, + writePackage, +} from './helpers.js' + +const consoleSilencer = createConsoleSilencer() +const root = createTempDir('catalog') +const runner = createCliRunner({ cwd: root }) +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: { '@bench/query': '1.0.0' }, + }) + writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: "9.0"\n') + writePackage(join(root, 'node_modules'), '@bench/query', '1.0.0', { + skills: ['queries', 'mutations', 'invalidation'], + }) + 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/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..92dc6d7a 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": { @@ -39,8 +43,8 @@ }, "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:integration": "vitest run tests/integration/", "test:types": "tsc --noEmit", 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 065aa4dc..8aea9de6 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -7,6 +7,7 @@ import { fail, isCliFailure } from './shared/cli-error.js' import type { CAC } from 'cac' 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' @@ -42,6 +43,21 @@ function createCli(): CAC { 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]', 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/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 622113ce..6abf0df0 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -166,6 +166,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 ?? ''}` } diff --git a/packages/intent/src/session-catalog.ts b/packages/intent/src/session-catalog.ts new file mode 100644 index 00000000..11048520 --- /dev/null +++ b/packages/intent/src/session-catalog.ts @@ -0,0 +1,449 @@ +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 = 1 +const DEFAULT_MAX_CONTEXT_BYTES = 8_000 +const DEFAULT_MAX_SKILLS = 50 +const MIN_CONTEXT_BYTES = 512 +const MAX_DESCRIPTION_LENGTH = 180 +const MAX_WARNING_COUNT = 10 +const MAX_WARNING_LENGTH = 300 +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 + totalWarningCount: number + warnings: Array +} + +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)) + const allWarnings = [...result.warnings, ...result.notices] + .map(normalizeWhitespace) + .filter((warning) => warning && !containsLocalPath(warning)) + .map((warning) => truncateText(warning, MAX_WARNING_LENGTH)) + + return { + skills: allSkills.slice(0, maxSkills), + totalSkillCount: allSkills.length, + totalWarningCount: allWarnings.length, + warnings: allWarnings.slice(0, MAX_WARNING_COUNT), + } +} + +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 fitWarnings( + ['No available Intent skills.'], + catalogue.warnings, + catalogue.totalWarningCount, + maxBytes, + ) + } + + 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 fitWarnings( + lines, + catalogue.warnings, + catalogue.totalWarningCount, + maxBytes, + ) +} + +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 fitWarnings( + lines: Array, + warnings: Array, + totalWarningCount: number, + maxBytes: number, +): string { + const warningLines: Array = [] + + for (const warning of warnings) { + const nextWarningLines = [...warningLines, `- ${warning}`] + const omitted = totalWarningCount - nextWarningLines.length + const candidateLines = [ + ...lines, + '', + 'Warnings:', + ...nextWarningLines, + ...(omitted > 0 ? [formatOmittedWarnings(omitted)] : []), + ] + if (!fits(candidateLines, maxBytes)) break + warningLines.push(nextWarningLines.at(-1)!) + } + + const omitted = totalWarningCount - warningLines.length + if (warningLines.length === 0 && omitted === 0) return lines.join('\n') + + const outputLines = [ + ...lines, + '', + 'Warnings:', + ...warningLines, + ...(omitted > 0 ? [formatOmittedWarnings(omitted)] : []), + ] + return fits(outputLines, maxBytes) ? outputLines.join('\n') : lines.join('\n') +} + +function formatOmittedWarnings(count: number): string { + return `- ${count} additional ${count === 1 ? 'warning' : 'warnings'} omitted.` +} + +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' && + typeof catalogue.totalWarningCount === 'number' && + Array.isArray(catalogue.warnings) && + catalogue.warnings.every((warning) => typeof warning === 'string') + ) +} + +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/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/tests/catalog-api.test.ts b/packages/intent/tests/catalog-api.test.ts new file mode 100644 index 00000000..85e3ec2e --- /dev/null +++ b/packages/intent/tests/catalog-api.test.ts @@ -0,0 +1,143 @@ +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('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/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/session-catalog.test.ts b/packages/intent/tests/session-catalog.test.ts new file mode 100644 index 00000000..f0929d30 --- /dev/null +++ b/packages/intent/tests/session-catalog.test.ts @@ -0,0 +1,188 @@ +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 { + 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 = [], +): 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(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(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', + ) + }) +}) + +describe('session catalogue cache', () => { + 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) + }) +}) From 2d68b6b620d7f623a033a85ba6281e2c251da438 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 19 Jul 2026 22:16:07 -0700 Subject: [PATCH 04/37] feat(install): add consumer installation planning --- benchmarks/intent/install-plan.bench.ts | 47 +++ .../intent/src/commands/install/config.ts | 253 ++++++++++++++ packages/intent/src/commands/install/plan.ts | 310 ++++++++++++++++++ packages/intent/tests/install-config.test.ts | 128 ++++++++ packages/intent/tests/install-plan.test.ts | 214 ++++++++++++ 5 files changed, 952 insertions(+) create mode 100644 benchmarks/intent/install-plan.bench.ts create mode 100644 packages/intent/src/commands/install/config.ts create mode 100644 packages/intent/src/commands/install/plan.ts create mode 100644 packages/intent/tests/install-config.test.ts create mode 100644 packages/intent/tests/install-plan.test.ts 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/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts new file mode 100644 index 00000000..91ff4cd5 --- /dev/null +++ b/packages/intent/src/commands/install/config.ts @@ -0,0 +1,253 @@ +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' | 'map' +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']), + map: new Set(INSTALL_TARGETS.map((target) => 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 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/plan.ts b/packages/intent/src/commands/install/plan.ts new file mode 100644 index 00000000..434cf84b --- /dev/null +++ b/packages/intent/src/commands/install/plan.ts @@ -0,0 +1,310 @@ +import { + compileExcludePatterns, + isPackageExcluded, + isSkillExcluded, +} from '../../core/excludes.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { isSourcePermitted } from '../../core/source-policy.js' +import type { + IntentLockfileSource, + ReadIntentLockfileResult, +} from '../../core/lockfile/lockfile.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 } + +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 + }> +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function sourceEntry(pkg: IntentPackage): string { + return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name +} + +function skillId(pkg: IntentPackage, skill: SkillEntry): string { + return `${sourceEntry(pkg)}#${skill.name}` +} + +function skillExclude(pkg: IntentPackage, skill: SkillEntry): string { + return `${pkg.name}#${skill.name}` +} + +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 "${skillId(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/*".', + ) + } +} + +export function buildSkillSelectionPlan( + discovered: ReadonlyArray, + selection: SkillSelection, +): SkillSelectionPlan { + const packages = sortedPackages(discovered) + assertUniqueDiscovery(packages) + const kindsByName = new Map>() + for (const pkg of packages) { + const kinds = kindsByName.get(pkg.name) ?? new Set() + kinds.add(pkg.kind) + kindsByName.set(pkg.name, kinds) + } + 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) => skillId(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 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' && + sortedSkills(pkg).some((skill) => selected.has(skillId(pkg, skill)))) + if (selection.mode === 'scope') { + skills.add(selection.scope) + } else if (packageEnabled) { + skills.add(sourceEntry(pkg)) + } + + const packageSkills = sortedSkills(pkg) + const entries = packageSkills.map((skill) => { + const id = skillId(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) { + if ((kindsByName.get(pkg.name)?.size ?? 0) > 1) { + throw new Error( + `Cannot exclude only ${sourceEntry(pkg)} because intent.exclude matches npm and workspace sources by package name.`, + ) + } + 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) { + if ((kindsByName.get(pkg.name)?.size ?? 0) > 1) { + throw new Error( + `Cannot exclude only ${sourceEntry(pkg)} because intent.exclude matches npm and workspace sources by package name.`, + ) + } + exclude.add(pkg.name) + } else if (selection.mode === 'individual') { + for (const [index, entry] of entries.entries()) { + if (entry.status === 'excluded') { + if ((kindsByName.get(pkg.name)?.size ?? 0) > 1) { + throw new Error( + `Cannot exclude a skill from only ${sourceEntry(pkg)} because intent.exclude matches npm and workspace sources by package name.`, + ) + } + exclude.add(skillExclude(pkg, packageSkills[index]!)) + } + } + } + return { name: pkg.name, kind: pkg.kind, skills: entries } + }) + + 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 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) + const sourcePermitted = isSourcePermitted(sources, pkg.name, pkg.kind) + return { + name: pkg.name, + kind: pkg.kind, + skills: sortedSkills(pkg).map((skill) => { + const excluded = + isPackageExcluded(pkg.name, excludes) || + isSkillExcluded(pkg.name, skill.name, excludes) + const policy: InventoryPolicyStatus = excluded + ? 'excluded' + : sourcePermitted + ? 'enabled' + : 'pending' + if (policy !== 'enabled') + return { id: skillId(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: skillId(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/tests/install-config.test.ts b/packages/intent/tests/install-config.test.ts new file mode 100644 index 00000000..6470f7bd --- /dev/null +++ b/packages/intent/tests/install-config.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { + INSTALL_TARGETS, + 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('rejects an install method unsupported by a selected target', () => { + const preferences: IntentInstallPreferences = { + method: 'map', + targets: ['github'], + } + const method: InstallMethod = preferences.method + expect(method).toBe('map') + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "hooks", "targets": ["vscode"] } } }', + ), + ).toThrow('not supported') + }) + + it('rejects duplicate install targets', () => { + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "map", "targets": ["agents", "agents"] } } }', + ), + ).toThrow('Duplicate') + }) + + it('rejects unknown install fields', () => { + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "map", "targets": [], "extra": true } } }', + ), + ).toThrow('Unknown') + }) + + it('rejects unknown targets, methods, and wrong target types', () => { + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "map", "targets": ["unknown"] } } }', + ), + ).toThrow('Unknown install target') + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "unknown", "targets": ["github"] } } }', + ), + ).toThrow('Unknown install method') + expect(() => + readIntentConsumerConfig( + '{ "intent": { "install": { "method": "map", "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: 'map', 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: 'map', 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..f05e6098 --- /dev/null +++ b/packages/intent/tests/install-plan.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest' +import { + buildInstallDeltaInventory, + buildSkillSelectionPlan, +} 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('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('excludes unchecked siblings and packages for individual selection', () => { + const plan = buildSkillSelectionPlan(discovered, { + mode: 'individual', + enabled: ['@tanstack/query#alpha'], + }) + expect(plan.skills).toEqual(['@tanstack/query']) + expect(plan.exclude).toEqual([ + '@other/core', + '@tanstack/query#zeta', + 'workspace-query', + ]) + }) + + 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('preserves workspace identity and rejects unrepresentable exclusions', () => { + 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('intent.exclude matches npm and workspace sources') + }) + + it('uses the existing bare package grammar for workspace skill exclusions', () => { + const plan = buildSkillSelectionPlan( + [pkg('workspace-only', ['enabled', 'excluded'], 'workspace')], + { + mode: 'individual', + enabled: ['workspace:workspace-only#enabled'], + }, + ) + + expect(plan.skills).toEqual(['workspace:workspace-only']) + expect(plan.exclude).toEqual(['workspace-only#excluded']) + }) + + 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('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 }, + ]) + }) +}) From de94657cdc08e9e864c06c62d43d7892ced9c1c1 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Mon, 20 Jul 2026 09:48:55 -0700 Subject: [PATCH 05/37] feat(sync): add managed skill link synchronization --- benchmarks/intent/sync.bench.ts | 77 ++++++ packages/intent/src/cli.ts | 13 + packages/intent/src/commands/sync/command.ts | 220 ++++++++++++++++ .../intent/src/commands/sync/gitignore.ts | 19 ++ packages/intent/src/commands/sync/links.ts | 189 ++++++++++++++ packages/intent/src/commands/sync/state.ts | 117 +++++++++ packages/intent/src/commands/sync/targets.ts | 91 +++++++ packages/intent/src/core/lockfile/hash.ts | 29 ++- packages/intent/src/core/source-policy.ts | 23 ++ packages/intent/tests/cli.test.ts | 126 +++++++++ packages/intent/tests/sync.test.ts | 239 ++++++++++++++++++ 11 files changed, 1133 insertions(+), 10 deletions(-) create mode 100644 benchmarks/intent/sync.bench.ts create mode 100644 packages/intent/src/commands/sync/command.ts create mode 100644 packages/intent/src/commands/sync/gitignore.ts create mode 100644 packages/intent/src/commands/sync/links.ts create mode 100644 packages/intent/src/commands/sync/state.ts create mode 100644 packages/intent/src/commands/sync/targets.ts create mode 100644 packages/intent/tests/sync.test.ts 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/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 8aea9de6..a41cb017 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -12,6 +12,7 @@ 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 { @@ -163,6 +164,18 @@ 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') + runSyncCommand(options) + }) + cli .command( 'hooks [action]', diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts new file mode 100644 index 00000000..d0b50e76 --- /dev/null +++ b/packages/intent/src/commands/sync/command.ts @@ -0,0 +1,220 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fail } from '../../shared/cli-error.js' +import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' +import { readIntentLockfile } from '../../core/lockfile/lockfile.js' +import { resolveProjectContext } from '../../core/project-context.js' +import { scanForConfiguredIntents } from '../../core/source-policy.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { readIntentConsumerConfig } from '../install/config.js' +import { buildInstallDeltaInventory } from '../install/plan.js' +import { updateIntentGitignore } from './gitignore.js' +import { reconcileManagedLinks } from './links.js' +import { + INSTALL_STATE_PATH, + readInstallState, + writeInstallState, +} from './state.js' +import { + createSyncAliases, + resolveSyncTargetDirectories, + toProjectRelativePath, +} from './targets.js' +import type { IntentPackage } from '../../shared/types.js' + +export interface SyncCommandOptions { + dryRun?: boolean + json?: boolean +} + +interface SyncPackageSummary { + name: string + skillCount: number +} + +interface SyncCommandResult { + created: Array + repaired: Array + removed: Array + unchanged: Array + conflicts: Array + pending: Array + changed: Array +} + +function findSkill(pkg: IntentPackage, name: string) { + return pkg.skills.find((skill) => skill.name === name) +} + +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 output(result: SyncCommandResult, json: 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.`, + ) + if (result.pending.length > 0) + console.log( + `Pending: ${result.pending.map((entry) => `${entry.name} (${entry.skillCount})`).join(', ')}.`, + ) + if (result.changed.length > 0) + console.log( + `Changed: ${result.changed.map((entry) => `${entry.name} (${entry.skillCount})`).join(', ')}.`, + ) + if (result.conflicts.length > 0) + console.log(`Conflicts: ${result.conflicts.join(', ')}.`) +} + +export function runSyncCommand(options: SyncCommandOptions): void { + const context = resolveProjectContext({ 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 config = readIntentConsumerConfig(readFileSync(packageJsonPath, 'utf8')) + 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 current = buildCurrentLockfileSources(policy.packages) + const inventory = buildInstallDeltaInventory( + discovered, + current, + lock, + config, + ) + const aliases = new Map( + createSyncAliases( + policy.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 alias = aliases.get(`${pkg.kind}\0${pkg.name}\0${skill.name}`)! + return targetDirectories.map((target) => { + const path = join(target.path, alias) + return { + path, + targetDirectory: toProjectRelativePath(root, target.path), + alias, + source: { kind: pkg.kind, id: pkg.name }, + skillPath: `skills/${skill.name}`, + sourceDirectory: dirname(skill.path), + packageRoot: source.packageRoot, + } + }) + }) + const persistedState = readInstallState(root) + const stateForLinks = + persistedState.status === 'found' + ? { + status: 'found' as const, + state: { + version: 1 as const, + entries: persistedState.state.entries.map((entry) => ({ + ...entry, + path: join(root, ...entry.path.split('/')), + })), + }, + } + : persistedState + const links = reconcileManagedLinks({ + dryRun: options.dryRun === true, + expected, + stateResult: stateForLinks, + }) + const pending = inventory.packages + .map((pkg) => ({ + name: pkg.name, + skillCount: pkg.skills.filter( + (skill) => + skill.policy === 'pending' || + (skill.policy === 'enabled' && skill.lock === 'new'), + ).length, + })) + .filter((entry) => entry.skillCount > 0) + const changed = inventory.packages + .map((pkg) => ({ + name: pkg.name, + 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)), + pending, + changed, + } + if (!options.dryRun) { + const stateEntries = links.entries.map((entry) => ({ + ...entry, + path: toProjectRelativePath(root, entry.path), + })) + writeInstallState(root, { version: 1, entries: stateEntries }) + writeGitignore(root, [ + ...stateEntries.map((entry) => entry.path), + INSTALL_STATE_PATH, + ]) + } + output(result, options.json === true) + if (links.conflicts.length > 0) + fail('Intent sync found managed link conflicts.') +} 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..ce0d505e --- /dev/null +++ b/packages/intent/src/commands/sync/links.ts @@ -0,0 +1,189 @@ +import { + lstatSync, + mkdirSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, +} 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') +} + +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) { + rmSync(entry.path, { recursive: true, force: true }) + 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) rmSync(priorEntry.path, { recursive: true, force: true }) + 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/state.ts b/packages/intent/src/commands/sync/state.ts new file mode 100644 index 00000000..ed81ff96 --- /dev/null +++ b/packages/intent/src/commands/sync/state.ts @@ -0,0 +1,117 @@ +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' + +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 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 + const directory = dirname(path) + mkdirSync(directory, { recursive: true }) + const temp = join(directory, `.${basename(path)}.${process.pid}.tmp`) + try { + writeFileSync(temp, content, 'utf8') + renameSync(temp, path) + } finally { + if (existsSync(temp)) unlinkSync(temp) + } + 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/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index 76ce8a4a..814eb08a 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -151,9 +151,15 @@ export function computeSkillContentHash({ if (!fs.lstatSync(realSkillDir).isDirectory()) { throw new Error('Skill directory is not a directory.') } - const entries: Array = [] - let entryCount = 0 - let totalBytes = 0 + const hashState: { + entries: Array + entryCount: number + totalBytes: number + } = { + entries: [], + entryCount: 0, + totalBytes: 0, + } const readFile = (physicalPath: string, logicalPath: string): void => { const realPath = resolveInPackage( @@ -166,12 +172,15 @@ export function computeSkillContentHash({ throw new Error(`${logicalPath} is not a regular file.`) } const content = readBoundedFile(fs, realPath) - totalBytes += content.length - if (totalBytes > HASH_LIMITS.maxTotalBytes) + hashState.totalBytes += content.length + if (hashState.totalBytes > HASH_LIMITS.maxTotalBytes) throw new Error('Hash total size limit exceeded.') - if (entries.length + 1 > HASH_LIMITS.maxFileCount) + if (hashState.entries.length + 1 > HASH_LIMITS.maxFileCount) throw new Error('Hash file count limit exceeded.') - entries.push({ path: logicalPath, content: normalizeContent(content) }) + hashState.entries.push({ + path: logicalPath, + content: normalizeContent(content), + }) } const collect = ( @@ -185,8 +194,8 @@ export function computeSkillContentHash({ for (const entry of [...dirEntries].sort((a, b) => compareStrings(a.name, b.name), )) { - entryCount += 1 - if (entryCount > HASH_LIMITS.maxEntryCount) + 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) @@ -226,5 +235,5 @@ export function computeSkillContentHash({ throw new Error(`${directory} is not a directory.`) collect(realDir, directory, 1) } - return hashEntries(entries) + return hashEntries(hashState.entries) } diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 929fd034..5cec769d 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -184,6 +184,29 @@ export interface SourcePolicyResult { notices: Array } +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( scanResult: { packages: Array }, options: SourcePolicyOptions, diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index aedcbe47..959bc835 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1,5 +1,6 @@ import { existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -12,6 +13,9 @@ 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 { isMainModule, main } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) @@ -222,6 +226,128 @@ describe('cli commands', () => { expect(output).toContain('--show-hidden') }) + 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) + + 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: verified (1).', + ) + 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( + 'Pending: pending (1).', + ) + + 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('prints the install prompt', async () => { const exitCode = await main(['install', '--print-prompt']) const output = String(logSpy.mock.calls[0]?.[0]) diff --git a/packages/intent/tests/sync.test.ts b/packages/intent/tests/sync.test.ts new file mode 100644 index 00000000..e4023764 --- /dev/null +++ b/packages/intent/tests/sync.test.ts @@ -0,0 +1,239 @@ +import { + existsSync, + lstatSync, + 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 { updateIntentGitignore } from '../src/commands/sync/gitignore.js' +import { reconcileManagedLinks } from '../src/commands/sync/links.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]) + rmSync(link.path, { recursive: true, force: true }) + 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) + }) +}) From 010baadfe052f74a8ad7827af0e9140d3f7fc9ce Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 22 Jul 2026 12:28:38 -0700 Subject: [PATCH 06/37] add clack --- packages/intent/package.json | 1 + pnpm-lock.yaml | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/intent/package.json b/packages/intent/package.json index 92dc6d7a..359d2686 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -30,6 +30,7 @@ "meta" ], "dependencies": { + "@clack/prompts": "1.7.0", "cac": "^6.7.14", "jsonc-parser": "^3.3.1", "semver": "^7.8.4", 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: {} From e16f781a3da6a8a2070e5ccf37fa4bc60a1abe5b Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 22 Jul 2026 13:06:11 -0700 Subject: [PATCH 07/37] feat(install): add interactive consumer setup --- packages/intent/src/cli.ts | 13 +- .../intent/src/commands/install/command.ts | 76 +++-- .../intent/src/commands/install/config.ts | 8 + .../intent/src/commands/install/consumer.ts | 165 +++++++++ packages/intent/src/commands/install/plan.ts | 21 +- .../intent/src/commands/install/prompts.ts | 134 ++++++++ packages/intent/src/commands/sync/command.ts | 90 +---- packages/intent/src/commands/sync/plan.ts | 95 ++++++ packages/intent/src/commands/sync/state.ts | 37 ++- packages/intent/src/core/lockfile/lockfile.ts | 26 +- packages/intent/src/shared/atomic-write.ts | 25 ++ packages/intent/src/shared/command-runner.ts | 3 - packages/intent/tests/cli.test.ts | 128 +------ .../intent/tests/consumer-install.test.ts | 314 ++++++++++++++++++ 14 files changed, 846 insertions(+), 289 deletions(-) create mode 100644 packages/intent/src/commands/install/consumer.ts create mode 100644 packages/intent/src/commands/install/prompts.ts create mode 100644 packages/intent/src/commands/sync/plan.ts create mode 100644 packages/intent/src/shared/atomic-write.ts create mode 100644 packages/intent/tests/consumer-install.test.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index a41cb017..553ed26b 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -135,27 +135,24 @@ 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]', ) .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', ) - .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 --map --global') .action(async (options: InstallCommandOptions) => { const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ import('./commands/support.js'), diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 2721081b..11596ac5 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,6 +1,5 @@ import { relative } from 'node:path' import { fail } from '../../shared/cli-error.js' -import { detectIntentCommandPackageManager } from '../../shared/command-runner.js' import { coreOptionsFromGlobalFlags, noticeOptionsFromGlobalFlags, @@ -8,13 +7,13 @@ 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 { IntentCoreOptions } from '../../core/index.js' import type { ScanResult } from '../../shared/types.js' @@ -127,6 +126,34 @@ export interface InstallCommandOptions extends GlobalScanFlags { printPrompt?: boolean } +export async function runInteractiveInstall({ + cwd, + dryRun, + prompts, +}: { + cwd: string + dryRun?: boolean + prompts: InstallerPrompter +}): Promise { + const [ + { runConsumerInstall }, + { resolveProjectContext }, + { scanForIntents }, + ] = await Promise.all([ + import('./consumer.js'), + import('../../core/project-context.js'), + import('../../discovery/scanner.js'), + ]) + const context = resolveProjectContext({ cwd }) + const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + dryRun, + prompts, + root, + }) +} + function formatTargetPath(targetPath: string): string { return relative(process.cwd(), targetPath) || targetPath } @@ -207,46 +234,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 or use `intent install --map`.', ) } - - printWriteResult(result) - printPlacementTip(result.targetPath) + const { createClackInstallerPrompter } = await import('./prompts.js') + await runInteractiveInstall({ + cwd: process.cwd(), + dryRun: options.dryRun, + prompts: createClackInstallerPrompter(), + }) return } diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts index 91ff4cd5..e7dab6c1 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -109,6 +109,14 @@ function parsePackageJson(text: string): Record { return value } +export function hasIntentDevDependency(text: string): boolean { + const devDependencies = parsePackageJson(text).devDependencies + return ( + isRecord(devDependencies) && + typeof devDependencies['@tanstack/intent'] === 'string' + ) +} + export function readIntentConsumerConfig(text: string): IntentConsumerConfig { const packageJson = parsePackageJson(text) const intent = packageJson.intent diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts new file mode 100644 index 00000000..5c897055 --- /dev/null +++ b/packages/intent/src/commands/install/consumer.ts @@ -0,0 +1,165 @@ +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 { writeIntentLockfile } from '../../core/lockfile/lockfile.js' +import { applySourcePolicy } from '../../core/source-policy.js' +import { parseSkillSources } from '../../core/skill-sources.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 { readInstallStateForLinks } from '../sync/state.js' +import { toProjectRelativePath } from '../sync/targets.js' +import { + INSTALL_TARGETS, + hasIntentDevDependency, + updateIntentConsumerConfigText, +} from './config.js' +import { buildSkillSelectionPlan } from './plan.js' +import type { + InstallMethod, + InstallTarget, + IntentConsumerConfig, + IntentInstallPreferences, +} from './config.js' +import type { SkillSelection } from './plan.js' +import type { IntentPackage } from '../../shared/types.js' + +interface ConsumerInstallConfig extends IntentConsumerConfig { + install: IntentInstallPreferences +} + +export type InstallConfirmation = 'install' | 'back' | null + +export interface InstallerPrompter { + complete: (message: string) => void + selectTargets: () => Promise | null> + selectMethod: () => Promise + confirmSymlink: () => Promise + selectSkills: ( + discovered: ReadonlyArray, + ) => Promise + confirmInstall: (confirmation: { + config: ConsumerInstallConfig + skillCount: number + }) => Promise +} + +export interface RunConsumerInstallOptions { + discovered: ReadonlyArray + dryRun?: boolean + prompts: InstallerPrompter + root: string +} + +export async function runConsumerInstall({ + discovered, + dryRun = false, + prompts, + root, +}: RunConsumerInstallOptions): Promise { + const packageJsonPath = join(root, 'package.json') + const packageJson = readFileSync(packageJsonPath, 'utf8') + if (!hasIntentDevDependency(packageJson)) { + throw new Error( + '@tanstack/intent must be installed as a project devDependency before running `intent install`.', + ) + } + for (;;) { + const targets = await prompts.selectTargets() + if (!targets || targets.length === 0) return + const method = await prompts.selectMethod() + if (!method) return + if (method !== 'symlink') { + throw new Error(`Install method "${method}" is not implemented yet.`) + } + const symlinkAccepted = await prompts.confirmSymlink() + if (!symlinkAccepted) return + if (discovered.every((pkg) => pkg.skills.length === 0)) { + prompts.complete('No intent-enabled skills found.') + return + } + const selection = 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 updatedPackageJson = updateIntentConsumerConfigText( + packageJson, + installation.config, + ) + 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), + } + 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 + } + + writeTextFileAtomic(packageJsonPath, updatedPackageJson) + writeIntentLockfile(join(root, 'intent.lock'), lockfile) + runSyncCommand({ cwd: root }) + prompts.complete( + `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using ${method}.`, + ) + return + } +} diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index 434cf84b..14a05e85 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -55,7 +55,10 @@ function sourceEntry(pkg: IntentPackage): string { return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name } -function skillId(pkg: IntentPackage, skill: SkillEntry): string { +export function skillSelectionId( + pkg: IntentPackage, + skill: SkillEntry, +): string { return `${sourceEntry(pkg)}#${skill.name}` } @@ -89,7 +92,9 @@ function assertUniqueDiscovery(packages: ReadonlyArray): void { const skills = new Set() for (const skill of pkg.skills) { if (skills.has(skill.name)) { - throw new Error(`Duplicate discovered skill "${skillId(pkg, skill)}".`) + throw new Error( + `Duplicate discovered skill "${skillSelectionId(pkg, skill)}".`, + ) } skills.add(skill.name) } @@ -125,7 +130,7 @@ export function buildSkillSelectionPlan( } const discoveredIds = new Set( packages.flatMap((pkg) => - sortedSkills(pkg).map((skill) => skillId(pkg, skill)), + sortedSkills(pkg).map((skill) => skillSelectionId(pkg, skill)), ), ) for (const id of selected) { @@ -148,7 +153,9 @@ export function buildSkillSelectionPlan( selection.mode === 'all-found' || packageMatchesScope || (selection.mode === 'individual' && - sortedSkills(pkg).some((skill) => selected.has(skillId(pkg, skill)))) + sortedSkills(pkg).some((skill) => + selected.has(skillSelectionId(pkg, skill)), + )) if (selection.mode === 'scope') { skills.add(selection.scope) } else if (packageEnabled) { @@ -157,7 +164,7 @@ export function buildSkillSelectionPlan( const packageSkills = sortedSkills(pkg) const entries = packageSkills.map((skill) => { - const id = skillId(pkg, skill) + const id = skillSelectionId(pkg, skill) const enabled = selection.mode !== 'individual' || selected.has(id) return { id, @@ -257,7 +264,7 @@ export function buildInstallDeltaInventory( ? 'enabled' : 'pending' if (policy !== 'enabled') - return { id: skillId(pkg, skill), policy, lock: null } + return { id: skillSelectionId(pkg, skill), policy, lock: null } const currentEntry = currentSkill(skill, current) const lockedEntry = currentEntry ? locked?.skills.find((entry) => entry.path === currentEntry.path) @@ -269,7 +276,7 @@ export function buildInstallDeltaInventory( ? 'accepted' : 'changed' return { - id: skillId(pkg, skill), + id: skillSelectionId(pkg, skill), policy, lock, } diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts new file mode 100644 index 00000000..30e6c345 --- /dev/null +++ b/packages/intent/src/commands/install/prompts.ts @@ -0,0 +1,134 @@ +import { + cancel, + confirm, + intro, + isCancel, + multiselect, + note, + outro, + select, +} from '@clack/prompts' +import { INSTALL_TARGETS } 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 createClackInstallerPrompter(): InstallerPrompter { + intro('Configure TanStack Intent') + return { + complete(message: string): void { + outro(message) + }, + async selectTargets(): Promise | null> { + return cancelled( + await multiselect({ + message: 'Where do you want to install skills?', + options: INSTALL_TARGETS.map((target) => ({ + value: target.id, + label: target.label, + })), + required: true, + }), + ) + }, + 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', + hint: 'Target-aware hook installation is not available yet', + disabled: true, + }, + { + value: 'map', + label: 'Add a compact skill map to agent instructions', + hint: 'Target-aware map installation is not available yet', + disabled: 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, + }), + ) + }, + async selectSkills( + discovered: ReadonlyArray, + ): Promise { + for (const pkg of discovered) { + note( + pkg.skills + .map((skill) => `${skill.name} -> ${skill.description}`) + .join('\n'), + sourceLabel(pkg), + ) + } + 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 multiselect({ + message: 'Select skills to enable', + options: discovered.flatMap((pkg) => + pkg.skills.map((skill) => ({ + value: skillSelectionId(pkg, skill), + label: `${sourceLabel(pkg)} / ${skill.name}`, + hint: skill.description, + })), + ), + required: false, + }), + ) + return enabled ? { mode, enabled } : null + }, + 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/sync/command.ts b/packages/intent/src/commands/sync/command.ts index d0b50e76..8836156b 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { fail } from '../../shared/cli-error.js' import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' import { readIntentLockfile } from '../../core/lockfile/lockfile.js' @@ -7,22 +7,18 @@ import { resolveProjectContext } from '../../core/project-context.js' import { scanForConfiguredIntents } from '../../core/source-policy.js' import { parseSkillSources } from '../../core/skill-sources.js' import { readIntentConsumerConfig } from '../install/config.js' -import { buildInstallDeltaInventory } from '../install/plan.js' import { updateIntentGitignore } from './gitignore.js' import { reconcileManagedLinks } from './links.js' +import { buildSyncLinkPlan } from './plan.js' import { INSTALL_STATE_PATH, - readInstallState, + readInstallStateForLinks, writeInstallState, } from './state.js' -import { - createSyncAliases, - resolveSyncTargetDirectories, - toProjectRelativePath, -} from './targets.js' -import type { IntentPackage } from '../../shared/types.js' +import { toProjectRelativePath } from './targets.js' export interface SyncCommandOptions { + cwd?: string dryRun?: boolean json?: boolean } @@ -42,10 +38,6 @@ interface SyncCommandResult { changed: Array } -function findSkill(pkg: IntentPackage, name: string) { - return pkg.skills.find((skill) => skill.name === name) -} - function writeGitignore(root: string, paths: Array): boolean { const path = join(root, '.gitignore') const before = existsSync(path) ? readFileSync(path, 'utf8') : null @@ -76,7 +68,7 @@ function output(result: SyncCommandResult, json: boolean): void { } export function runSyncCommand(options: SyncCommandOptions): void { - const context = resolveProjectContext({ cwd: process.cwd() }) + 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)) { @@ -103,78 +95,18 @@ export function runSyncCommand(options: SyncCommandOptions): void { exclude: config.exclude, }) const current = buildCurrentLockfileSources(policy.packages) - const inventory = buildInstallDeltaInventory( + const { expected, inventory } = buildSyncLinkPlan({ + config, + currentSources: current, discovered, - current, lock, - config, - ) - const aliases = new Map( - createSyncAliases( - policy.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( + packages: policy.packages, root, - config.install.targets, - ) - const expected = accepted.flatMap(({ pkg, skill, source }) => { - const alias = aliases.get(`${pkg.kind}\0${pkg.name}\0${skill.name}`)! - return targetDirectories.map((target) => { - const path = join(target.path, alias) - return { - path, - targetDirectory: toProjectRelativePath(root, target.path), - alias, - source: { kind: pkg.kind, id: pkg.name }, - skillPath: `skills/${skill.name}`, - sourceDirectory: dirname(skill.path), - packageRoot: source.packageRoot, - } - }) }) - const persistedState = readInstallState(root) - const stateForLinks = - persistedState.status === 'found' - ? { - status: 'found' as const, - state: { - version: 1 as const, - entries: persistedState.state.entries.map((entry) => ({ - ...entry, - path: join(root, ...entry.path.split('/')), - })), - }, - } - : persistedState const links = reconcileManagedLinks({ dryRun: options.dryRun === true, expected, - stateResult: stateForLinks, + stateResult: readInstallStateForLinks(root), }) const pending = inventory.packages .map((pkg) => ({ 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/state.ts b/packages/intent/src/commands/sync/state.ts index ed81ff96..e3e64642 100644 --- a/packages/intent/src/commands/sync/state.ts +++ b/packages/intent/src/commands/sync/state.ts @@ -1,12 +1,6 @@ -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { basename, dirname, join } from 'node:path' +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' @@ -100,18 +94,25 @@ export function readInstallState(root: string): ReadInstallStateResult { 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 - const directory = dirname(path) - mkdirSync(directory, { recursive: true }) - const temp = join(directory, `.${basename(path)}.${process.pid}.tmp`) - try { - writeFileSync(temp, content, 'utf8') - renameSync(temp, path) - } finally { - if (existsSync(temp)) unlinkSync(temp) - } + writeTextFileAtomic(path, content) return true } diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index 3dcc9812..06b9cd02 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -1,12 +1,5 @@ -import { - existsSync, - mkdirSync, - readFileSync, - renameSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { basename, dirname, join } from 'node:path' +import { readFileSync } from 'node:fs' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' import { validateSkillPaths } from '../skill-path.js' export interface IntentLockfileSkill { @@ -177,18 +170,5 @@ export function writeIntentLockfile( filePath: string, lockfile: IntentLockfile, ): void { - const directory = dirname(filePath) - mkdirSync(directory, { recursive: true }) - const tempPath = join( - directory, - `.${basename(filePath)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`, - ) - try { - writeFileSync(tempPath, serializeIntentLockfile(lockfile), 'utf8') - renameSync(tempPath, filePath) - } finally { - try { - if (existsSync(tempPath)) unlinkSync(tempPath) - } catch {} - } + writeTextFileAtomic(filePath, serializeIntentLockfile(lockfile)) } 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..f757a6be 100644 --- a/packages/intent/src/shared/command-runner.ts +++ b/packages/intent/src/shared/command-runner.ts @@ -1,8 +1,5 @@ -import { detectPackageManager } from '../discovery/package-manager.js' import type { PackageManager } from './types.js' -export { detectPackageManager as detectIntentCommandPackageManager } - const runnerByPackageManager: Record = { bun: 'bunx @tanstack/intent@latest', npm: 'npx @tanstack/intent@latest', diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 959bc835..593a17e1 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -475,120 +475,24 @@ describe('cli commands', () => { ) }) - 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 - process.chdir(root) + 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']) - const output = logSpy.mock.calls.flat().join('\n') + const exitCode = await main(['install', ...flags]) - 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( + 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map`.', + ) + 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-')) diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts new file mode 100644 index 00000000..8905a821 --- /dev/null +++ b/packages/intent/tests/consumer-install.test.ts @@ -0,0 +1,314 @@ +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 { runConsumerInstall } from '../src/commands/install/consumer.js' +import { readIntentConsumerConfig } from '../src/commands/install/config.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 { InstallerPrompter } from '../src/commands/install/consumer.js' + +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 createPrompts( + overrides: Partial = {}, +): InstallerPrompter { + return { + complete: () => {}, + selectTargets: () => Promise.resolve(['agents']), + selectMethod: () => Promise.resolve('symlink'), + confirmSymlink: () => 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('installs confirmed skills with policy, lock state, and managed links', async () => { + const root = createProject() + const prompts = createPrompts() + + await runInteractiveInstall({ + cwd: root, + prompts, + }) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ + skills: ['@tanstack/query'], + exclude: [], + install: { method: 'symlink', targets: ['agents'] }, + }) + 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) + }) + + 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 while 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.exclude).toEqual(['@tanstack/query#mutations']) + 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('requires Intent as a project development dependency', async () => { + const root = createProject() + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + const prompts = createPrompts() + + await expect( + runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }), + ).rejects.toThrow( + '@tanstack/intent must be installed as a project devDependency before running `intent install`.', + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + }) + + 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) + }) +}) From 9a135298db8094544da43c199f3984fe13289a3d Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 22 Jul 2026 13:53:29 -0700 Subject: [PATCH 08/37] feat(install): enhance target selection based on install method --- .../intent/src/commands/install/config.ts | 8 +++ .../intent/src/commands/install/consumer.ts | 6 +- .../intent/src/commands/install/prompts.ts | 55 ++++++++++--------- .../intent/tests/consumer-install.test.ts | 25 ++++++++- packages/intent/tests/install-config.test.ts | 18 ++++++ 5 files changed, 83 insertions(+), 29 deletions(-) diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts index e7dab6c1..259e40a2 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -42,6 +42,14 @@ const INSTALL_METHODS: Readonly< map: new Set(INSTALL_TARGETS.map((target) => target.id)), } +export function installTargetsForMethod( + method: InstallMethod, +): typeof INSTALL_TARGETS { + return INSTALL_TARGETS.filter((target) => + INSTALL_METHODS[method].has(target.id), + ) +} + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index 5c897055..d7f01fd3 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -34,8 +34,8 @@ export type InstallConfirmation = 'install' | 'back' | null export interface InstallerPrompter { complete: (message: string) => void - selectTargets: () => Promise | null> selectMethod: () => Promise + selectTargets: (method: InstallMethod) => Promise | null> confirmSymlink: () => Promise selectSkills: ( discovered: ReadonlyArray, @@ -67,10 +67,10 @@ export async function runConsumerInstall({ ) } for (;;) { - const targets = await prompts.selectTargets() - if (!targets || targets.length === 0) return const method = await prompts.selectMethod() if (!method) return + const targets = await prompts.selectTargets(method) + if (!targets || targets.length === 0) return if (method !== 'symlink') { throw new Error(`Install method "${method}" is not implemented yet.`) } diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index 30e6c345..5b970c1c 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -8,7 +8,7 @@ import { outro, select, } from '@clack/prompts' -import { INSTALL_TARGETS } from './config.js' +import { installTargetsForMethod } from './config.js' import { skillSelectionId } from './plan.js' import type { InstallConfirmation, InstallerPrompter } from './consumer.js' import type { InstallMethod, InstallTarget } from './config.js' @@ -31,11 +31,37 @@ export function createClackInstallerPrompter(): InstallerPrompter { complete(message: string): void { outro(message) }, - async selectTargets(): Promise | null> { + async selectMethod(): Promise { + for (;;) { + const method = cancelled( + await select({ + message: 'How do you want to install skills?', + options: [ + { value: 'symlink', label: 'Symlink skill folders' }, + { value: 'hooks', label: 'Install lifecycle hooks' }, + { + value: 'map', + label: 'Add a compact skill map to agent instructions', + }, + ], + }), + ) + if (!method || method === 'symlink') return method + note( + 'This delivery adapter is not available in the current installer slice.', + method === 'hooks' + ? 'Lifecycle hooks are coming next' + : 'Compact skill maps are coming next', + ) + } + }, + async selectTargets( + method: InstallMethod, + ): Promise | null> { return cancelled( await multiselect({ message: 'Where do you want to install skills?', - options: INSTALL_TARGETS.map((target) => ({ + options: installTargetsForMethod(method).map((target) => ({ value: target.id, label: target.label, })), @@ -43,28 +69,6 @@ export function createClackInstallerPrompter(): InstallerPrompter { }), ) }, - 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', - hint: 'Target-aware hook installation is not available yet', - disabled: true, - }, - { - value: 'map', - label: 'Add a compact skill map to agent instructions', - hint: 'Target-aware map installation is not available yet', - disabled: 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.', @@ -74,6 +78,7 @@ export function createClackInstallerPrompter(): InstallerPrompter { await confirm({ message: 'Continue with symlinks?', initialValue: false, + vertical: true, }), ) }, diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 8905a821..098ad062 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -57,8 +57,8 @@ function createPrompts( ): InstallerPrompter { return { complete: () => {}, - selectTargets: () => Promise.resolve(['agents']), selectMethod: () => Promise.resolve('symlink'), + selectTargets: () => Promise.resolve(['agents']), confirmSymlink: () => Promise.resolve(true), selectSkills: () => Promise.resolve({ mode: 'all-found' }), confirmInstall: () => Promise.resolve('install'), @@ -73,6 +73,29 @@ afterEach(() => { }) describe('consumer install', () => { + 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('installs confirmed skills with policy, lock state, and managed links', async () => { const root = createProject() const prompts = createPrompts() diff --git a/packages/intent/tests/install-config.test.ts b/packages/intent/tests/install-config.test.ts index 6470f7bd..a80d114c 100644 --- a/packages/intent/tests/install-config.test.ts +++ b/packages/intent/tests/install-config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { INSTALL_TARGETS, + installTargetsForMethod, readIntentConsumerConfig, updateIntentConsumerConfigText, } from '../src/commands/install/config.js' @@ -22,6 +23,23 @@ describe('installer configuration', () => { ]) }) + 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'], + ) + expect(installTargetsForMethod('map').map((target) => target.id)).toEqual([ + 'agents', + 'github', + 'vscode', + 'cursor', + 'codex', + 'claude', + ]) + }) + it('rejects an install method unsupported by a selected target', () => { const preferences: IntentInstallPreferences = { method: 'map', From a4f30a75925eb0b80fc51e98936c6394e78b4724 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 22 Jul 2026 14:18:36 -0700 Subject: [PATCH 09/37] feat(install): implement grouped skill selection for improved user experience --- .../intent/src/commands/install/prompts.ts | 43 ++++++++++++------- .../intent/tests/consumer-install.test.ts | 16 +++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index 5b970c1c..1bb1cddf 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -1,6 +1,7 @@ import { cancel, confirm, + groupMultiselect, intro, isCancel, multiselect, @@ -25,6 +26,24 @@ 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 function createClackInstallerPrompter(): InstallerPrompter { intro('Configure TanStack Intent') return { @@ -85,14 +104,10 @@ export function createClackInstallerPrompter(): InstallerPrompter { async selectSkills( discovered: ReadonlyArray, ): Promise { - for (const pkg of discovered) { - note( - pkg.skills - .map((skill) => `${skill.name} -> ${skill.description}`) - .join('\n'), - sourceLabel(pkg), - ) - } + note( + `${discovered.reduce((count, pkg) => count + pkg.skills.length, 0)} skills from ${discovered.length} packages`, + 'Skills found', + ) const mode = cancelled( await select({ message: 'Which skills do you want to enable?', @@ -110,16 +125,12 @@ export function createClackInstallerPrompter(): InstallerPrompter { if (mode === 'all-found') return { mode } if (mode === 'scope') return { mode, scope: '@tanstack/*' } const enabled = cancelled( - await multiselect({ + await groupMultiselect({ message: 'Select skills to enable', - options: discovered.flatMap((pkg) => - pkg.skills.map((skill) => ({ - value: skillSelectionId(pkg, skill), - label: `${sourceLabel(pkg)} / ${skill.name}`, - hint: skill.description, - })), - ), + options: groupSkillOptions(discovered), required: false, + selectableGroups: true, + groupSpacing: 1, }), ) return enabled ? { mode, enabled } : null diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 098ad062..b098c322 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -13,6 +13,7 @@ import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { runInteractiveInstall } from '../src/commands/install/command.js' import { runConsumerInstall } from '../src/commands/install/consumer.js' +import { groupSkillOptions } from '../src/commands/install/prompts.js' import { readIntentConsumerConfig } from '../src/commands/install/config.js' import { readInstallState } from '../src/commands/sync/state.js' import { readIntentLockfile } from '../src/core/lockfile/lockfile.js' @@ -73,6 +74,21 @@ afterEach(() => { }) describe('consumer install', () => { + 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('selects the method before requesting applicable targets', async () => { const root = createProject() const calls: Array = [] From b39fff8f9ef97f553bfad3bc1d9f1cb9c4583d72 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 22 Jul 2026 14:39:51 -0700 Subject: [PATCH 10/37] feat(sync): enhance output for new dependencies and skills in sync command --- packages/intent/src/commands/sync/command.ts | 91 +++++++++++++------- packages/intent/tests/cli.test.ts | 34 +++++++- 2 files changed, 94 insertions(+), 31 deletions(-) diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index 8836156b..4ced0b02 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -34,10 +34,30 @@ interface SyncCommandResult { removed: Array unchanged: Array conflicts: Array - pending: Array + newDependencies: Array + newSkills: Array changed: Array } +function formatPackageSummaries(entries: Array): string { + const width = Math.max(...entries.map((entry) => entry.name.length)) + return entries + .map( + (entry) => + `${entry.name.padEnd(width)} ${entry.skillCount} ${entry.skillCount === 1 ? 'skill' : 'skills'}`, + ) + .join('\n') +} + +function printReminder( + title: string, + entries: Array, + action: string, +): void { + if (entries.length === 0) return + console.log(`${title}:\n\n${formatPackageSummaries(entries)}\n\n${action}`) +} + function writeGitignore(root: string, paths: Array): boolean { const path = join(root, '.gitignore') const before = existsSync(path) ? readFileSync(path, 'utf8') : null @@ -55,14 +75,21 @@ function output(result: SyncCommandResult, json: boolean): void { console.log( `Intent sync: ${result.created.length} created, ${result.repaired.length} repaired, ${result.removed.length} removed.`, ) - if (result.pending.length > 0) - console.log( - `Pending: ${result.pending.map((entry) => `${entry.name} (${entry.skillCount})`).join(', ')}.`, - ) - if (result.changed.length > 0) - console.log( - `Changed: ${result.changed.map((entry) => `${entry.name} (${entry.skillCount})`).join(', ')}.`, - ) + printReminder( + 'New dependencies with skills found', + result.newDependencies, + '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(', ')}.`) } @@ -108,32 +135,38 @@ export function runSyncCommand(options: SyncCommandOptions): void { expected, stateResult: readInstallStateForLinks(root), }) - const pending = inventory.packages - .map((pkg) => ({ - name: pkg.name, - skillCount: pkg.skills.filter( - (skill) => - skill.policy === 'pending' || - (skill.policy === 'enabled' && skill.lock === 'new'), - ).length, - })) - .filter((entry) => entry.skillCount > 0) - const changed = inventory.packages - .map((pkg) => ({ - name: pkg.name, - skillCount: pkg.skills.filter( - (skill) => skill.policy === 'enabled' && skill.lock === 'changed', - ).length, - })) - .filter((entry) => entry.skillCount > 0) + const summaries = { + newDependencies: inventory.packages + .map((pkg) => ({ + name: pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name, + skillCount: pkg.skills.filter((skill) => skill.policy === 'pending') + .length, + })) + .filter((entry) => entry.skillCount > 0), + newSkills: inventory.packages + .map((pkg) => ({ + name: pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name, + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'new', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + changed: inventory.packages + .map((pkg) => ({ + name: pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name, + 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)), - pending, - changed, + ...summaries, } if (!options.dryRun) { const stateEntries = links.entries.map((entry) => ({ diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 593a17e1..6fd06e8c 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -281,6 +281,24 @@ describe('cli commands', () => { 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', @@ -288,7 +306,13 @@ describe('cli commands', () => { expect(await main(['sync'])).toBe(0) expect(existsSync(linkPath)).toBe(false) expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'Changed: verified (1).', + [ + 'Changed skill content:', + '', + 'verified 1 skill', + '', + 'Run `intent install` to review and accept the new baseline.', + ].join('\n'), ) expect( JSON.parse( @@ -304,7 +328,13 @@ describe('cli commands', () => { }) expect(await main(['sync'])).toBe(0) expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'Pending: pending (1).', + [ + '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'), { From 0949902c3b13f7272929c930a37c4c5881b413ea Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 22 Jul 2026 15:02:41 -0700 Subject: [PATCH 11/37] feat(sync): implement interactive review for new dependencies and skills --- packages/intent/src/cli.ts | 2 +- .../intent/src/commands/install/consumer.ts | 8 +- .../intent/src/commands/install/prompts.ts | 66 ++-- packages/intent/src/commands/sync/command.ts | 285 ++++++++++++++++-- packages/intent/src/commands/sync/prepare.ts | 50 +++ packages/intent/src/commands/sync/prompts.ts | 27 ++ .../intent/tests/consumer-install.test.ts | 179 +++++++++++ packages/intent/tests/sync.test.ts | 13 + 8 files changed, 562 insertions(+), 68 deletions(-) create mode 100644 packages/intent/src/commands/sync/prepare.ts create mode 100644 packages/intent/src/commands/sync/prompts.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 553ed26b..bb5e5035 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -170,7 +170,7 @@ function createCli(): CAC { .example('sync --dry-run') .action(async (options: SyncCommandOptions) => { const { runSyncCommand } = await import('./commands/sync/command.js') - runSyncCommand(options) + await runSyncCommand(options) }) cli diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index d7f01fd3..9fbe927a 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -9,6 +9,7 @@ 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 { @@ -100,9 +101,8 @@ export async function runConsumerInstall({ if (confirmation === null) return if (confirmation === 'back') continue - const updatedPackageJson = updateIntentConsumerConfigText( - packageJson, - installation.config, + const updatedPackageJson = wireIntentSyncPrepare( + updateIntentConsumerConfigText(packageJson, installation.config), ) const policy = applySourcePolicy( { packages: [...discovered] }, @@ -156,7 +156,7 @@ export async function runConsumerInstall({ writeTextFileAtomic(packageJsonPath, updatedPackageJson) writeIntentLockfile(join(root, 'intent.lock'), lockfile) - runSyncCommand({ cwd: root }) + await runSyncCommand({ cwd: root }, { interactive: false }) prompts.complete( `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using ${method}.`, ) diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index 1bb1cddf..829b5c6e 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -44,6 +44,41 @@ export function groupSkillOptions( ) } +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 { @@ -104,36 +139,7 @@ export function createClackInstallerPrompter(): InstallerPrompter { async selectSkills( discovered: ReadonlyArray, ): Promise { - note( - `${discovered.reduce((count, pkg) => count + pkg.skills.length, 0)} skills from ${discovered.length} packages`, - 'Skills found', - ) - 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, enabled } : null + return selectClackSkills(discovered) }, async confirmInstall({ config, skillCount }): Promise { return cancelled( diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index 4ced0b02..c085d8f9 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -1,12 +1,24 @@ 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 } from '../../core/lockfile/lockfile.js' +import { + readIntentLockfile, + writeIntentLockfile, +} from '../../core/lockfile/lockfile.js' import { resolveProjectContext } from '../../core/project-context.js' -import { scanForConfiguredIntents } from '../../core/source-policy.js' +import { + applySourcePolicy, + scanForConfiguredIntents, +} from '../../core/source-policy.js' import { parseSkillSources } from '../../core/skill-sources.js' -import { readIntentConsumerConfig } from '../install/config.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' @@ -16,6 +28,9 @@ import { 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 @@ -23,6 +38,23 @@ export interface SyncCommandOptions { json?: boolean } +export type NewDependencyDecision = 'review' | 'exclude' | 'later' + +export interface SyncReviewPrompter { + complete: (message: string) => void + reviewNewDependencies: ( + entries: ReadonlyArray, + ) => Promise + selectSkills: ( + packages: ReadonlyArray, + ) => Promise +} + +export interface SyncCommandRuntime { + interactive?: boolean + prompts?: SyncReviewPrompter +} + interface SyncPackageSummary { name: string skillCount: number @@ -39,23 +71,20 @@ interface SyncCommandResult { changed: Array } -function formatPackageSummaries(entries: Array): string { - const width = Math.max(...entries.map((entry) => entry.name.length)) - return entries - .map( - (entry) => - `${entry.name.padEnd(width)} ${entry.skillCount} ${entry.skillCount === 1 ? 'skill' : 'skills'}`, - ) - .join('\n') -} - function printReminder( title: string, entries: Array, action: string, ): void { if (entries.length === 0) return - console.log(`${title}:\n\n${formatPackageSummaries(entries)}\n\n${action}`) + 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 { @@ -67,7 +96,23 @@ function writeGitignore(root: string, paths: Array): boolean { return true } -function output(result: SyncCommandResult, json: boolean): void { +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 @@ -78,7 +123,9 @@ function output(result: SyncCommandResult, json: boolean): void { printReminder( 'New dependencies with skills found', result.newDependencies, - 'Run `intent install` to review and install them, or add them to `intent.exclude`.', + 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', @@ -94,7 +141,151 @@ function output(result: SyncCommandResult, json: boolean): void { console.log(`Conflicts: ${result.conflicts.join(', ')}.`) } -export function runSyncCommand(options: SyncCommandOptions): void { +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.interactive !== undefined) return runtime.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 updatedConfig = { + ...config, + exclude: [ + ...new Set([...config.exclude, ...packages.map((pkg) => 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 updatedConfig = { + ...config, + skills: [...new Set([...config.skills, ...selectionPlan.skills])].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') @@ -103,7 +294,8 @@ export function runSyncCommand(options: SyncCommandOptions): void { 'Intent sync requires intent.install configuration and intent.lock. Run `intent install` first.', ) } - const config = readIntentConsumerConfig(readFileSync(packageJsonPath, 'utf8')) + const packageJson = readFileSync(packageJsonPath, 'utf8') + const config = readIntentConsumerConfig(packageJson) const lock = readIntentLockfile(join(root, 'intent.lock')) if (!config.install || lock.status !== 'found') { fail( @@ -121,10 +313,9 @@ export function runSyncCommand(options: SyncCommandOptions): void { config: parseSkillSources(config.skills), exclude: config.exclude, }) - const current = buildCurrentLockfileSources(policy.packages) const { expected, inventory } = buildSyncLinkPlan({ config, - currentSources: current, + currentSources: buildCurrentLockfileSources(policy.packages), discovered, lock, packages: policy.packages, @@ -138,14 +329,14 @@ export function runSyncCommand(options: SyncCommandOptions): void { const summaries = { newDependencies: inventory.packages .map((pkg) => ({ - name: pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name, + name: sourceName(pkg), skillCount: pkg.skills.filter((skill) => skill.policy === 'pending') .length, })) .filter((entry) => entry.skillCount > 0), newSkills: inventory.packages .map((pkg) => ({ - name: pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name, + name: sourceName(pkg), skillCount: pkg.skills.filter( (skill) => skill.policy === 'enabled' && skill.lock === 'new', ).length, @@ -153,7 +344,7 @@ export function runSyncCommand(options: SyncCommandOptions): void { .filter((entry) => entry.skillCount > 0), changed: inventory.packages .map((pkg) => ({ - name: pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name, + name: sourceName(pkg), skillCount: pkg.skills.filter( (skill) => skill.policy === 'enabled' && skill.lock === 'changed', ).length, @@ -169,17 +360,45 @@ export function runSyncCommand(options: SyncCommandOptions): void { ...summaries, } if (!options.dryRun) { - const stateEntries = links.entries.map((entry) => ({ - ...entry, - path: toProjectRelativePath(root, entry.path), - })) - writeInstallState(root, { version: 1, entries: stateEntries }) - writeGitignore(root, [ - ...stateEntries.map((entry) => entry.path), - INSTALL_STATE_PATH, - ]) + writeManagedLinkState(root, links) } - output(result, options.json === true) + 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 (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/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/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index b098c322..1b357d98 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -15,6 +15,7 @@ import { runInteractiveInstall } from '../src/commands/install/command.js' import { runConsumerInstall } from '../src/commands/install/consumer.js' import { groupSkillOptions } from '../src/commands/install/prompts.js' import { readIntentConsumerConfig } from '../src/commands/install/config.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' @@ -53,6 +54,28 @@ function createProject(): string { 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 { @@ -130,6 +153,9 @@ describe('consumer install', () => { 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: { @@ -350,4 +376,157 @@ describe('consumer install', () => { ).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 }, + { + interactive: true, + 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', '@tanstack/query']) + expect(config.exclude).toEqual(['@tanstack/new-package#second']) + 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('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 }, + { + interactive: true, + 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('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 }, + { + interactive: true, + 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 }, + { + interactive: true, + 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/sync.test.ts b/packages/intent/tests/sync.test.ts index e4023764..cd01d673 100644 --- a/packages/intent/tests/sync.test.ts +++ b/packages/intent/tests/sync.test.ts @@ -12,6 +12,7 @@ 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, @@ -236,4 +237,16 @@ describe('sync managed text', () => { ]), ).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) + }) }) From 3187fb5665a8e70fa9a9d31dca3f5d4eeced3660 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Wed, 22 Jul 2026 15:27:12 -0700 Subject: [PATCH 12/37] fix(load): enforce accepted skill baselines --- packages/intent/src/core/intent-core.ts | 44 +++++- packages/intent/src/core/load-resolution.ts | 4 +- packages/intent/src/core/types.ts | 2 + packages/intent/src/skills/resolver.ts | 2 + packages/intent/tests/core.test.ts | 148 ++++++++++++++++++++ packages/intent/tests/resolver.test.ts | 1 + 6 files changed, 197 insertions(+), 4 deletions(-) diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index d24f7a95..ff03db34 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,6 +7,8 @@ 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 { @@ -179,6 +181,8 @@ function toResolvedIntentSkill( use: string, resolved: ResolveSkillResult, readFs: ReadFs, + kind: 'npm' | 'workspace', + lockRoot: string, debug?: LoadedIntentSkillDebug, ): { realPackageRoot: string @@ -208,6 +212,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,6 +319,8 @@ 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) @@ -313,6 +351,8 @@ function resolveIntentSkillInCwd( use, fastPathResolved, fsCache.getReadFs(), + fastPathResolved.kind, + lockRoot, options.debug ? createLoadedSkillDebug({ cwd, @@ -353,6 +393,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/types.ts b/packages/intent/src/core/types.ts index cf897035..8d22b15b 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -122,5 +122,7 @@ export type IntentCoreErrorCode = | 'package-not-listed' | 'skill-excluded' | 'skill-not-found' + | 'skill-not-accepted' + | 'skill-content-changed' | 'skill-path-outside-package' | 'skill-file-not-found' 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/core.test.ts b/packages/intent/tests/core.test.ts index 0989b1fb..37de188b 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') 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', From cd7529bd3bec68011715d38e258e01a84737a9a4 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 24 Jul 2026 19:50:44 -0700 Subject: [PATCH 13/37] feat(sync): replace rmSync with unlinkSync and rmdirSync for link removal --- packages/intent/src/commands/sync/links.ts | 17 ++++++++++++++--- packages/intent/tests/sync.test.ts | 3 ++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/intent/src/commands/sync/links.ts b/packages/intent/src/commands/sync/links.ts index ce0d505e..5e28b18b 100644 --- a/packages/intent/src/commands/sync/links.ts +++ b/packages/intent/src/commands/sync/links.ts @@ -3,8 +3,9 @@ import { mkdirSync, readlinkSync, realpathSync, - rmSync, + rmdirSync, symlinkSync, + unlinkSync, } from 'node:fs' import { dirname, isAbsolute, relative, resolve } from 'node:path' import type { InstallStateEntry, ReadInstallStateResult } from './state.js' @@ -92,6 +93,16 @@ function createLink(path: string, target: string): void { 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 } @@ -145,7 +156,7 @@ export function reconcileManagedLinks({ } if (current === priorEntry.linkTarget) { if (!dryRun) { - rmSync(entry.path, { recursive: true, force: true }) + removeLink(entry.path) createLink(entry.path, target) } result.repaired.push(entry.path) @@ -167,7 +178,7 @@ export function reconcileManagedLinks({ isLink(priorEntry.path) && resolveLinkTarget(priorEntry.path) === priorEntry.linkTarget ) { - if (!dryRun) rmSync(priorEntry.path, { recursive: true, force: true }) + if (!dryRun) removeLink(priorEntry.path) result.removed.push(priorEntry.path) continue } diff --git a/packages/intent/tests/sync.test.ts b/packages/intent/tests/sync.test.ts index cd01d673..584dba78 100644 --- a/packages/intent/tests/sync.test.ts +++ b/packages/intent/tests/sync.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync, symlinkSync, + unlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -144,7 +145,7 @@ describe('managed sync links', () => { }, }) expect(second.unchanged).toEqual([link.path]) - rmSync(link.path, { recursive: true, force: true }) + unlinkSync(link.path) const repaired = reconcileManagedLinks({ dryRun: false, expected: [link], From e91b98c3bdb0801c2d34cd914128e5321449a114 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 24 Jul 2026 21:08:44 -0700 Subject: [PATCH 14/37] test(benchmarks): exercise lock verification in catalogue benchmarks --- benchmarks/intent/catalog.bench.ts | 57 ++++++++++++++++++++++++++++-- benchmarks/intent/tsconfig.json | 1 - 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/benchmarks/intent/catalog.bench.ts b/benchmarks/intent/catalog.bench.ts index 349ab83b..f947f9ff 100644 --- a/benchmarks/intent/catalog.bench.ts +++ b/benchmarks/intent/catalog.bench.ts @@ -1,6 +1,9 @@ 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, @@ -13,6 +16,39 @@ import { 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 @@ -24,12 +60,27 @@ beforeAll(async () => { name: 'intent-catalog-benchmark', private: true, intent: { skills: ['@bench/*'] }, - dependencies: { '@bench/query': '1.0.0' }, + dependencies: Object.fromEntries( + PACKAGES.map((pkg) => [pkg.name, '1.0.0']), + ), }) writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: "9.0"\n') - writePackage(join(root, 'node_modules'), '@bench/query', '1.0.0', { - skills: ['queries', 'mutations', 'invalidation'], + 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 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"] From 1746da4a1f0fcd00441a42019be96a9e9fd4b04c Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 24 Jul 2026 21:39:49 -0700 Subject: [PATCH 15/37] feat(delivery): add delivery job to PR workflow and implement delivery tests --- .github/workflows/pr.yml | 17 +++++++++++++++++ packages/intent/package.json | 1 + 2 files changed, 18 insertions(+) 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/packages/intent/package.json b/packages/intent/package.json index 359d2686..891de85e 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -47,6 +47,7 @@ "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 ." From 22a63c60aad082cb6b2df3670c5d4e4a58d36451 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 24 Jul 2026 21:50:15 -0700 Subject: [PATCH 16/37] test(scanner): pin current consumer discovery behavior --- packages/intent/tests/scanner.test.ts | 74 +++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) 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']) + }) +}) From a8eda955b4963fed54937bcd869ad6061a4f12f5 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 24 Jul 2026 21:54:02 -0700 Subject: [PATCH 17/37] refactor(exclude): write package.json through the shared JSONC config helpers --- packages/intent/src/commands/exclude.ts | 78 +++++++++---------------- 1 file changed, 26 insertions(+), 52 deletions(-) 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 From abd20be1b66f3644f4988d58066f240d8c234e62 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 24 Jul 2026 21:57:49 -0700 Subject: [PATCH 18/37] fix(lockfile): say when a lockfile was written by a newer Intent --- packages/intent/src/core/lockfile/lockfile.ts | 7 ++++++- packages/intent/tests/lockfile.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index 06b9cd02..04b166a8 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -104,6 +104,11 @@ export function parseIntentLockfile(content: string): IntentLockfile { } 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.') } @@ -117,7 +122,7 @@ export function parseIntentLockfile(content: string): IntentLockfile { } if (source.kind !== 'npm' && source.kind !== 'workspace') { throw new Error( - `Invalid intent.lock source kind: ${String(source.kind)}.`, + `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 { diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts index 8dbea33e..f33a286c 100644 --- a/packages/intent/tests/lockfile.test.ts +++ b/packages/intent/tests/lockfile.test.ts @@ -102,6 +102,17 @@ describe('intent lockfile', () => { ).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' }) From 6cb7cb54064118c474dc442d46bacf7090186805 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 24 Jul 2026 23:10:27 -0700 Subject: [PATCH 19/37] fix(install): reject only exclusions that hide an enabled skill --- packages/intent/src/commands/install/plan.ts | 55 ++++++++++++-------- packages/intent/tests/install-plan.test.ts | 53 ++++++++++++++++++- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index 14a05e85..3a73ff23 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -109,18 +109,44 @@ function validateScope(scope: string): void { } } +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) - const kindsByName = new Map>() - for (const pkg of packages) { - const kinds = kindsByName.get(pkg.name) ?? new Set() - kinds.add(pkg.kind) - kindsByName.set(pkg.name, kinds) - } const selected = new Set() if (selection.mode === 'scope') validateScope(selection.scope) if (selection.mode === 'individual') { @@ -172,11 +198,6 @@ export function buildSkillSelectionPlan( } }) if (selection.mode === 'scope' && !packageMatchesScope) { - if ((kindsByName.get(pkg.name)?.size ?? 0) > 1) { - throw new Error( - `Cannot exclude only ${sourceEntry(pkg)} because intent.exclude matches npm and workspace sources by package name.`, - ) - } exclude.add(pkg.name) return { name: pkg.name, @@ -188,20 +209,10 @@ export function buildSkillSelectionPlan( } } if (selection.mode === 'individual' && !packageEnabled) { - if ((kindsByName.get(pkg.name)?.size ?? 0) > 1) { - throw new Error( - `Cannot exclude only ${sourceEntry(pkg)} because intent.exclude matches npm and workspace sources by package name.`, - ) - } exclude.add(pkg.name) } else if (selection.mode === 'individual') { for (const [index, entry] of entries.entries()) { if (entry.status === 'excluded') { - if ((kindsByName.get(pkg.name)?.size ?? 0) > 1) { - throw new Error( - `Cannot exclude a skill from only ${sourceEntry(pkg)} because intent.exclude matches npm and workspace sources by package name.`, - ) - } exclude.add(skillExclude(pkg, packageSkills[index]!)) } } @@ -209,6 +220,8 @@ export function buildSkillSelectionPlan( return { name: pkg.name, kind: pkg.kind, skills: entries } }) + assertExclusionsRepresentable(packages, grouped, exclude) + return { skills: [...skills].sort(compareStrings), exclude: [...exclude].sort(compareStrings), diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts index f05e6098..5eb93904 100644 --- a/packages/intent/tests/install-plan.test.ts +++ b/packages/intent/tests/install-plan.test.ts @@ -89,7 +89,7 @@ describe('installer selection planning', () => { ).toThrow('Duplicate') }) - it('preserves workspace identity and rejects unrepresentable exclusions', () => { + it('rejects a selection whose exclusion would hide an enabled skill', () => { const sameName = [ pkg('shared', ['npm-skill']), pkg('shared', ['workspace-skill'], 'workspace'), @@ -102,7 +102,56 @@ describe('installer selection planning', () => { mode: 'individual', enabled: ['workspace:shared#workspace-skill'], }), - ).toThrow('intent.exclude matches npm and workspace sources') + ).toThrow('would also hide "workspace:shared#workspace-skill"') + }) + + it('reports the real collision when a skill alias conflicts within one package', () => { + expect(() => + buildSkillSelectionPlan([pkg('ui', ['theme', 'ui/theme'])], { + mode: 'individual', + enabled: ['ui#theme'], + }), + ).toThrow( + 'Cannot write intent.exclude "ui#ui/theme": it would also hide "ui#theme"', + ) + }) + + it('rejects an exclusion that only collides once consumers trim it', () => { + expect(() => + buildSkillSelectionPlan([pkg('ws', ['drop', 'drop '])], { + mode: 'individual', + enabled: ['ws#drop'], + }), + ).toThrow('would also hide "ws#drop"') + }) + + it('writes a shared skill exclusion when both kinds drop the same skill', () => { + 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', 'workspace:shared']) + expect(plan.exclude).toEqual(['shared#drop']) + }) + + 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 the existing bare package grammar for workspace skill exclusions', () => { From 6cc869012404e78af85d2cffebc5959a77a0e076 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 24 Jul 2026 23:36:03 -0700 Subject: [PATCH 20/37] test: pin audience and declined-package policy status --- packages/intent/tests/install-plan.test.ts | 22 +++++++++++++++++++ .../source-policy-surfaces.test.ts | 20 +++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts index 5eb93904..12c1260c 100644 --- a/packages/intent/tests/install-plan.test.ts +++ b/packages/intent/tests/install-plan.test.ts @@ -260,4 +260,26 @@ describe('installer delta inventory', () => { { id: 'b#two', policy: 'pending', 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/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 5bbede6f..226fd830 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -70,10 +70,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 +87,22 @@ 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('list and load accept packages matched by an allowlist glob', () => { writeJson(join(root, 'package.json'), { name: 'app', From 153f1fc74029d099f52fb75ea1fb41720a6d7ec1 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 07:52:53 -0700 Subject: [PATCH 21/37] feat(policy): enforce skill-level intent.skills entries on every surface --- packages/intent/src/commands/install/plan.ts | 6 +- packages/intent/src/commands/list.ts | 5 +- packages/intent/src/core/excludes.ts | 2 +- packages/intent/src/core/intent-core.ts | 36 +++++ packages/intent/src/core/skill-sources.ts | 100 ++++++++++++-- packages/intent/src/core/source-policy.ts | 128 ++++++++++++++++-- packages/intent/src/core/types.ts | 2 + packages/intent/tests/core.test.ts | 65 +++++++++ packages/intent/tests/install-plan.test.ts | 14 ++ packages/intent/tests/skill-sources.test.ts | 129 +++++++++++++++++- packages/intent/tests/source-policy.test.ts | 131 +++++++++++++++++++ 11 files changed, 587 insertions(+), 31 deletions(-) diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index 3a73ff23..8fde7def 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -4,7 +4,7 @@ import { isSkillExcluded, } from '../../core/excludes.js' import { parseSkillSources } from '../../core/skill-sources.js' -import { isSourcePermitted } from '../../core/source-policy.js' +import { compileSkillSourcePolicy } from '../../core/source-policy.js' import type { IntentLockfileSource, ReadIntentLockfileResult, @@ -248,6 +248,7 @@ export function buildInstallDeltaInventory( ): 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]), @@ -263,7 +264,6 @@ export function buildInstallDeltaInventory( seen.add(key) const current = currentByKey.get(key) const locked = lockedByKey.get(key) - const sourcePermitted = isSourcePermitted(sources, pkg.name, pkg.kind) return { name: pkg.name, kind: pkg.kind, @@ -273,7 +273,7 @@ export function buildInstallDeltaInventory( isSkillExcluded(pkg.name, skill.name, excludes) const policy: InventoryPolicyStatus = excluded ? 'excluded' - : sourcePermitted + : sourcePolicy.permitsSkill(pkg.name, skill.name, pkg.kind) ? 'enabled' : 'pending' if (policy !== 'enabled') diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 0fc45c5e..7230d511 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -101,8 +101,11 @@ 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})`, ) } } diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 81b3e3e7..3c7b2a30 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -143,7 +143,7 @@ export function isPackageExcluded( } // 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 { diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index ff03db34..d7922739 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -13,10 +13,12 @@ import { resolveSkillUseFastPath } from './load-resolution.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, + isSkillPermitted, isSourcePermitted, packageNotListedRefusal, readSkillSourcesConfig, scanForPolicedIntents, + skillNotListedRefusal, } from './source-policy.js' import type { ResolveSkillResult } from '../skills/resolver.js' import type { IntentFsCache } from '../discovery/fs-cache.js' @@ -346,6 +348,21 @@ function resolveIntentSkillInCwd( const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) throw new IntentCoreError(lateRefusal.code, lateRefusal.message) } + if ( + !isSkillPermitted( + config, + parsedUse.packageName, + parsedUse.skillName, + fastPathResolved.kind, + ) + ) { + const lateRefusal = skillNotListedRefusal( + use, + parsedUse.packageName, + parsedUse.skillName, + ) + throw new IntentCoreError(lateRefusal.code, lateRefusal.message) + } return toResolvedIntentSkill( cwd, use, @@ -376,6 +393,25 @@ function resolveIntentSkillInCwd( 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 && + !isSkillPermitted( + config, + 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) 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 5cec769d..d4f3a252 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -8,6 +8,7 @@ import { getEffectiveExcludePatterns, isPackageExcluded, isSkillExcluded, + skillNameVariants, warningMentionsPackage, } from './excludes.js' import { readPackageJson } from './package-json.js' @@ -42,6 +43,7 @@ type LoadRefusalCode = | 'package-excluded' | 'package-not-listed' | 'skill-excluded' + | 'skill-not-listed' export interface LoadRefusal { code: LoadRefusalCode @@ -59,13 +61,20 @@ interface SkillSourceMatcher { packageName: string, packageKind?: 'npm' | 'workspace', ) => boolean + matchesSkill: + | ((packageName: string, skillName: string) => boolean) + | undefined } function compileSkillSourceMatcher( source: ExplicitSkillSource, ): SkillSourceMatcher { if (source.kind === 'git') { - return { source, matchesPackage: () => false } + return { + source, + matchesPackage: () => false, + matchesSkill: undefined, + } } const matchesName = @@ -73,24 +82,39 @@ 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): { +export function compileSkillSourcePolicy(config: SkillSourcesConfig): { matchers: Array permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean + permitsSkill: ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + ) => boolean } { switch (config.mode) { case 'absent': case 'allow-all': - return { matchers: [], permits: () => true } + return { matchers: [], permits: () => true, permitsSkill: () => true } case 'empty': - return { matchers: [], permits: () => false } + return { matchers: [], permits: () => false, permitsSkill: () => false } case 'explicit': { const matchers = config.sources.map(compileSkillSourceMatcher) return { @@ -99,6 +123,13 @@ 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)), + ), } } } @@ -112,6 +143,19 @@ export function isSourcePermitted( return compileSkillSourcePolicy(config).permits(packageName, packageKind) } +export function isSkillPermitted( + config: SkillSourcesConfig, + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', +): boolean { + return compileSkillSourcePolicy(config).permitsSkill( + packageName, + skillName, + packageKind, + ) +} + export function packageNotListedRefusal( use: string, packageName: string, @@ -122,6 +166,17 @@ 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, @@ -143,10 +198,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)) { + const policy = compileSkillSourcePolicy(config) + if (!policy.permits(packageName)) { return packageNotListedRefusal(use, packageName) } + if (!policy.permitsSkill(packageName, skillName)) { + return skillNotListedRefusal(use, packageName, skillName) + } + if (isSkillExcluded(packageName, skillName, excludeMatchers)) { return { code: 'skill-excluded', @@ -177,6 +237,23 @@ 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 @@ -236,22 +313,49 @@ 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 (isSkillExcluded(pkg.name, skill.name, excludeMatchers)) continue + if (!sourcePolicy.permitsSkill(pkg.name, skill.name, pkg.kind)) { + hiddenSkills.push(skill.name) + 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( diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index 8d22b15b..62187676 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -20,6 +20,7 @@ export type IntentAudience = 'agent' | 'human' export interface IntentHiddenSourceSummary { name: string skillCount: number + hiddenSkills?: Array } export interface IntentSkillSummary { @@ -121,6 +122,7 @@ export type IntentCoreErrorCode = | 'package-excluded' | 'package-not-listed' | 'skill-excluded' + | 'skill-not-listed' | 'skill-not-found' | 'skill-not-accepted' | 'skill-content-changed' diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 37de188b..3a74fd47 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -1078,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/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts index 12c1260c..347693ea 100644 --- a/packages/intent/tests/install-plan.test.ts +++ b/packages/intent/tests/install-plan.test.ts @@ -261,6 +261,20 @@ describe('installer delta inventory', () => { ]) }) + 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('records a declined package as excluded rather than pending', () => { const discovered = [pkg('keep', ['one']), pkg('decline', ['two'])] const plan = buildSkillSelectionPlan(discovered, { 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..0c23d54e 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -17,6 +17,7 @@ import { EMPTY_NOTE, MIGRATION_NOTICE, applySourcePolicy, + checkLoadAllowed, readSkillSourcesConfig, } from '../src/core/source-policy.js' import { parseSkillSources } from '../src/core/skill-sources.js' @@ -236,6 +237,136 @@ describe('applySourcePolicy — allowlist matrix', () => { }) }) +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' }, + { + config: 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, { + config: 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, { + config: config(['@other/b#x']), + excludeMatchers: [], + }) + expect(refusal?.code).toBe('package-not-listed') + }) +}) + describe('applySourcePolicy — permit-all and empty modes', () => { it('unqualified exclude hides both an npm and a workspace package of the same name (kind-agnostic, deliberate)', () => { const result = applySourcePolicy( From 906d3bb4227c0a8865e4610fc12d9c171d98cabd Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 09:29:21 -0700 Subject: [PATCH 22/37] refactor(policy): compile the skill source policy once per load --- packages/intent/src/core/intent-core.ts | 19 ++++--- packages/intent/src/core/source-policy.ts | 56 +++++++-------------- packages/intent/tests/source-policy.test.ts | 7 +-- 3 files changed, 31 insertions(+), 51 deletions(-) diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index d7922739..95cdc2ce 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -13,8 +13,7 @@ import { resolveSkillUseFastPath } from './load-resolution.js' import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, - isSkillPermitted, - isSourcePermitted, + compileSkillSourcePolicy, packageNotListedRefusal, readSkillSourcesConfig, scanForPolicedIntents, @@ -326,8 +325,12 @@ function resolveIntentSkillInCwd( 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) } @@ -342,15 +345,12 @@ function resolveIntentSkillInCwd( fsCache, ) if (fastPathResolved) { - if ( - !isSourcePermitted(config, parsedUse.packageName, fastPathResolved.kind) - ) { + if (!sourcePolicy.permits(parsedUse.packageName, fastPathResolved.kind)) { const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) throw new IntentCoreError(lateRefusal.code, lateRefusal.message) } if ( - !isSkillPermitted( - config, + !sourcePolicy.permitsSkill( parsedUse.packageName, parsedUse.skillName, fastPathResolved.kind, @@ -398,8 +398,7 @@ function resolveIntentSkillInCwd( ) if ( survivingPackage && - !isSkillPermitted( - config, + !sourcePolicy.permitsSkill( parsedUse.packageName, parsedUse.skillName, survivingPackage.kind, diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index d4f3a252..0a8ee528 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -66,6 +66,16 @@ interface SkillSourceMatcher { | undefined } +export interface CompiledSkillSourcePolicy { + matchers: Array + permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean + permitsSkill: ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + ) => boolean +} + function compileSkillSourceMatcher( source: ExplicitSkillSource, ): SkillSourceMatcher { @@ -100,15 +110,9 @@ function compileSkillSourceMatcher( } } -export function compileSkillSourcePolicy(config: SkillSourcesConfig): { - matchers: Array - permits: (packageName: string, packageKind?: 'npm' | 'workspace') => boolean - permitsSkill: ( - packageName: string, - skillName: string, - packageKind?: 'npm' | 'workspace', - ) => boolean -} { +export function compileSkillSourcePolicy( + config: SkillSourcesConfig, +): CompiledSkillSourcePolicy { switch (config.mode) { case 'absent': case 'allow-all': @@ -135,27 +139,6 @@ export function compileSkillSourcePolicy(config: SkillSourcesConfig): { } } -export function isSourcePermitted( - config: SkillSourcesConfig, - packageName: string, - packageKind?: 'npm' | 'workspace', -): boolean { - return compileSkillSourcePolicy(config).permits(packageName, packageKind) -} - -export function isSkillPermitted( - config: SkillSourcesConfig, - packageName: string, - skillName: string, - packageKind?: 'npm' | 'workspace', -): boolean { - return compileSkillSourcePolicy(config).permitsSkill( - packageName, - skillName, - packageKind, - ) -} - export function packageNotListedRefusal( use: string, packageName: string, @@ -181,11 +164,11 @@ 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)) { @@ -195,15 +178,12 @@ 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). - const policy = compileSkillSourcePolicy(config) - if (!policy.permits(packageName)) { + // Name-only pre-check: kind isn't known until resolution. + if (!sourcePolicy.permits(packageName)) { return packageNotListedRefusal(use, packageName) } - if (!policy.permitsSkill(packageName, skillName)) { + if (!sourcePolicy.permitsSkill(packageName, skillName)) { return skillNotListedRefusal(use, packageName, skillName) } diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 0c23d54e..c46925ca 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -18,6 +18,7 @@ import { MIGRATION_NOTICE, applySourcePolicy, checkLoadAllowed, + compileSkillSourcePolicy, readSkillSourcesConfig, } from '../src/core/source-policy.js' import { parseSkillSources } from '../src/core/skill-sources.js' @@ -341,7 +342,7 @@ describe('checkLoadAllowed — skill-level allowlist entries', () => { '@scope/a#x', { packageName: '@scope/a', skillName: 'x' }, { - config: config(['@scope/a#x']), + sourcePolicy: compileSkillSourcePolicy(config(['@scope/a#x'])), excludeMatchers: [], }, ), @@ -350,7 +351,7 @@ describe('checkLoadAllowed — skill-level allowlist entries', () => { it('refuses a skill the entry does not name, without claiming the package is unlisted', () => { const refusal = checkLoadAllowed(use, parsed, { - config: config(['@scope/a#x']), + sourcePolicy: compileSkillSourcePolicy(config(['@scope/a#x'])), excludeMatchers: [], }) expect(refusal?.code).toBe('skill-not-listed') @@ -360,7 +361,7 @@ describe('checkLoadAllowed — skill-level allowlist entries', () => { it('still refuses a package that is not listed at all', () => { const refusal = checkLoadAllowed(use, parsed, { - config: config(['@other/b#x']), + sourcePolicy: compileSkillSourcePolicy(config(['@other/b#x'])), excludeMatchers: [], }) expect(refusal?.code).toBe('package-not-listed') From ee746c0ae972ab8709906201cad39f889c396d21 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 09:44:29 -0700 Subject: [PATCH 23/37] perf(load): reuse the already-read config on the full-scan path --- packages/intent/src/core/intent-core.ts | 1 + packages/intent/src/core/source-policy.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index 95cdc2ce..b2383e31 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -388,6 +388,7 @@ function resolveIntentSkillInCwd( scanOptions: withFsCache(scanOptions, fsCache), coreOptions: options, context: projectContext, + config, }) if (droppedNames.includes(parsedUse.packageName)) { const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 0a8ee528..2481b45b 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -390,13 +390,14 @@ 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) From 32ab9efa96200b0d4a5e7abbc1007bf7e23da9d5 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 10:11:31 -0700 Subject: [PATCH 24/37] fix(sync): decline new skills without revoking allowed siblings --- packages/intent/src/commands/sync/command.ts | 13 ++++++- .../intent/tests/consumer-install.test.ts | 39 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index c085d8f9..8d64ed22 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -10,6 +10,7 @@ import { import { resolveProjectContext } from '../../core/project-context.js' import { applySourcePolicy, + compileSkillSourcePolicy, scanForConfiguredIntents, } from '../../core/source-policy.js' import { parseSkillSources } from '../../core/skill-sources.js' @@ -196,10 +197,20 @@ async function reviewNewDependencies({ 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.map((pkg) => pkg.name)]), + ...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( diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 1b357d98..9aba9353 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -457,6 +457,45 @@ describe('consumer install', () => { 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 }, + { + interactive: true, + 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 }, { interactive: false }) + + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), + ).toBe(true) + }) + it('leaves new dependencies pending when review is deferred', async () => { const root = createProject() await runConsumerInstall({ From a983a33bfd37a508fda09aba07090422ec222e39 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 10:23:24 -0700 Subject: [PATCH 25/37] feat(install): record partial skill selections as allow entries --- packages/intent/src/commands/install/plan.ts | 21 ++- packages/intent/src/commands/sync/command.ts | 31 +++- .../intent/tests/consumer-install.test.ts | 134 +++++++++++++++++- packages/intent/tests/install-plan.test.ts | 52 +++---- 4 files changed, 189 insertions(+), 49 deletions(-) diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index 8fde7def..3be40173 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -62,10 +62,6 @@ export function skillSelectionId( return `${sourceEntry(pkg)}#${skill.name}` } -function skillExclude(pkg: IntentPackage, skill: SkillEntry): string { - return `${pkg.name}#${skill.name}` -} - function sortedPackages( packages: ReadonlyArray, ): Array { @@ -169,6 +165,7 @@ export function buildSkillSelectionPlan( 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' && @@ -179,16 +176,15 @@ export function buildSkillSelectionPlan( selection.mode === 'all-found' || packageMatchesScope || (selection.mode === 'individual' && - sortedSkills(pkg).some((skill) => + packageSkills.some((skill) => selected.has(skillSelectionId(pkg, skill)), )) if (selection.mode === 'scope') { skills.add(selection.scope) - } else if (packageEnabled) { + } else if (selection.mode === 'all-found') { skills.add(sourceEntry(pkg)) } - const packageSkills = sortedSkills(pkg) const entries = packageSkills.map((skill) => { const id = skillSelectionId(pkg, skill) const enabled = selection.mode !== 'individual' || selected.has(id) @@ -211,10 +207,13 @@ export function buildSkillSelectionPlan( if (selection.mode === 'individual' && !packageEnabled) { exclude.add(pkg.name) } else if (selection.mode === 'individual') { - for (const [index, entry] of entries.entries()) { - if (entry.status === 'excluded') { - exclude.add(skillExclude(pkg, packageSkills[index]!)) - } + 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 } diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index 8d64ed22..525231dd 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -227,9 +227,38 @@ async function reviewNewDependencies({ 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, ...selectionPlan.skills])].sort( + skills: [...new Set([...config.skills, ...addedSkills])].sort( compareStrings, ), exclude: [...new Set([...config.exclude, ...selectionPlan.exclude])].sort( diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 9aba9353..1ad7b674 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -204,7 +204,7 @@ describe('consumer install', () => { expect(existsSync(join(root, '.agents'))).toBe(false) }) - it('locks and links selected skills while excluding unchecked siblings', async () => { + 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') @@ -231,7 +231,8 @@ describe('consumer install', () => { const config = readIntentConsumerConfig( readFileSync(join(root, 'package.json'), 'utf8'), ) - expect(config.exclude).toEqual(['@tanstack/query#mutations']) + 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 : [], @@ -405,8 +406,11 @@ describe('consumer install', () => { const config = readIntentConsumerConfig( readFileSync(join(root, 'package.json'), 'utf8'), ) - expect(config.skills).toEqual(['@tanstack/new-package', '@tanstack/query']) - expect(config.exclude).toEqual(['@tanstack/new-package#second']) + expect(config.skills).toEqual([ + '@tanstack/new-package#first', + '@tanstack/query', + ]) + expect(config.exclude).toEqual([]) expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ status: 'found', lockfile: { @@ -428,6 +432,128 @@ describe('consumer install', () => { ).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 }, + { + interactive: true, + 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 }, + { + interactive: true, + 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 }, + { + interactive: true, + 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({ diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts index 347693ea..c8c6ef66 100644 --- a/packages/intent/tests/install-plan.test.ts +++ b/packages/intent/tests/install-plan.test.ts @@ -61,17 +61,23 @@ describe('installer selection planning', () => { ]) }) - it('excludes unchecked siblings and packages for individual selection', () => { + 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']) - expect(plan.exclude).toEqual([ - '@other/core', - '@tanstack/query#zeta', - 'workspace-query', - ]) + 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', () => { @@ -105,27 +111,7 @@ describe('installer selection planning', () => { ).toThrow('would also hide "workspace:shared#workspace-skill"') }) - it('reports the real collision when a skill alias conflicts within one package', () => { - expect(() => - buildSkillSelectionPlan([pkg('ui', ['theme', 'ui/theme'])], { - mode: 'individual', - enabled: ['ui#theme'], - }), - ).toThrow( - 'Cannot write intent.exclude "ui#ui/theme": it would also hide "ui#theme"', - ) - }) - - it('rejects an exclusion that only collides once consumers trim it', () => { - expect(() => - buildSkillSelectionPlan([pkg('ws', ['drop', 'drop '])], { - mode: 'individual', - enabled: ['ws#drop'], - }), - ).toThrow('would also hide "ws#drop"') - }) - - it('writes a shared skill exclusion when both kinds drop the same skill', () => { + it('writes kind-aware skill allows when both kinds select a subset', () => { const sameName = [ pkg('shared', ['keep', 'drop']), pkg('shared', ['keep', 'drop'], 'workspace'), @@ -135,8 +121,8 @@ describe('installer selection planning', () => { enabled: ['shared#keep', 'workspace:shared#keep'], }) - expect(plan.skills).toEqual(['shared', 'workspace:shared']) - expect(plan.exclude).toEqual(['shared#drop']) + 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', () => { @@ -154,7 +140,7 @@ describe('installer selection planning', () => { expect(plan.exclude).toEqual(['shared']) }) - it('uses the existing bare package grammar for workspace skill exclusions', () => { + it('uses kind-aware workspace grammar for partial skill allows', () => { const plan = buildSkillSelectionPlan( [pkg('workspace-only', ['enabled', 'excluded'], 'workspace')], { @@ -163,8 +149,8 @@ describe('installer selection planning', () => { }, ) - expect(plan.skills).toEqual(['workspace:workspace-only']) - expect(plan.exclude).toEqual(['workspace-only#excluded']) + expect(plan.skills).toEqual(['workspace:workspace-only#enabled']) + expect(plan.exclude).toEqual([]) }) it('rejects duplicate discovered sources and skills', () => { From a0629880695b12252fab6e05ce88622a24181fe7 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 11:29:36 -0700 Subject: [PATCH 26/37] refactor(hooks): run session hooks through the CLI --- packages/intent/src/cli.ts | 32 ++- packages/intent/src/hooks/adapters.ts | 28 -- packages/intent/src/hooks/install.ts | 259 ++---------------- packages/intent/tests/catalog-api.test.ts | 21 ++ packages/intent/tests/cli.test.ts | 28 ++ packages/intent/tests/hooks-install.test.ts | 206 +------------- .../source-policy-surfaces.test.ts | 147 +++++++++- 7 files changed, 260 insertions(+), 461 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index bb5e5035..a221171b 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -5,6 +5,7 @@ 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' @@ -179,24 +180,41 @@ function createCli(): CAC { '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 } - const { runHooksInstallCommand } = - await import('./commands/hooks/command.js') - runHooksInstallCommand(options) + 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 + } + + fail('Unknown hooks action: expected install or run.') }, ) diff --git a/packages/intent/src/hooks/adapters.ts b/packages/intent/src/hooks/adapters.ts index 604f2643..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-catalog.mjs') - : join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-claude-catalog.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-catalog.mjs') - : join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-codex-catalog.mjs', - ), } }, }, @@ -79,13 +58,6 @@ export const HOOK_AGENT_ADAPTERS: Record = { 'hooks', 'hooks.json', ), - scriptPath: join( - homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-copilot-catalog.mjs', - ), }), }, } diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 37697428..4bc9550e 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -1,10 +1,4 @@ -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, relative } from 'node:path' import { detectPackageManager } from '../discovery/package-manager.js' @@ -63,145 +57,6 @@ export function validateHookInstallOptions({ parseAgents(agents) } -export function buildHookRunnerScript( - agent: HookAgent, - catalogCommand = formatIntentCommand( - detectPackageManager(), - 'list --json --no-notices', - ), -): string { - return `#!/usr/bin/env node -import { readFileSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -import { performance } from 'node:perf_hooks' - -const AGENT = ${JSON.stringify(agent)} -const CATALOG_COMMAND = ${JSON.stringify(catalogCommand)} - -try { - await main() -} catch { -} - -process.exit(0) - -async function main() { - const event = readEventFromStdin() - - if (!isSessionStartEvent(event)) return - - const additionalContext = await createSessionCatalogContext(rootForEvent(event)) - if (additionalContext) { - process.stdout.write(JSON.stringify(sessionStartOutput(additionalContext))) - } -} - -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, - }, - } -} - -` -} - export function formatHookInstallResult(result: HookInstallResult): string { if (result.status === 'skipped') { return `Skipped Intent hooks for ${result.agent}: ${result.reason}` @@ -246,93 +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}`, ) - removeLegacyGateScript(scriptPath) 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), { @@ -340,12 +156,7 @@ function upsertClaudeHooks( hooks: [ { type: 'command', - command: 'node', - args: [ - project - ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-catalog.mjs' - : scriptPath, - ], + command: catalogCommand, timeout: 10, statusMessage: CATALOG_STATUS_MESSAGE, }, @@ -357,8 +168,7 @@ function upsertClaudeHooks( function upsertCodexHooks( config: Record, - project: boolean, - scriptPath: string, + catalogCommand: string, ): Record { const hooks = objectValue(config.hooks) hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { @@ -366,9 +176,7 @@ function upsertCodexHooks( hooks: [ { type: 'command', - command: project - ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-catalog.mjs"' - : `node ${quoteShell(scriptPath)}`, + command: catalogCommand, timeout: 10, statusMessage: CATALOG_STATUS_MESSAGE, }, @@ -380,11 +188,11 @@ function upsertCodexHooks( function upsertCopilotHooks( config: Record, - scriptPath: string, + catalogCommand: string, ): Record { const hooks = objectValue(config.hooks) hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { - command: `node ${quoteShell(scriptPath)}`, + command: catalogCommand, }) hooks.PreToolUse = removeIntentHooks(arrayValue(hooks.PreToolUse)) return { ...config, hooks } @@ -422,20 +230,20 @@ 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|catalog)\.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, + ) ) } -function removeLegacyGateScript(scriptPath: string): void { - const legacyPath = scriptPath.replace(/-catalog\.mjs$/, '-gate.mjs') - if (legacyPath !== scriptPath) rmSync(legacyPath, { force: true }) -} - function updateJsonConfig( filePath: string, update: (config: Record) => Record, @@ -454,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 @@ -521,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/tests/catalog-api.test.ts b/packages/intent/tests/catalog-api.test.ts index 85e3ec2e..b6761e72 100644 --- a/packages/intent/tests/catalog-api.test.ts +++ b/packages/intent/tests/catalog-api.test.ts @@ -133,6 +133,27 @@ describe('runSessionCatalogueHook', () => { 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) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 6fd06e8c..eaba5c77 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -552,6 +552,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( diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index c0cc0bcb..173fa6a6 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -8,11 +8,9 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { spawnSync } from 'node:child_process' 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' @@ -47,6 +45,10 @@ describe('hook installer', () => { 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,8 +76,7 @@ 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-catalog.mjs'], + command: 'pnpm dlx @tanstack/intent@latest hooks run --agent claude', type: 'command', }) expect(claudeConfig.hooks.PreToolUse).toEqual([]) @@ -84,16 +85,16 @@ describe('hook installer', () => { expect(codexConfig.hooks.SessionStart[0].matcher).toBe( 'startup|resume|clear|compact', ) - expect(codexConfig.hooks.SessionStart[0].hooks[0].command).toContain( - '.intent/hooks/intent-codex-catalog.mjs', + expect(codexConfig.hooks.SessionStart[0].hooks[0].command).toBe( + 'pnpm dlx @tanstack/intent@latest hooks run --agent codex', ) expect(codexConfig.hooks.PreToolUse).toEqual([]) expect( existsSync(join(root, '.intent', 'hooks', 'intent-claude-catalog.mjs')), - ).toBe(true) + ).toBe(false) expect( existsSync(join(root, '.intent', 'hooks', 'intent-codex-catalog.mjs')), - ).toBe(true) + ).toBe(false) }) it('installs user-scoped Copilot hooks into the selected home', () => { @@ -113,8 +114,9 @@ describe('hook installer', () => { const config = readJson(join(copilotHome, 'hooks', 'hooks.json')) const sessionCommand = config.hooks.SessionStart[0].command as string - expect(sessionCommand).toContain(join(homeDir, '.tanstack')) - expect(sessionCommand).toContain('intent-copilot-catalog.mjs') + expect(sessionCommand).toBe( + 'npx @tanstack/intent@latest hooks run --agent copilot', + ) expect(config.hooks.PreToolUse).toEqual([]) expect( existsSync( @@ -126,10 +128,10 @@ describe('hook installer', () => { '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( @@ -175,7 +177,7 @@ describe('hook installer', () => { expect(config.hooks.SessionStart).toHaveLength(1) expect(config.hooks.PreToolUse).toHaveLength(1) expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('echo keep') - expect(existsSync(legacyScriptPath)).toBe(false) + expect(existsSync(legacyScriptPath)).toBe(true) expect(second[0]).toMatchObject({ status: 'unchanged' }) }) @@ -294,148 +296,6 @@ describe('hook installer', () => { }) }) - it('builds a catalogue-only runner script', () => { - const script = buildHookRunnerScript('claude') - - expect(script).toContain('const AGENT = "claude"') - expect(script).not.toContain('permissionDecision') - expect(script).not.toContain('PreToolUse') - expect(script).not.toContain('tanstack-intent-hooks') - expect(script).not.toContain('@tanstack/intent/core') - expect(script).not.toContain('createRequire') - }) - - it('ignores edit events before and after a load command', () => { - const root = tempRoot('intent-hooks-runner-') - const scriptPath = join(root, 'intent-claude-catalog.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(beforeLoad.stdout).toBe('') - 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}-catalog.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 emit an edit decision after session catalogue context', () => { - const root = tempRoot('intent-hooks-session-catalog-gate-') - const catalogCommand = writeFakeIntentListCommand(root) - const scriptPath = join( - root, - '.intent', - 'hooks', - 'intent-claude-catalog.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(edit.stdout).toBe('') - }) - - 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('formats skipped install results', () => { expect( formatHookInstallResult({ @@ -451,39 +311,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 JSON.stringify(value) -} diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 226fd830..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 }) }) @@ -103,6 +115,137 @@ describe('source policy — all four surfaces filter excluded and unlisted', () ).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', From df2ecc94d8d8d1f4217adb75f1b18859fac28950 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 12:34:58 -0700 Subject: [PATCH 27/37] feat(install): deliver skills through lifecycle hooks --- .../intent/src/commands/install/config.ts | 3 +- .../intent/src/commands/install/consumer.ts | 114 ++++++++--- .../intent/src/commands/install/prompts.ts | 45 ++--- .../intent/tests/consumer-install.test.ts | 185 +++++++++++++++++- packages/intent/tests/install-config.test.ts | 24 +-- 5 files changed, 303 insertions(+), 68 deletions(-) diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts index 259e40a2..dfa5121e 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -2,7 +2,7 @@ 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' | 'map' +export type InstallMethod = 'symlink' | 'hooks' export type InstallTarget = | 'agents' | 'github' @@ -39,7 +39,6 @@ const INSTALL_METHODS: Readonly< > = { symlink: new Set(INSTALL_TARGETS.map((target) => target.id)), hooks: new Set(['github', 'codex', 'claude']), - map: new Set(INSTALL_TARGETS.map((target) => target.id)), } export function installTargetsForMethod( diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index 9fbe927a..b5bb2282 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -5,6 +5,7 @@ import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state. import { 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' @@ -25,6 +26,7 @@ import type { 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 { @@ -38,6 +40,7 @@ export interface InstallerPrompter { selectMethod: () => Promise selectTargets: (method: InstallMethod) => Promise | null> confirmSymlink: () => Promise + confirmUserScopeHooks: () => Promise selectSkills: ( discovered: ReadonlyArray, ) => Promise @@ -54,6 +57,20 @@ export interface RunConsumerInstallOptions { root: string } +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}".`, + ) + } +} + export async function runConsumerInstall({ discovered, dryRun = false, @@ -72,11 +89,10 @@ export async function runConsumerInstall({ if (!method) return const targets = await prompts.selectTargets(method) if (!targets || targets.length === 0) return - if (method !== 'symlink') { - throw new Error(`Install method "${method}" is not implemented yet.`) + if (method === 'symlink') { + const symlinkAccepted = await prompts.confirmSymlink() + if (!symlinkAccepted) return } - const symlinkAccepted = await prompts.confirmSymlink() - if (!symlinkAccepted) return if (discovered.every((pkg) => pkg.skills.length === 0)) { prompts.complete('No intent-enabled skills found.') return @@ -101,9 +117,14 @@ export async function runConsumerInstall({ if (confirmation === null) return if (confirmation === 'back') continue - const updatedPackageJson = wireIntentSyncPrepare( - updateIntentConsumerConfigText(packageJson, installation.config), + const updatedConsumerConfig = updateIntentConsumerConfigText( + packageJson, + installation.config, ) + const updatedPackageJson = + method === 'symlink' + ? wireIntentSyncPrepare(updatedConsumerConfig) + : updatedConsumerConfig const policy = applySourcePolicy( { packages: [...discovered] }, { @@ -115,25 +136,27 @@ export async function runConsumerInstall({ lockfileVersion: 1 as const, sources: buildCurrentLockfileSources(policy.packages), } - 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 (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) { @@ -154,11 +177,48 @@ export async function runConsumerInstall({ 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) - await runSyncCommand({ cwd: root }, { interactive: false }) + if (method === 'symlink') { + await runSyncCommand({ cwd: root }, { interactive: false }) + 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 ${method}.`, + `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/prompts.ts b/packages/intent/src/commands/install/prompts.ts index 829b5c6e..e3de5b70 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -86,28 +86,15 @@ export function createClackInstallerPrompter(): InstallerPrompter { outro(message) }, async selectMethod(): Promise { - for (;;) { - const method = cancelled( - await select({ - message: 'How do you want to install skills?', - options: [ - { value: 'symlink', label: 'Symlink skill folders' }, - { value: 'hooks', label: 'Install lifecycle hooks' }, - { - value: 'map', - label: 'Add a compact skill map to agent instructions', - }, - ], - }), - ) - if (!method || method === 'symlink') return method - note( - 'This delivery adapter is not available in the current installer slice.', - method === 'hooks' - ? 'Lifecycle hooks are coming next' - : 'Compact skill maps are coming next', - ) - } + 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, @@ -136,6 +123,20 @@ export function createClackInstallerPrompter(): InstallerPrompter { }), ) }, + 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 { diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 1ad7b674..66b09846 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -13,14 +13,35 @@ import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { runInteractiveInstall } from '../src/commands/install/command.js' import { runConsumerInstall } from '../src/commands/install/consumer.js' -import { groupSkillOptions } from '../src/commands/install/prompts.js' +import { + createClackInstallerPrompter, + groupSkillOptions, +} from '../src/commands/install/prompts.js' import { readIntentConsumerConfig } from '../src/commands/install/config.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(), + select: + vi.fn< + (options: { options: Array<{ value: string }> }) => Promise + >(), +})) + +vi.mock('@clack/prompts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + intro: clackPromptMocks.intro, + select: clackPromptMocks.select, + } +}) + const roots: Array = [] function writeJson(path: string, value: unknown): void { @@ -84,6 +105,7 @@ function createPrompts( 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, @@ -112,6 +134,16 @@ describe('consumer install', () => { }) }) + 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 = [] @@ -184,6 +216,123 @@ describe('consumer install', () => { expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) + 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') @@ -278,6 +427,40 @@ describe('consumer install', () => { 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('requires Intent as a project development dependency', async () => { const root = createProject() writeJson(join(root, 'package.json'), { name: 'app', private: true }) diff --git a/packages/intent/tests/install-config.test.ts b/packages/intent/tests/install-config.test.ts index a80d114c..eb07425b 100644 --- a/packages/intent/tests/install-config.test.ts +++ b/packages/intent/tests/install-config.test.ts @@ -30,23 +30,15 @@ describe('installer configuration', () => { expect(installTargetsForMethod('hooks').map((target) => target.id)).toEqual( ['github', 'codex', 'claude'], ) - expect(installTargetsForMethod('map').map((target) => target.id)).toEqual([ - 'agents', - 'github', - 'vscode', - 'cursor', - 'codex', - 'claude', - ]) }) it('rejects an install method unsupported by a selected target', () => { const preferences: IntentInstallPreferences = { - method: 'map', + method: 'hooks', targets: ['github'], } const method: InstallMethod = preferences.method - expect(method).toBe('map') + expect(method).toBe('hooks') expect(() => readIntentConsumerConfig( '{ "intent": { "install": { "method": "hooks", "targets": ["vscode"] } } }', @@ -57,7 +49,7 @@ describe('installer configuration', () => { it('rejects duplicate install targets', () => { expect(() => readIntentConsumerConfig( - '{ "intent": { "install": { "method": "map", "targets": ["agents", "agents"] } } }', + '{ "intent": { "install": { "method": "symlink", "targets": ["agents", "agents"] } } }', ), ).toThrow('Duplicate') }) @@ -65,7 +57,7 @@ describe('installer configuration', () => { it('rejects unknown install fields', () => { expect(() => readIntentConsumerConfig( - '{ "intent": { "install": { "method": "map", "targets": [], "extra": true } } }', + '{ "intent": { "install": { "method": "symlink", "targets": [], "extra": true } } }', ), ).toThrow('Unknown') }) @@ -73,7 +65,7 @@ describe('installer configuration', () => { it('rejects unknown targets, methods, and wrong target types', () => { expect(() => readIntentConsumerConfig( - '{ "intent": { "install": { "method": "map", "targets": ["unknown"] } } }', + '{ "intent": { "install": { "method": "symlink", "targets": ["unknown"] } } }', ), ).toThrow('Unknown install target') expect(() => @@ -83,7 +75,7 @@ describe('installer configuration', () => { ).toThrow('Unknown install method') expect(() => readIntentConsumerConfig( - '{ "intent": { "install": { "method": "map", "targets": "github" } } }', + '{ "intent": { "install": { "method": "symlink", "targets": "github" } } }', ), ).toThrow('array of strings') }) @@ -94,7 +86,7 @@ describe('installer configuration', () => { const updated = updateIntentConsumerConfigText(source, { skills: ['@tanstack/query'], exclude: ['@other/pkg'], - install: { method: 'map', targets: ['github'] }, + install: { method: 'hooks', targets: ['github'] }, }) expect(updated.startsWith('\ufeff')).toBe(true) @@ -105,7 +97,7 @@ describe('installer configuration', () => { expect(readIntentConsumerConfig(updated)).toEqual({ skills: ['@tanstack/query'], exclude: ['@other/pkg'], - install: { method: 'map', targets: ['github'] }, + install: { method: 'hooks', targets: ['github'] }, }) }) From eaae76865665b50164c5f678966d2e0d0c98c6a1 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 13:17:10 -0700 Subject: [PATCH 28/37] feat(install): report deltas and detect agent targets --- packages/intent/src/cli.ts | 14 +- .../intent/src/commands/install/command.ts | 112 +------------ .../intent/src/commands/install/config.ts | 37 +++++ .../intent/src/commands/install/consumer.ts | 58 ++++++- packages/intent/src/commands/install/plan.ts | 40 ++++- .../intent/src/commands/install/prompts.ts | 6 +- packages/intent/tests/cli.test.ts | 54 +++++-- .../intent/tests/consumer-install.test.ts | 152 +++++++++++++++++- packages/intent/tests/install-plan.test.ts | 26 +++ 9 files changed, 361 insertions(+), 138 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index a221171b..afcaa89d 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -138,23 +138,25 @@ function createCli(): CAC { cli .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', 'Preview installation without writing files') - .option( - '--print-prompt', - 'Print the legacy agent setup prompt instead of writing', - ) + .option('--no-input', 'Synchronize without interactive prompts') .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 --no-input') .example('install --map --global') .action(async (options: InstallCommandOptions) => { + if (options.input === false) { + const { runSyncCommand } = await import('./commands/sync/command.js') + await runSyncCommand(options, { interactive: false }) + return + } const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ import('./commands/support.js'), import('./commands/install/command.js'), diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 11596ac5..e49740c1 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -17,113 +17,10 @@ import type { InstallerPrompter } from './consumer.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 - - 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` - export interface InstallCommandOptions extends GlobalScanFlags { dryRun?: boolean + input?: boolean map?: boolean - printPrompt?: boolean } export async function runInteractiveInstall({ @@ -225,18 +122,13 @@ export async function runInstallCommand( options: InstallCommandOptions, scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, ): Promise { - if (options.printPrompt) { - console.log(INSTALL_PROMPT) - return - } - const coreOptions = coreOptionsFromGlobalFlags(options) const noticeOptions = noticeOptionsFromGlobalFlags(options) if (!options.map) { if (!process.stdin.isTTY || !process.stdout.isTTY) { fail( - 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map`.', + 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map` or `intent install --no-input`.', ) } const { createClackInstallerPrompter } = await import('./prompts.js') diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts index dfa5121e..5b3dabcd 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -1,3 +1,5 @@ +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' @@ -49,6 +51,41 @@ export function installTargetsForMethod( ) } +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) } diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index b5bb2282..ebcf032d 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -2,7 +2,10 @@ 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 { writeIntentLockfile } from '../../core/lockfile/lockfile.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' @@ -15,10 +18,16 @@ import { readInstallStateForLinks } from '../sync/state.js' import { toProjectRelativePath } from '../sync/targets.js' import { INSTALL_TARGETS, + detectInstallTargets, hasIntentDevDependency, + readIntentConsumerConfig, updateIntentConsumerConfigText, } from './config.js' -import { buildSkillSelectionPlan } from './plan.js' +import { + buildInstallDeltaInventory, + buildSkillSelectionPlan, + summarizeInstallDeltaInventory, +} from './plan.js' import type { InstallMethod, InstallTarget, @@ -38,7 +47,10 @@ export type InstallConfirmation = 'install' | 'back' | null export interface InstallerPrompter { complete: (message: string) => void selectMethod: () => Promise - selectTargets: (method: InstallMethod) => Promise | null> + selectTargets: ( + method: InstallMethod, + detected: ReadonlyArray, + ) => Promise | null> confirmSymlink: () => Promise confirmUserScopeHooks: () => Promise selectSkills: ( @@ -71,6 +83,10 @@ function hookAgentForTarget(target: InstallTarget): HookAgent { } } +function countSkills(entries: ReadonlyArray<{ skillCount: number }>): number { + return entries.reduce((count, entry) => count + entry.skillCount, 0) +} + export async function runConsumerInstall({ discovered, dryRun = false, @@ -84,12 +100,42 @@ export async function runConsumerInstall({ '@tanstack/intent must be installed as a project devDependency before running `intent install`.', ) } + const existingConfig = readIntentConsumerConfig(packageJson) + const configured = !dryRun && existingConfig.install !== undefined + if (configured) { + const inventory = buildInstallDeltaInventory( + discovered, + buildCurrentLockfileSources(discovered), + readIntentLockfile(join(root, 'intent.lock')), + 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 + ) { + 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 = await prompts.selectMethod() + const method = configured + ? existingConfig.install!.method + : await prompts.selectMethod() if (!method) return - const targets = await prompts.selectTargets(method) + const targets = configured + ? existingConfig.install!.targets + : await prompts.selectTargets(method, detectInstallTargets(root)) if (!targets || targets.length === 0) return - if (method === 'symlink') { + if (!configured && method === 'symlink') { const symlinkAccepted = await prompts.confirmSymlink() if (!symlinkAccepted) return } diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index 3be40173..d6e37089 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -47,14 +47,52 @@ export interface InstallDeltaInventory { }> } +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: IntentPackage): string { +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, diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index e3de5b70..7d8a2840 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -98,14 +98,18 @@ export function createClackInstallerPrompter(): InstallerPrompter { }, 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: installTargetsForMethod(method).map((target) => ({ + options: targets.map((target) => ({ value: target.id, label: target.label, })), + initialValues: detected.filter((target) => supported.has(target)), required: true, }), ) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index eaba5c77..23e7b8f2 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -12,7 +12,6 @@ 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' @@ -378,20 +377,49 @@ describe('cli commands', () => { ) }) - it('prints the install prompt', async () => { + 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(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(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('Unknown option `--printPrompt`') + }) + + 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), + }), ) - 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"') + 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('lists excludes when none are configured', async () => { @@ -517,7 +545,7 @@ describe('cli commands', () => { expect(exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith( - 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map`.', + 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map` or `intent install --no-input`.', ) expect(existsSync(join(root, 'intent.lock'))).toBe(false) expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 66b09846..203d61ca 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -12,12 +12,15 @@ 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 { readIntentConsumerConfig } from '../src/commands/install/config.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' @@ -27,6 +30,7 @@ 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 @@ -38,6 +42,7 @@ vi.mock('@clack/prompts', async (importOriginal) => { return { ...actual, intro: clackPromptMocks.intro, + multiselect: clackPromptMocks.multiselect, select: clackPromptMocks.select, } }) @@ -119,6 +124,51 @@ afterEach(() => { }) 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 @@ -167,6 +217,106 @@ describe('consumer install', () => { 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 prompts = createPrompts() diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts index c8c6ef66..c5a556c6 100644 --- a/packages/intent/tests/install-plan.test.ts +++ b/packages/intent/tests/install-plan.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { buildInstallDeltaInventory, buildSkillSelectionPlan, + summarizeInstallDeltaInventory, } from '../src/commands/install/plan.js' import type { InventoryLockStatus, @@ -171,6 +172,31 @@ describe('installer selection planning', () => { }) 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' From b7c51907f686b84da25e8389a6d09bbebc5da9bd Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 14:12:37 -0700 Subject: [PATCH 29/37] test: pin released config shapes against policy changes --- .../tests/released-config-compat.test.ts | 448 ++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 packages/intent/tests/released-config-compat.test.ts 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') + }) +}) From a3e75454e6f69293acd29ceb7e87035f365cffee Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 14:57:07 -0700 Subject: [PATCH 30/37] feat(install): encourage the Intent devDependency instead of requiring it Running the installer without @tanstack/intent as a devDependency no longer fails. The prepare script is only wired when the dependency is present, and the installer says what that costs: skills will not re-sync automatically. --- .../intent/src/commands/install/consumer.ts | 14 +++--- .../intent/src/commands/install/prompts.ts | 3 ++ .../intent/tests/consumer-install.test.ts | 50 ++++++++++++++----- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index ebcf032d..bb9e779b 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -45,6 +45,7 @@ interface ConsumerInstallConfig extends IntentConsumerConfig { export type InstallConfirmation = 'install' | 'back' | null export interface InstallerPrompter { + advisory: (message: string) => void complete: (message: string) => void selectMethod: () => Promise selectTargets: ( @@ -95,11 +96,7 @@ export async function runConsumerInstall({ }: RunConsumerInstallOptions): Promise { const packageJsonPath = join(root, 'package.json') const packageJson = readFileSync(packageJsonPath, 'utf8') - if (!hasIntentDevDependency(packageJson)) { - throw new Error( - '@tanstack/intent must be installed as a project devDependency before running `intent install`.', - ) - } + const intentDevDependency = hasIntentDevDependency(packageJson) const existingConfig = readIntentConsumerConfig(packageJson) const configured = !dryRun && existingConfig.install !== undefined if (configured) { @@ -168,7 +165,7 @@ export async function runConsumerInstall({ installation.config, ) const updatedPackageJson = - method === 'symlink' + method === 'symlink' && intentDevDependency ? wireIntentSyncPrepare(updatedConsumerConfig) : updatedConsumerConfig const policy = applySourcePolicy( @@ -232,6 +229,11 @@ export async function runConsumerInstall({ 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 }, { interactive: false }) prompts.complete( diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index 7d8a2840..d0c15cf1 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -82,6 +82,9 @@ export async function selectClackSkills( 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) }, diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 203d61ca..9081cd3a 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -106,6 +106,7 @@ function createPrompts( overrides: Partial = {}, ): InstallerPrompter { return { + advisory: () => {}, complete: () => {}, selectMethod: () => Promise.resolve('symlink'), selectTargets: () => Promise.resolve(['agents']), @@ -319,7 +320,8 @@ describe('consumer install', () => { it('installs confirmed skills with policy, lock state, and managed links', async () => { const root = createProject() - const prompts = createPrompts() + const advisory = vi.fn() + const prompts = createPrompts({ advisory }) await runInteractiveInstall({ cwd: root, @@ -364,6 +366,7 @@ describe('consumer install', () => { }, }) 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 () => { @@ -611,21 +614,44 @@ describe('consumer install', () => { expect(existsSync(join(root, '.copilot'))).toBe(false) }) - it('requires Intent as a project development dependency', async () => { + it('installs without Intent as a project development dependency', async () => { const root = createProject() writeJson(join(root, 'package.json'), { name: 'app', private: true }) - const prompts = createPrompts() + const advisory = vi.fn() + const prompts = createPrompts({ advisory }) - await expect( - runConsumerInstall({ - discovered: scanForIntents(root, { scope: 'local' }).packages, - prompts, - root, - }), - ).rejects.toThrow( - '@tanstack/intent must be installed as a project devDependency before running `intent install`.', + 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.', ) - expect(existsSync(join(root, 'intent.lock'))).toBe(false) }) it('stops without skill selection when discovery is empty', async () => { From fa7f70f99760383d3ca643753b76fae9e9d8205f Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 15:13:41 -0700 Subject: [PATCH 31/37] fix(install): pin the version in generated Intent commands Hook configs and AGENTS.md blocks are written once and executed on every agent session, so `@latest` meant a file written today would silently run a future major version. Generated commands now carry the writing CLI's own version: an exact pin for prereleases, since npm ranges exclude them, and major.minor otherwise so committed files stay stable across patch releases. The AGENTS.md verifier accepts pinned specifiers alongside the older forms. --- .../intent/src/commands/install/guidance.ts | 2 +- packages/intent/src/shared/command-runner.ts | 49 ++++++++++-- packages/intent/tests/cli.test.ts | 19 +++-- packages/intent/tests/hooks-install.test.ts | 26 +++++- packages/intent/tests/install-writer.test.ts | 79 ++++++++++++++----- 5 files changed, 140 insertions(+), 35 deletions(-) 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/shared/command-runner.ts b/packages/intent/src/shared/command-runner.ts index f757a6be..1c0ef651 100644 --- a/packages/intent/src/shared/command-runner.ts +++ b/packages/intent/src/shared/command-runner.ts @@ -1,11 +1,50 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { PackageManager } from './types.js' +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/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 23e7b8f2..9c096eab 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -15,11 +15,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 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 { @@ -634,7 +639,7 @@ describe('cli commands', () => { 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(root) @@ -981,10 +986,10 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#fetching', + `Load: npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching`, ) expect(output).toContain( - 'Load: npx @tanstack/intent@latest load @tanstack/query#query/cache', + `Load: npx @tanstack/intent@${intentPackagePin} load @tanstack/query#query/cache`, ) }) @@ -1104,9 +1109,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) => { @@ -1430,7 +1435,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) }) diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 173fa6a6..e95e5196 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -7,15 +7,24 @@ 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 { HOOK_AGENT_ADAPTERS } from '../src/hooks/adapters.js' import { 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)) { @@ -34,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) @@ -76,7 +93,7 @@ describe('hook installer', () => { 'startup|resume|clear|compact', ) expect(claudeConfig.hooks.SessionStart[0].hooks[0]).toMatchObject({ - command: 'pnpm dlx @tanstack/intent@latest hooks run --agent claude', + command: `pnpm dlx @tanstack/intent@${intentPackagePin} hooks run --agent claude`, type: 'command', }) expect(claudeConfig.hooks.PreToolUse).toEqual([]) @@ -86,7 +103,7 @@ describe('hook installer', () => { 'startup|resume|clear|compact', ) expect(codexConfig.hooks.SessionStart[0].hooks[0].command).toBe( - 'pnpm dlx @tanstack/intent@latest hooks run --agent codex', + `pnpm dlx @tanstack/intent@${intentPackagePin} hooks run --agent codex`, ) expect(codexConfig.hooks.PreToolUse).toEqual([]) expect( @@ -115,8 +132,9 @@ describe('hook installer', () => { const sessionCommand = config.hooks.SessionStart[0].command as string expect(sessionCommand).toBe( - 'npx @tanstack/intent@latest hooks run --agent copilot', + `npx @tanstack/intent@${intentPackagePin} hooks run --agent copilot`, ) + expect(sessionCommand).toContain('@tanstack/intent') expect(config.hooks.PreToolUse).toEqual([]) expect( existsSync( 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" ` From 25b39eeb6487ef12267579e959076a4e196f4c75 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 20:58:28 -0700 Subject: [PATCH 32/37] perf(catalog): load yaml and semver only when they are used --- packages/intent/src/discovery/scanner.ts | 24 ++++++++---- .../intent/src/setup/workspace-patterns.ts | 13 ++++++- packages/intent/src/shared/utils.ts | 12 +++++- .../tests/integration/catalog-bundle.test.ts | 39 +++++++++++++++++++ 4 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 packages/intent/tests/integration/catalog-bundle.test.ts diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 6abf0df0..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') { @@ -405,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 { @@ -421,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/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/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/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)) + } + } + } + }) +}) From 3dfe63f3492d0ec0ed616f07251a78422117cb21 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 21:11:28 -0700 Subject: [PATCH 33/37] fix(catalog): keep human-facing notices out of agent context --- packages/intent/src/session-catalog.ts | 4 +- packages/intent/tests/session-catalog.test.ts | 95 ++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/packages/intent/src/session-catalog.ts b/packages/intent/src/session-catalog.ts index 11048520..a7b1c7bf 100644 --- a/packages/intent/src/session-catalog.ts +++ b/packages/intent/src/session-catalog.ts @@ -19,7 +19,7 @@ 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 = 1 +const CACHE_SCHEMA_VERSION = 2 const DEFAULT_MAX_CONTEXT_BYTES = 8_000 const DEFAULT_MAX_SKILLS = 50 const MIN_CONTEXT_BYTES = 512 @@ -98,7 +98,7 @@ export function buildSessionCatalogue( } }) .sort((left, right) => compareOrdinal(left.id, right.id)) - const allWarnings = [...result.warnings, ...result.notices] + const allWarnings = result.warnings .map(normalizeWhitespace) .filter((warning) => warning && !containsLocalPath(warning)) .map((warning) => truncateText(warning, MAX_WARNING_LENGTH)) diff --git a/packages/intent/tests/session-catalog.test.ts b/packages/intent/tests/session-catalog.test.ts index f0929d30..ccafbace 100644 --- a/packages/intent/tests/session-catalog.test.ts +++ b/packages/intent/tests/session-catalog.test.ts @@ -1,4 +1,10 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +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' @@ -22,6 +28,7 @@ function tempRoot(name: string): string { function result( skills: Array<{ use: string; description: string }>, warnings: Array = [], + notices: Array = [], ): IntentSkillList { return { packageManager: 'pnpm', @@ -45,7 +52,7 @@ function result( hiddenSourceCount: 0, hiddenSources: [], warnings, - notices: [], + notices, conflicts: [], } } @@ -110,9 +117,93 @@ describe('session catalogue formatting', () => { 'Use /users/:id and /posts/:slug routes', ) }) + + it('includes warnings and excludes human-facing notices', () => { + const catalogue = buildSessionCatalogue( + result([], ['Agent warning'], ['Maintainer notice']), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue.totalWarningCount).toBe(1) + expect(context).toContain('- Agent warning') + expect(context).not.toContain('Maintainer notice') + }) + + it('reports omitted warnings from the warning count only', () => { + const catalogue = buildSessionCatalogue( + result( + [], + Array.from({ length: 12 }, (_, index) => `Warning ${index + 1}`), + ['Maintainer notice'], + ), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue.totalWarningCount).toBe(12) + expect(catalogue.warnings).toHaveLength(10) + expect(context).toContain('- 2 additional warnings omitted.') + expect(context).not.toContain('Maintainer notice') + }) + + it('omits the warnings section when only notices are present', () => { + const catalogue = buildSessionCatalogue( + result([], [], ['Maintainer notice']), + ) + const context = formatSessionCatalogue(catalogue) + + expect(catalogue.totalWarningCount).toBe(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') From 9ad2f594c407eb8f1cb7275e654d99e0f89c42e8 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 21:32:52 -0700 Subject: [PATCH 34/37] fix(list): stop spending agent context on output agents cannot use --- .../intent/src/commands/install/command.ts | 11 +- packages/intent/src/commands/list.ts | 39 ++++-- packages/intent/tests/cli.test.ts | 113 +++++++++++++++++- 3 files changed, 145 insertions(+), 18 deletions(-) diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index e49740c1..3ddacf47 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,5 +1,6 @@ import { relative } from 'node:path' import { fail } from '../../shared/cli-error.js' +import { detectIntentAudience } from '../../shared/environment.js' import { coreOptionsFromGlobalFlags, noticeOptionsFromGlobalFlags, @@ -201,5 +202,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/list.ts b/packages/intent/src/commands/list.ts index 7230d511..78e969c9 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -82,11 +82,11 @@ function getPackageSkills( } 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 { @@ -143,7 +143,9 @@ export async function runListCommand( console.log() printWarnings(result.warnings) } - printNotices(result.notices, noticeOptions) + if (audience === 'human') { + printNotices(result.notices, noticeOptions) + } return } @@ -151,13 +153,15 @@ 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) @@ -181,6 +185,12 @@ 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) { console.log(` ${pkg.name}`) @@ -188,7 +198,10 @@ export async function runListCommand( getPackageSkills(pkg, skillsByPackageRoot).map((skill) => ({ name: skill.skillName, description: skill.description, - loadCommand: formatLoadCommand(skill, result.packageManager, scopeFlag), + loadCommand: + audience === 'human' + ? formatLoadCommand(skill.use, result.packageManager, scopeFlag) + : undefined, type: skill.type, })), { nameWidth, packageName: pkg.name, showTypes }, @@ -197,5 +210,7 @@ export async function runListCommand( } printWarnings(result.warnings) - printNotices(result.notices, noticeOptions) + if (audience === 'human') { + printNotices(result.notices, noticeOptions) + } } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 9c096eab..cc2687fa 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -627,21 +627,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@${intentPackagePin} load @tanstack/query#fetching"`, ) expect(content).not.toContain('load:') + expect(content).not.toContain('snapshot') expect(content).not.toContain(root) logSpy.mockClear() @@ -656,6 +663,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( @@ -965,6 +998,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', @@ -979,18 +1014,75 @@ 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: npx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching`, + `Load: pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#fetching`, ) expect(output).toContain( - `Load: npx @tanstack/intent@${intentPackagePin} load @tanstack/query#query/cache`, + `Load: pnpm dlx @tanstack/intent@${intentPackagePin} load @tanstack/query#query/cache`, ) + expect(output.match(/Load:/g)).toHaveLength(2) + }) + + 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( + `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 () => { @@ -1060,7 +1152,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') @@ -1127,6 +1219,7 @@ describe('cli commands', () => { description: 'Query fetching skill', }) + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) @@ -1190,6 +1283,7 @@ describe('cli commands', () => { }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) @@ -1249,6 +1343,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']) @@ -1427,6 +1522,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']) @@ -2051,16 +2147,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 () => { From deb5a1b2a96dcd19efc76cad3139b3b16586221f Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 21:43:18 -0700 Subject: [PATCH 35/37] stop sending discovery warnings to agents & remove redundant work from skill content hashing --- packages/intent/src/core/lockfile/hash.ts | 20 +++--- packages/intent/src/session-catalog.ts | 71 ++----------------- packages/intent/tests/session-catalog.test.ts | 21 +++--- 3 files changed, 24 insertions(+), 88 deletions(-) diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index 814eb08a..fb74b3a3 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -24,6 +24,7 @@ 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 @@ -65,14 +66,14 @@ function hashEntries(entries: ReadonlyArray): string { compareStrings(a.path, b.path), )) { const path = Buffer.from(entry.path, 'utf8') - hash.update(Buffer.from(String(path.length), 'ascii')) - hash.update(Buffer.from([0])) + hash.update(String(path.length), 'ascii') + hash.update(HASH_ENTRY_SEPARATOR) hash.update(path) - hash.update(Buffer.from([0])) - hash.update(Buffer.from(String(entry.content.length), 'ascii')) - hash.update(Buffer.from([0])) + hash.update(HASH_ENTRY_SEPARATOR) + hash.update(String(entry.content.length), 'ascii') + hash.update(HASH_ENTRY_SEPARATOR) hash.update(entry.content) - hash.update(Buffer.from([0])) + hash.update(HASH_ENTRY_SEPARATOR) } return `sha256-${hash.digest('hex')}` } @@ -133,12 +134,7 @@ export function computeSkillContentHash({ skillDir, fs = nodeHashReadFs, }: ComputeSkillContentHashOptions): string { - const realPackageRoot = resolveInPackage( - fs, - resolve(packageRoot), - fs.realpathSync(resolve(packageRoot)), - 'package root', - ) + const realPackageRoot = fs.realpathSync(resolve(packageRoot)) const requestedSkillDir = isAbsolute(skillDir) ? resolve(skillDir) : resolve(packageRoot, skillDir) diff --git a/packages/intent/src/session-catalog.ts b/packages/intent/src/session-catalog.ts index a7b1c7bf..4a635f06 100644 --- a/packages/intent/src/session-catalog.ts +++ b/packages/intent/src/session-catalog.ts @@ -19,13 +19,11 @@ 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 = 2 +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 MAX_WARNING_COUNT = 10 -const MAX_WARNING_LENGTH = 300 const FINGERPRINT_FILES = [ 'package.json', 'intent.lock', @@ -55,8 +53,6 @@ export interface CatalogueVerificationEntry { export interface SessionCatalogue { skills: Array totalSkillCount: number - totalWarningCount: number - warnings: Array } export interface DiscoveredSessionCatalogue { @@ -98,16 +94,9 @@ export function buildSessionCatalogue( } }) .sort((left, right) => compareOrdinal(left.id, right.id)) - const allWarnings = result.warnings - .map(normalizeWhitespace) - .filter((warning) => warning && !containsLocalPath(warning)) - .map((warning) => truncateText(warning, MAX_WARNING_LENGTH)) - return { skills: allSkills.slice(0, maxSkills), totalSkillCount: allSkills.length, - totalWarningCount: allWarnings.length, - warnings: allWarnings.slice(0, MAX_WARNING_COUNT), } } @@ -122,12 +111,7 @@ export function formatSessionCatalogue( ) } if (catalogue.skills.length === 0) { - return fitWarnings( - ['No available Intent skills.'], - catalogue.warnings, - catalogue.totalWarningCount, - maxBytes, - ) + return 'No available Intent skills.' } const baseLines = ['Available Intent skills:', ''] @@ -165,12 +149,7 @@ export function formatSessionCatalogue( 'Session catalogue maxBytes must be large enough for complete guidance.', ) } - return fitWarnings( - lines, - catalogue.warnings, - catalogue.totalWarningCount, - maxBytes, - ) + return lines.join('\n') } export function resolveCatalogueWorkspaceRoot(cwd: string): string { @@ -338,45 +317,6 @@ 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 fitWarnings( - lines: Array, - warnings: Array, - totalWarningCount: number, - maxBytes: number, -): string { - const warningLines: Array = [] - - for (const warning of warnings) { - const nextWarningLines = [...warningLines, `- ${warning}`] - const omitted = totalWarningCount - nextWarningLines.length - const candidateLines = [ - ...lines, - '', - 'Warnings:', - ...nextWarningLines, - ...(omitted > 0 ? [formatOmittedWarnings(omitted)] : []), - ] - if (!fits(candidateLines, maxBytes)) break - warningLines.push(nextWarningLines.at(-1)!) - } - - const omitted = totalWarningCount - warningLines.length - if (warningLines.length === 0 && omitted === 0) return lines.join('\n') - - const outputLines = [ - ...lines, - '', - 'Warnings:', - ...warningLines, - ...(omitted > 0 ? [formatOmittedWarnings(omitted)] : []), - ] - return fits(outputLines, maxBytes) ? outputLines.join('\n') : lines.join('\n') -} - -function formatOmittedWarnings(count: number): string { - return `- ${count} additional ${count === 1 ? 'warning' : 'warnings'} omitted.` -} - function fits(lines: Array, maxBytes: number): boolean { return Buffer.byteLength(lines.join('\n')) <= maxBytes } @@ -410,10 +350,7 @@ function isCatalogue(value: unknown): value is SessionCatalogue { return ( Array.isArray(catalogue.skills) && catalogue.skills.every(isSkillSummary) && - typeof catalogue.totalSkillCount === 'number' && - typeof catalogue.totalWarningCount === 'number' && - Array.isArray(catalogue.warnings) && - catalogue.warnings.every((warning) => typeof warning === 'string') + typeof catalogue.totalSkillCount === 'number' ) } diff --git a/packages/intent/tests/session-catalog.test.ts b/packages/intent/tests/session-catalog.test.ts index ccafbace..2149ac89 100644 --- a/packages/intent/tests/session-catalog.test.ts +++ b/packages/intent/tests/session-catalog.test.ts @@ -84,6 +84,7 @@ describe('session catalogue formatting', () => { '@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) }) @@ -99,6 +100,8 @@ describe('session catalogue formatting', () => { ) 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/) }) @@ -118,18 +121,18 @@ describe('session catalogue formatting', () => { ) }) - it('includes warnings and excludes human-facing notices', () => { + it('excludes warnings and human-facing notices', () => { const catalogue = buildSessionCatalogue( result([], ['Agent warning'], ['Maintainer notice']), ) const context = formatSessionCatalogue(catalogue) - expect(catalogue.totalWarningCount).toBe(1) - expect(context).toContain('- Agent warning') + expect(catalogue).toEqual({ skills: [], totalSkillCount: 0 }) + expect(context).not.toContain('Agent warning') expect(context).not.toContain('Maintainer notice') }) - it('reports omitted warnings from the warning count only', () => { + it('excludes warnings regardless of warning count', () => { const catalogue = buildSessionCatalogue( result( [], @@ -139,19 +142,19 @@ describe('session catalogue formatting', () => { ) const context = formatSessionCatalogue(catalogue) - expect(catalogue.totalWarningCount).toBe(12) - expect(catalogue.warnings).toHaveLength(10) - expect(context).toContain('- 2 additional warnings omitted.') + 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 the warnings section when only notices are present', () => { + it('omits diagnostics when only notices are present', () => { const catalogue = buildSessionCatalogue( result([], [], ['Maintainer notice']), ) const context = formatSessionCatalogue(catalogue) - expect(catalogue.totalWarningCount).toBe(0) + expect(catalogue).toEqual({ skills: [], totalSkillCount: 0 }) expect(context).not.toContain('Warnings:') expect(context).not.toContain('Maintainer notice') }) From 11e9f3917d6a0939350006c51e55d790fa8e1446 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 25 Jul 2026 22:03:45 -0700 Subject: [PATCH 36/37] feat(list): add --why to explain why each skill is or is not available --- packages/intent/src/cli.ts | 4 +- packages/intent/src/commands/list.ts | 54 ++++- packages/intent/src/core/excludes.ts | 23 ++ packages/intent/src/core/intent-core.ts | 57 ++++- packages/intent/src/core/source-policy.ts | 103 +++++++- packages/intent/src/core/types.ts | 7 + packages/intent/src/shared/display.ts | 4 + packages/intent/tests/cli.test.ts | 245 ++++++++++++++++++++ packages/intent/tests/hash.test.ts | 21 ++ packages/intent/tests/source-policy.test.ts | 87 +++++++ 10 files changed, 581 insertions(+), 24 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index afcaa89d..f361ebba 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -26,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') @@ -36,10 +36,12 @@ 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) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 78e969c9..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,6 +83,18 @@ 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( skillUse: string, packageManager: ScanResult['packageManager'], @@ -89,7 +103,11 @@ function formatLoadCommand( 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.', @@ -107,6 +125,9 @@ function printHiddenSources(result: IntentSkillList, audience: string): void { ? ` ${source.name} (${count} not listed: ${source.hiddenSkills.join(', ')})` : ` ${source.name} (${count})`, ) + if (why) { + console.log(' Hidden because not listed in intent.skills') + } } } @@ -114,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) @@ -134,10 +157,10 @@ 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() @@ -166,14 +189,24 @@ export async function runListCommand( 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, })), ) @@ -192,17 +225,18 @@ export async function runListCommand( } 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, + description: 'excluded' in skill ? '(excluded)' : skill.description, loadCommand: - audience === 'human' + audience === 'human' && !('excluded' in skill) ? formatLoadCommand(skill.use, result.packageManager, scopeFlag) : undefined, type: skill.type, + why: skill.why, })), { nameWidth, packageName: pkg.name, showTypes }, ) diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 3c7b2a30..74856276 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -142,6 +142,16 @@ 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. export function skillNameVariants( packageName: string, @@ -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 b2383e31..02d47147 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -26,6 +26,7 @@ import type { ScanOptions, ScanScope } from '../shared/types.js' import type { IntentCoreErrorCode, IntentCoreOptions, + IntentExcludedSkillSummary, IntentSkillList, IntentSkillSummary, LoadedIntentSkill, @@ -36,6 +37,7 @@ import type { export type { IntentCoreErrorCode, IntentCoreOptions, + IntentExcludedSkillSummary, IntentPackageSummary, IntentSkillListDebug, IntentSkillList, @@ -104,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 => { @@ -124,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, } }), ) @@ -145,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, diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 2481b45b..ab686f5a 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -4,6 +4,8 @@ import { ALLOW_ALL_NOTICE } from '../shared/cli-output.js' import { compileExcludePatterns, compileWildcardPattern, + findPackageExcludeMatch, + findSkillExcludeMatch, getConfigDirs, getEffectiveExcludePatterns, isPackageExcluded, @@ -50,11 +52,16 @@ export interface LoadRefusal { 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: ( @@ -74,6 +81,15 @@ export interface CompiledSkillSourcePolicy { skillName: string, packageKind?: 'npm' | 'workspace', ) => boolean + explainPermits: ( + packageName: string, + packageKind?: 'npm' | 'workspace', + ) => SkillSourcePolicyDecision + explainPermitsSkill: ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + ) => SkillSourcePolicyDecision } function compileSkillSourceMatcher( @@ -116,9 +132,21 @@ export function compileSkillSourcePolicy( switch (config.mode) { case 'absent': case 'allow-all': - return { matchers: [], permits: () => true, permitsSkill: () => true } + return { + matchers: [], + permits: () => true, + permitsSkill: () => true, + explainPermits: () => ({ permitted: true, source: null }), + explainPermitsSkill: () => ({ permitted: true, source: null }), + } case 'empty': - return { matchers: [], permits: () => false, permitsSkill: () => 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 { @@ -134,6 +162,27 @@ export function compileSkillSourcePolicy( (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, + } + }, } } } @@ -237,8 +286,16 @@ function formatUnlistedSkillNotice( 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({ @@ -282,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') { @@ -296,11 +368,23 @@ export function applySourcePolicy( const skills: Array = [] const hiddenSkills: Array = [] for (const skill of pkg.skills) { - if (isSkillExcluded(pkg.name, skill.name, excludeMatchers)) continue 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) { @@ -352,8 +436,10 @@ export function applySourcePolicy( return { hiddenSourceCount: hiddenSources.length, hiddenSources, + excludedSkills, packages, notices, + sourcePolicy, } } @@ -380,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: { @@ -400,7 +489,6 @@ export function scanForPolicedIntents(params: { const config = params.config ?? readSkillSourcesConfig(cwd, context) const excludePatterns = getEffectiveExcludePatterns(coreOptions, context) const excludeMatchers = compileExcludePatterns(excludePatterns) - const policy = applySourcePolicy(scanResult, { audience, config, @@ -417,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, @@ -431,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 62187676..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' @@ -33,6 +34,11 @@ export interface IntentSkillSummary { description: string type?: string framework?: string + why?: string +} + +export interface IntentExcludedSkillSummary extends IntentSkillSummary { + excluded: true } export interface IntentPackageSummary { @@ -46,6 +52,7 @@ export interface IntentPackageSummary { export interface IntentSkillList { packageManager: PackageManager skills: Array + excludedSkills?: Array packages: Array hiddenSourceCount: number hiddenSources: Array 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/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index cc2687fa..7c4bcc15 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1036,6 +1036,220 @@ describe('cli commands', () => { 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( + '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) @@ -1120,6 +1334,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) diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index 498bcf1d..326dccbb 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -28,6 +28,27 @@ afterEach(() => { }) 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({ diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index c46925ca..7113d3ec 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { compileExcludePatterns, compileWildcardPattern, + findPackageExcludeMatch, + findSkillExcludeMatch, } from '../src/core/excludes.js' import { ALLOW_ALL_NOTICE, @@ -236,6 +238,91 @@ 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> { From 7b54a05943c8033595c27a3a4ec2a5a0c9f185bc Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 26 Jul 2026 09:59:25 -0700 Subject: [PATCH 37/37] feat(install): support declarative CI bootstrap --- packages/intent/src/cli.ts | 11 +- .../intent/src/commands/install/command.ts | 117 +++- .../intent/src/commands/install/config.ts | 5 + .../intent/src/commands/install/consumer.ts | 31 +- packages/intent/src/commands/install/plan.ts | 71 ++- packages/intent/src/commands/sync/command.ts | 14 +- packages/intent/tests/cli.test.ts | 603 +++++++++++++++++- .../intent/tests/consumer-install.test.ts | 106 ++- packages/intent/tests/install-plan.test.ts | 53 ++ 9 files changed, 956 insertions(+), 55 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index f361ebba..55ec68af 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -144,7 +144,10 @@ function createCli(): CAC { ) .option('--map', 'Write explicit skill-to-task mappings') .option('--dry-run', 'Preview installation without writing files') - .option('--no-input', 'Synchronize without interactive prompts') + .option( + '--no-input', + 'Install or synchronize from package.json without prompts', + ) .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') @@ -154,10 +157,8 @@ function createCli(): CAC { .example('install --no-input') .example('install --map --global') .action(async (options: InstallCommandOptions) => { - if (options.input === false) { - const { runSyncCommand } = await import('./commands/sync/command.js') - await runSyncCommand(options, { interactive: false }) - return + if (options.map && options.input === false) { + fail('Cannot combine --map and --no-input.') } const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ import('./commands/support.js'), diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 3ddacf47..118fd1c6 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,4 +1,5 @@ -import { relative } from 'node:path' +import { readFileSync } from 'node:fs' +import { join, relative } from 'node:path' import { fail } from '../../shared/cli-error.js' import { detectIntentAudience } from '../../shared/environment.js' import { @@ -15,9 +16,98 @@ import { } 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' +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, + }) +} + +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 @@ -33,23 +123,11 @@ export async function runInteractiveInstall({ dryRun?: boolean prompts: InstallerPrompter }): Promise { - const [ - { runConsumerInstall }, - { resolveProjectContext }, - { scanForIntents }, - ] = await Promise.all([ - import('./consumer.js'), - import('../../core/project-context.js'), - import('../../discovery/scanner.js'), - ]) + const { resolveProjectContext } = + await import('../../core/project-context.js') const context = resolveProjectContext({ cwd }) const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd - await runConsumerInstall({ - discovered: scanForIntents(root, { scope: 'local' }).packages, - dryRun, - prompts, - root, - }) + await runInstallWithPrompts({ dryRun, prompts, root }) } function formatTargetPath(targetPath: string): string { @@ -123,13 +201,18 @@ export async function runInstallCommand( options: InstallCommandOptions, scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, ): Promise { + if (options.input === false) { + await runDeclarativeInstall(options) + return + } + const coreOptions = coreOptionsFromGlobalFlags(options) const noticeOptions = noticeOptionsFromGlobalFlags(options) if (!options.map) { if (!process.stdin.isTTY || !process.stdout.isTTY) { fail( - 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map` or `intent install --no-input`.', + '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`.', ) } const { createClackInstallerPrompter } = await import('./prompts.js') diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts index 5b3dabcd..0c723d2a 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -161,6 +161,11 @@ export function hasIntentDevDependency(text: string): boolean { ) } +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 diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index bb9e779b..a26e5a9d 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -68,6 +68,7 @@ export interface RunConsumerInstallOptions { dryRun?: boolean prompts: InstallerPrompter root: string + selection?: SkillSelection } function hookAgentForTarget(target: InstallTarget): HookAgent { @@ -93,17 +94,19 @@ export async function runConsumerInstall({ 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 configured = !dryRun && existingConfig.install !== undefined - if (configured) { + const install = dryRun ? undefined : existingConfig.install + if (install) { + const existingLock = readIntentLockfile(join(root, 'intent.lock')) const inventory = buildInstallDeltaInventory( discovered, buildCurrentLockfileSources(discovered), - readIntentLockfile(join(root, 'intent.lock')), + existingLock, existingConfig, ) const summary = summarizeInstallDeltaInventory(inventory) @@ -114,7 +117,8 @@ export async function runConsumerInstall({ newDependencies === 0 && newSkills === 0 && changed === 0 && - summary.removed === 0 + summary.removed === 0 && + existingLock.status === 'found' ) { prompts.complete('Project is up to date.') return @@ -124,23 +128,24 @@ export async function runConsumerInstall({ ) } for (;;) { - const method = configured - ? existingConfig.install!.method - : await prompts.selectMethod() + const method = install ? install.method : await prompts.selectMethod() if (!method) return - const targets = configured - ? existingConfig.install!.targets + const targets = install + ? install.targets : await prompts.selectTargets(method, detectInstallTargets(root)) if (!targets || targets.length === 0) return - if (!configured && method === 'symlink') { + if (!install && method === 'symlink') { const symlinkAccepted = await prompts.confirmSymlink() if (!symlinkAccepted) return } - if (discovered.every((pkg) => pkg.skills.length === 0)) { + if ( + fixedSelection === undefined && + discovered.every((pkg) => pkg.skills.length === 0) + ) { prompts.complete('No intent-enabled skills found.') return } - const selection = await prompts.selectSkills(discovered) + const selection = fixedSelection ?? (await prompts.selectSkills(discovered)) if (!selection) return const plan = buildSkillSelectionPlan(discovered, selection) const installation = { @@ -235,7 +240,7 @@ export async function runConsumerInstall({ ) } if (method === 'symlink') { - await runSyncCommand({ cwd: root }, { interactive: false }) + await runSyncCommand({ cwd: root }, { review: 'reminder' }) prompts.complete( `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using ${method}.`, ) diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index d6e37089..3d3523f7 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -9,6 +9,9 @@ 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' @@ -16,6 +19,11 @@ 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 @@ -100,6 +108,25 @@ export function skillSelectionId( 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 { @@ -181,6 +208,35 @@ export function buildSkillSelectionPlan( ): 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') { @@ -305,14 +361,13 @@ export function buildInstallDeltaInventory( name: pkg.name, kind: pkg.kind, skills: sortedSkills(pkg).map((skill) => { - const excluded = - isPackageExcluded(pkg.name, excludes) || - isSkillExcluded(pkg.name, skill.name, excludes) - const policy: InventoryPolicyStatus = excluded - ? 'excluded' - : sourcePolicy.permitsSkill(pkg.name, skill.name, pkg.kind) - ? 'enabled' - : 'pending' + 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) diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index 525231dd..53ea3bd1 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -51,8 +51,10 @@ export interface SyncReviewPrompter { ) => Promise } +export type SyncReviewMode = 'interactive' | 'reminder' | 'fail' + export interface SyncCommandRuntime { - interactive?: boolean + review?: SyncReviewMode prompts?: SyncReviewPrompter } @@ -165,7 +167,7 @@ function shouldReviewInteractively( ) { return false } - if (runtime.interactive !== undefined) return runtime.interactive + if (runtime.review !== undefined) return runtime.review === 'interactive' return process.stdin.isTTY === true && process.stdout.isTTY === true } @@ -408,6 +410,14 @@ export async function runSyncCommand( 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) => [ diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 7c4bcc15..e035be39 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, realpathSync, rmSync, writeFileSync, @@ -389,6 +390,577 @@ describe('cli commands', () => { 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(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(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) @@ -427,6 +999,35 @@ describe('cli commands', () => { ).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 () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-list-empty-')) tempDirs.push(root) @@ -550,7 +1151,7 @@ describe('cli commands', () => { expect(exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith( - 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map` or `intent install --no-input`.', + '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) diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 9081cd3a..3a09e2df 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -749,7 +749,7 @@ describe('consumer install', () => { await runSyncCommand( { cwd: root }, { - interactive: true, + review: 'interactive', prompts: { complete: () => {}, reviewNewDependencies: () => Promise.resolve('review'), @@ -809,7 +809,7 @@ describe('consumer install', () => { await runSyncCommand( { cwd: root }, { - interactive: true, + review: 'interactive', prompts: { complete: () => {}, reviewNewDependencies: () => Promise.resolve('review'), @@ -850,7 +850,7 @@ describe('consumer install', () => { await runSyncCommand( { cwd: root }, { - interactive: true, + review: 'interactive', prompts: { complete: () => {}, reviewNewDependencies: () => Promise.resolve('review'), @@ -886,7 +886,7 @@ describe('consumer install', () => { await runSyncCommand( { cwd: root }, { - interactive: true, + review: 'interactive', prompts: { complete: () => {}, reviewNewDependencies: () => Promise.resolve('review'), @@ -926,7 +926,7 @@ describe('consumer install', () => { await runSyncCommand( { cwd: root }, { - interactive: true, + review: 'interactive', prompts: { complete: () => {}, reviewNewDependencies: () => Promise.resolve('exclude'), @@ -959,7 +959,7 @@ describe('consumer install', () => { await runSyncCommand( { cwd: root }, { - interactive: true, + review: 'interactive', prompts: { complete: () => {}, reviewNewDependencies: () => Promise.resolve('exclude'), @@ -974,13 +974,101 @@ describe('consumer install', () => { expect(config.exclude).toContain('demo-pkg#beta') expect(config.exclude).not.toContain('demo-pkg') - await runSyncCommand({ cwd: root }, { interactive: false }) + 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({ @@ -995,7 +1083,7 @@ describe('consumer install', () => { await runSyncCommand( { cwd: root }, { - interactive: true, + review: 'interactive', prompts: { complete: () => {}, reviewNewDependencies: () => Promise.resolve('later'), @@ -1027,7 +1115,7 @@ describe('consumer install', () => { await runSyncCommand( { cwd: root }, { - interactive: true, + review: 'interactive', prompts: { complete: () => {}, reviewNewDependencies: () => diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts index c5a556c6..1e9a0c21 100644 --- a/packages/intent/tests/install-plan.test.ts +++ b/packages/intent/tests/install-plan.test.ts @@ -38,6 +38,45 @@ const discovered = [ ] 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' }), @@ -287,6 +326,20 @@ describe('installer delta inventory', () => { ]) }) + 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, {