From 9a04a8b3e5436b9b929e8c147c19a370662b39a3 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 19 Jul 2026 20:39:35 -0700 Subject: [PATCH 01/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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, { From 6ddb1ea4489ddbc7b8b8236db245f40eeeaa6515 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 26 Jul 2026 10:44:26 -0700 Subject: [PATCH 38/56] fix(catalog): enforce cache ownership boundaries --- packages/intent/src/session-catalog.ts | 137 +++++++- packages/intent/tests/session-catalog.test.ts | 323 +++++++++++++++++- 2 files changed, 443 insertions(+), 17 deletions(-) diff --git a/packages/intent/src/session-catalog.ts b/packages/intent/src/session-catalog.ts index 4a635f06..408314ab 100644 --- a/packages/intent/src/session-catalog.ts +++ b/packages/intent/src/session-catalog.ts @@ -1,6 +1,12 @@ import { + chmodSync, + closeSync, + constants, existsSync, + fstatSync, + lstatSync, mkdirSync, + openSync, readFileSync, realpathSync, renameSync, @@ -8,7 +14,7 @@ import { writeFileSync, } from 'node:fs' import { createHash } from 'node:crypto' -import { tmpdir } from 'node:os' +import { homedir, tmpdir, userInfo } 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' @@ -24,6 +30,7 @@ const DEFAULT_MAX_CONTEXT_BYTES = 8_000 const DEFAULT_MAX_SKILLS = 50 const MIN_CONTEXT_BYTES = 512 const MAX_DESCRIPTION_LENGTH = 180 +const warnedCacheDirectories = new Set() const FINGERPRINT_FILES = [ 'package.json', 'intent.lock', @@ -157,14 +164,7 @@ export function resolveCatalogueWorkspaceRoot(cwd: string): string { return normalizeRoot(context.workspaceRoot ?? context.packageRoot ?? cwd) } -export async function getSessionCatalogue({ - cacheDir = join(tmpdir(), 'tanstack-intent', 'catalogues'), - discover, - refresh = false, - root, - policyRoot = root, - readFs, -}: { +export async function getSessionCatalogue(options: { cacheDir?: string discover: () => | DiscoveredSessionCatalogue @@ -174,6 +174,15 @@ export async function getSessionCatalogue({ policyRoot?: string readFs?: ReadFs }): Promise { + const { + cacheDir: suppliedCacheDir, + discover, + refresh = false, + root, + policyRoot = root, + readFs, + } = options + const cache = prepareCacheDirectory(suppliedCacheDir) const workspaceRoot = normalizeRoot(root) const normalizedPolicyRoot = normalizeRoot(policyRoot) const dependencyFingerprint = computeCatalogueFingerprint( @@ -181,10 +190,10 @@ export async function getSessionCatalogue({ normalizedPolicyRoot, ) const cachePath = join( - cacheDir, + cache.path, `${createHash('sha256').update(workspaceRoot).update('\0').update(normalizedPolicyRoot).digest('hex')}.json`, ) - const cached = readCache(cachePath) + const cached = cache.enabled ? readCache(cachePath) : null if ( !refresh && @@ -210,7 +219,7 @@ export async function getSessionCatalogue({ catalogue, verification: refreshed.verification, } - writeCache(cachePath, entry) + if (cache.enabled) writeCache(cachePath, entry) return { cachePath, @@ -321,12 +330,108 @@ function fits(lines: Array, maxBytes: number): boolean { return Buffer.byteLength(lines.join('\n')) <= maxBytes } +function defaultCacheDirectory(): string { + const tempRoot = realpathSync.native(tmpdir()) + const userKey = + typeof process.getuid === 'function' + ? String(process.getuid()) + : createHash('sha256') + .update(userInfo().username) + .update('\0') + .update(homedir()) + .digest('hex') + .slice(0, 12) + return join(tempRoot, `tanstack-intent-${userKey}-catalogues`) +} + +function prepareCacheDirectory(suppliedPath?: string): { + path: string + enabled: boolean +} { + let path = suppliedPath + try { + const isDefault = path === undefined + path = suppliedPath ?? defaultCacheDirectory() + if (typeof process.getuid === 'function' && isDefault) { + const tempRoot = lstatSync(dirname(path)) + const isPrivateOwnerDirectory = + tempRoot.uid === process.getuid() && (tempRoot.mode & 0o022) === 0 + if ( + tempRoot.isSymbolicLink() || + !tempRoot.isDirectory() || + (!isPrivateOwnerDirectory && (tempRoot.mode & 0o1000) === 0) + ) { + throw new Error('Unsafe temporary directory') + } + } + + mkdirSync(path, { recursive: true, mode: 0o700 }) + const initial = lstatSync(path) + if (initial.isSymbolicLink() || !initial.isDirectory()) { + throw new Error('Unsafe cache directory') + } + + if (typeof process.getuid === 'function') { + if (initial.uid !== process.getuid()) { + throw new Error('Unsafe cache directory owner') + } + if (isDefault && (initial.mode & 0o077) !== 0) { + chmodSync(path, 0o700) + const tightened = lstatSync(path) + if ( + tightened.dev !== initial.dev || + tightened.ino !== initial.ino || + tightened.isSymbolicLink() || + !tightened.isDirectory() || + tightened.uid !== process.getuid() || + (tightened.mode & 0o077) !== 0 + ) { + throw new Error('Cache directory changed while securing it') + } + } else if (!isDefault && (initial.mode & 0o022) !== 0) { + throw new Error('Writable cache directory') + } + } + + return { path, enabled: true } + } catch { + path ??= join(tmpdir(), 'tanstack-intent-catalogues-disabled') + if (!warnedCacheDirectories.has(path)) { + warnedCacheDirectories.add(path) + process.stderr.write( + `[intent catalog] rejected cache directory ${path}; caching is disabled.\n`, + ) + } + return { path, enabled: false } + } +} + function readCache(path: string): IntentSessionCatalogueCache | null { + let descriptor: number | undefined try { - const value = JSON.parse(readFileSync(path, 'utf8')) as unknown + const flags = + typeof process.getuid === 'function' + ? constants.O_RDONLY | constants.O_NOFOLLOW + : constants.O_RDONLY + descriptor = openSync(path, flags) + const file = fstatSync(descriptor) + if ( + !file.isFile() || + (typeof process.getuid === 'function' && + (file.uid !== process.getuid() || (file.mode & 0o022) !== 0)) + ) { + return null + } + const value = JSON.parse(readFileSync(descriptor, 'utf8')) as unknown return isCacheEntry(value) ? value : null } catch { return null + } finally { + if (descriptor !== undefined) { + try { + closeSync(descriptor) + } catch {} + } } } @@ -375,8 +480,10 @@ function isVerificationEntry( 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' }) + writeFileSync(temporaryPath, `${JSON.stringify(entry)}\n`, { + flag: 'wx', + mode: 0o600, + }) renameSync(temporaryPath, path) } catch { try { diff --git a/packages/intent/tests/session-catalog.test.ts b/packages/intent/tests/session-catalog.test.ts index 2149ac89..1558fdb4 100644 --- a/packages/intent/tests/session-catalog.test.ts +++ b/packages/intent/tests/session-catalog.test.ts @@ -1,13 +1,20 @@ import { + chmodSync, + existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, + statSync, + symlinkSync, + unlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' import { buildSessionCatalogue, formatSessionCatalogue, @@ -161,6 +168,318 @@ describe('session catalogue formatting', () => { }) describe('session catalogue cache', () => { + it('fails open when the default temporary directory cannot be resolved', async () => { + const root = tempRoot('intent-catalog-missing-temp-') + const missingTempRoot = join(root, 'missing') + writeFileSync(join(root, 'package.json'), '{}') + const originalTmpdir = process.env.TMPDIR + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + + try { + process.env.TMPDIR = missingTempRoot + vi.resetModules() + const catalog = await import('../src/session-catalog.js') + const discovered = await catalog.getSessionCatalogue({ + root, + discover: () => ({ + result: result([ + { + use: '@fixture/package#core', + description: 'Safe guidance', + }, + ]), + verification: [], + }), + }) + + expect(discovered.cacheStatus).toBe('miss') + expect(catalog.formatSessionCatalogue(discovered.catalogue)).toContain( + 'Safe guidance', + ) + expect(existsSync(discovered.cachePath)).toBe(false) + expect( + stderr.mock.calls.filter(([chunk]) => + String(chunk).includes('caching is disabled'), + ), + ).toHaveLength(1) + } finally { + if (originalTmpdir === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = originalTmpdir + vi.resetModules() + stderr.mockRestore() + } + }) + + it('uses a private immediate child of the current temporary directory by default', async ({ + skip, + }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-default-root-') + const importTempRoot = tempRoot('intent-catalog-import-temp-') + const defaultTempRoot = tempRoot('intent-catalog-default-temp-') + const realDefaultTempRoot = realpathSync.native(defaultTempRoot) + writeFileSync(join(root, 'package.json'), '{}') + const originalTmpdir = process.env.TMPDIR + let discoveries = 0 + + try { + process.env.TMPDIR = importTempRoot + vi.resetModules() + const { getSessionCatalogue: getFreshSessionCatalogue } = + await import('../src/session-catalog.js') + process.env.TMPDIR = defaultTempRoot + const options = { + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: [] } + }, + } + + const first = await getFreshSessionCatalogue(options) + const cacheDir = dirname(first.cachePath) + + expect(dirname(cacheDir)).toBe(realDefaultTempRoot) + expect(basename(cacheDir)).toMatch(/^tanstack-intent-.*-catalogues$/) + expect(statSync(cacheDir).mode & 0o777).toBe(0o700) + expect(statSync(first.cachePath).mode & 0o777).toBe(0o600) + + chmodSync(cacheDir, 0o755) + const second = await getFreshSessionCatalogue(options) + + expect(second.cacheStatus).toBe('hit') + expect(second.cachePath).toBe(first.cachePath) + expect(statSync(cacheDir).mode & 0o777).toBe(0o700) + expect(discoveries).toBe(1) + } finally { + if (originalTmpdir === undefined) delete process.env.TMPDIR + else process.env.TMPDIR = originalTmpdir + vi.resetModules() + } + }) + + it('bypasses a supplied cache directory symlink', async ({ skip }) => { + const root = tempRoot('intent-catalog-directory-symlink-') + const targetDir = join(root, 'target') + const cacheDir = join(root, 'cache') + mkdirSync(targetDir) + writeFileSync(join(root, 'package.json'), '{}') + try { + symlinkSync(targetDir, cacheDir, 'dir') + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + ['EACCES', 'ENOTSUP', 'EPERM'].includes(String(error.code)) + ) { + skip() + return + } + throw error + } + + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: [] } + }, + } + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + + try { + const first = await getSessionCatalogue(options) + const second = await getSessionCatalogue(options) + const cacheWarnings = stderr.mock.calls.filter(([chunk]) => + String(chunk).includes('caching is disabled'), + ) + + expect(first.cacheStatus).toBe('miss') + expect(second.cacheStatus).toBe('miss') + expect(discoveries).toBe(2) + expect(existsSync(first.cachePath)).toBe(false) + expect(cacheWarnings).toHaveLength(1) + expect(String(cacheWarnings[0]?.[0])).toContain(cacheDir) + } finally { + stderr.mockRestore() + } + }) + + it('does not serve a cache file symlink', async ({ skip }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-file-symlink-') + const cacheDir = join(root, 'cache') + const externalCachePath = join(root, 'external-cache.json') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { + result: result([ + { + use: '@fixture/package#core', + description: 'Safe guidance', + }, + ]), + verification: [], + } + }, + } + const first = await getSessionCatalogue(options) + const persisted = JSON.parse(readFileSync(first.cachePath, 'utf8')) as { + catalogue: { skills: Array<{ description: string }> } + } + persisted.catalogue.skills[0]!.description = 'Modified guidance' + writeFileSync(externalCachePath, JSON.stringify(persisted)) + unlinkSync(first.cachePath) + try { + symlinkSync(externalCachePath, first.cachePath) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + ['EACCES', 'ENOTSUP', 'EPERM'].includes(String(error.code)) + ) { + skip() + return + } + throw error + } + + const second = await getSessionCatalogue(options) + + expect(second.cacheStatus).toBe('miss') + expect(second.catalogue.skills[0]?.description).toBe('Safe guidance') + expect(discoveries).toBe(2) + expect(lstatSync(first.cachePath).isSymbolicLink()).toBe(false) + expect(lstatSync(first.cachePath).isFile()).toBe(true) + }) + + it('replaces a writable cache file instead of serving it', async ({ + skip, + }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-writable-file-') + 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: 'Safe guidance', + }, + ]), + verification: [], + } + }, + } + const first = await getSessionCatalogue(options) + const persisted = JSON.parse(readFileSync(first.cachePath, 'utf8')) as { + catalogue: { skills: Array<{ description: string }> } + } + persisted.catalogue.skills[0]!.description = 'Modified guidance' + writeFileSync(first.cachePath, JSON.stringify(persisted)) + chmodSync(first.cachePath, 0o666) + + const second = await getSessionCatalogue(options) + + expect(second.cacheStatus).toBe('miss') + expect(second.catalogue.skills[0]?.description).toBe('Safe guidance') + expect(second.cachePath).toBe(first.cachePath) + expect(discoveries).toBe(2) + expect(lstatSync(second.cachePath).isFile()).toBe(true) + expect(statSync(second.cachePath).mode & 0o777).toBe(0o600) + }) + + it('creates custom cache directories and files with private modes', async ({ + skip, + }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-private-modes-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + + const catalogue = await getSessionCatalogue({ + cacheDir, + root, + discover: () => ({ result: result([]), verification: [] }), + }) + + expect(statSync(cacheDir).mode & 0o777).toBe(0o700) + expect(statSync(catalogue.cachePath).mode & 0o777).toBe(0o600) + }) + + it('bypasses an existing writable custom cache directory without changing its mode', async ({ + skip, + }) => { + if (typeof process.getuid !== 'function') { + skip() + return + } + + const root = tempRoot('intent-catalog-permissive-directory-') + const cacheDir = join(root, 'cache') + mkdirSync(cacheDir) + chmodSync(cacheDir, 0o777) + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true) + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: [] } + }, + } + + try { + const first = await getSessionCatalogue(options) + const second = await getSessionCatalogue(options) + + expect(first.cacheStatus).toBe('miss') + expect(second.cacheStatus).toBe('miss') + expect(discoveries).toBe(2) + expect(existsSync(first.cachePath)).toBe(false) + expect(statSync(cacheDir).mode & 0o777).toBe(0o777) + } finally { + stderr.mockRestore() + } + }) + it('recomputes a persisted catalogue from an older schema version', async () => { const root = tempRoot('intent-catalog-stale-schema-') const cacheDir = join(root, 'cache') From e28dfbe0e2f8177ffd99def5d532746eccc0d149 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 26 Jul 2026 21:38:38 -0700 Subject: [PATCH 39/56] fix(sync): preserve trust and contain managed links --- packages/intent/src/commands/sync/command.ts | 148 ++++++++++++++---- packages/intent/src/commands/sync/prompts.ts | 11 +- packages/intent/src/commands/sync/state.ts | 40 ++++- packages/intent/tests/cli.test.ts | 4 +- .../intent/tests/consumer-install.test.ts | 137 +++++++++++++++- packages/intent/tests/sync.test.ts | 73 +++++++++ 6 files changed, 360 insertions(+), 53 deletions(-) diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index 53ea3bd1..5c1f1c29 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -33,6 +33,8 @@ import type { SkillSelection } from '../install/plan.js' import type { LinkReconciliation } from './links.js' import type { IntentPackage } from '../../shared/types.js' +type SyncSkillSelection = Extract + export interface SyncCommandOptions { cwd?: string dryRun?: boolean @@ -48,10 +50,10 @@ export interface SyncReviewPrompter { ) => Promise selectSkills: ( packages: ReadonlyArray, - ) => Promise + ) => Promise } -export type SyncReviewMode = 'interactive' | 'reminder' | 'fail' +type SyncReviewMode = 'interactive' | 'reminder' | 'fail' export interface SyncCommandRuntime { review?: SyncReviewMode @@ -124,7 +126,7 @@ function output( `Intent sync: ${result.created.length} created, ${result.repaired.length} repaired, ${result.removed.length} removed.`, ) printReminder( - 'New dependencies with skills found', + 'Pending skills by source', result.newDependencies, interactiveReview ? 'Choose how to handle them below.' @@ -176,6 +178,7 @@ async function reviewNewDependencies({ discovered, lock, packages, + reviewedPackages, prompts, root, }: { @@ -183,6 +186,7 @@ async function reviewNewDependencies({ discovered: Array lock: Extract, { status: 'found' }> packages: Array + reviewedPackages: Array prompts: SyncReviewPrompter root: string }): Promise { @@ -193,7 +197,7 @@ async function reviewNewDependencies({ })), ) if (!decision || decision === 'later') { - prompts.complete('New dependencies remain pending.') + prompts.complete('Pending skills remain pending review.') return } const packageJsonPath = join(root, 'package.json') @@ -219,33 +223,45 @@ async function reviewNewDependencies({ packageJsonPath, updateIntentConsumerConfigText(packageJson, updatedConfig), ) - prompts.complete('Excluded new skill dependencies.') + prompts.complete('Excluded pending skills.') return } const selection = await prompts.selectSkills(packages) if (!selection) { - prompts.complete('New dependencies remain pending.') + prompts.complete('Pending skills remain pending review.') return } - const selectionPlan = buildSkillSelectionPlan(packages, selection) + const pendingSelectionPlan = buildSkillSelectionPlan(packages, selection) + const pendingSkillIds = new Set( + packages.flatMap((pkg) => + pkg.skills.map((skill) => `${sourceName(pkg)}#${skill.name}`), + ), + ) + const selectionPlan = buildSkillSelectionPlan(reviewedPackages, { + ...selection, + enabled: [ + ...new Set([ + ...selection.enabled, + ...reviewedPackages.flatMap((pkg) => + pkg.skills + .map((skill) => `${sourceName(pkg)}#${skill.name}`) + .filter((id) => !pendingSkillIds.has(id)), + ), + ]), + ], + }) + const packageExcludes = new Set(selectionPlan.exclude) + const narrowedExcludes = packages.flatMap((pkg) => + pendingSelectionPlan.exclude.includes(pkg.name) && + !packageExcludes.has(pkg.name) + ? pkg.skills.map((skill) => `${pkg.name}#${skill.name}`) + : [], + ) 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 && @@ -263,9 +279,13 @@ async function reviewNewDependencies({ skills: [...new Set([...config.skills, ...addedSkills])].sort( compareStrings, ), - exclude: [...new Set([...config.exclude, ...selectionPlan.exclude])].sort( - compareStrings, - ), + exclude: [ + ...new Set([ + ...config.exclude, + ...selectionPlan.exclude, + ...narrowedExcludes, + ]), + ].sort(compareStrings), } const policy = applySourcePolicy( { packages: discovered }, @@ -274,17 +294,61 @@ async function reviewNewDependencies({ excludeMatchers: compileExcludePatterns(updatedConfig.exclude), }, ) - const reviewedKeys = new Set(packages.map(sourceKey)) const currentSources = buildCurrentLockfileSources(policy.packages) + const selectedPendingIds = new Set(selection.enabled) + const selectedPathsBySource = new Map( + packages.flatMap((pkg) => { + const paths = pkg.skills + .filter((skill) => + selectedPendingIds.has(`${sourceName(pkg)}#${skill.name}`), + ) + .map((skill) => `skills/${skill.name}`) + return paths.length > 0 ? [[sourceKey(pkg), new Set(paths)] as const] : [] + }), + ) + const selectedCurrentSources = new Map( + currentSources.flatMap((source) => { + const key = `${source.kind}\0${source.id}` + const selectedPaths = selectedPathsBySource.get(key) + if (!selectedPaths) return [] + return [ + [ + key, + { + ...source, + skills: source.skills.filter((skill) => + selectedPaths.has(skill.path), + ), + }, + ] as const, + ] + }), + ) + const lockedKeys = new Set( + lock.lockfile.sources.map((source) => `${source.kind}\0${source.id}`), + ) 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}`), - ), + ...lock.lockfile.sources.map((source) => { + const selectedSource = selectedCurrentSources.get( + `${source.kind}\0${source.id}`, + ) + if (!selectedSource) return source + const selectedPaths = new Set( + selectedSource.skills.map((skill) => skill.path), + ) + return { + ...source, + skills: [ + ...source.skills.filter((skill) => !selectedPaths.has(skill.path)), + ...selectedSource.skills, + ], + } + }), + ...[...selectedCurrentSources.entries()] + .filter(([key]) => !lockedKeys.has(key)) + .map(([, source]) => source), ], } const expected = buildSyncLinkPlan({ @@ -439,6 +503,29 @@ export async function runSyncCommand( }, ] }) + const reviewedKeys = new Set(packages.map(sourceKey)) + const enabledSkills = new Map( + policy.packages.map((pkg) => [ + sourceKey(pkg), + new Set(pkg.skills.map((skill) => skill.name)), + ]), + ) + const reviewedPackages = discovered.flatMap((pkg) => { + const key = sourceKey(pkg) + if (!reviewedKeys.has(key)) return [] + const pending = pendingSkills.get(key) + const enabled = enabledSkills.get(key) + return [ + { + ...pkg, + skills: pkg.skills.filter( + (skill) => + pending?.has(skill.name) === true || + enabled?.has(skill.name) === true, + ), + }, + ] + }) const prompts = runtime.prompts ?? (await import('./prompts.js')).createClackSyncReviewPrompter() @@ -447,6 +534,7 @@ export async function runSyncCommand( discovered, lock, packages, + reviewedPackages, prompts, root, }) diff --git a/packages/intent/src/commands/sync/prompts.ts b/packages/intent/src/commands/sync/prompts.ts index aaacea66..2d1caa5f 100644 --- a/packages/intent/src/commands/sync/prompts.ts +++ b/packages/intent/src/commands/sync/prompts.ts @@ -9,19 +9,20 @@ export function createClackSyncReviewPrompter(): SyncReviewPrompter { }, async reviewNewDependencies(): Promise { const decision = await select({ - message: 'How do you want to handle these dependencies?', + message: 'How do you want to handle these pending skills?', options: [ { value: 'review', label: 'Review and install' }, - { value: 'exclude', label: 'Exclude these packages' }, + { value: 'exclude', label: 'Exclude pending skills' }, { value: 'later', label: 'Remind me later' }, ], }) if (!isCancel(decision)) return decision - cancel('Sync review cancelled. New dependencies remain pending.') + cancel('Sync review cancelled. Pending skills remain pending review.') return null }, - selectSkills(packages) { - return selectClackSkills(packages, false) + async selectSkills(packages) { + const selection = await selectClackSkills(packages, false) + return selection?.mode === 'individual' ? selection : null }, } } diff --git a/packages/intent/src/commands/sync/state.ts b/packages/intent/src/commands/sync/state.ts index e3e64642..39670501 100644 --- a/packages/intent/src/commands/sync/state.ts +++ b/packages/intent/src/commands/sync/state.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' +import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path' import { writeTextFileAtomic } from '../../shared/atomic-write.js' export const INSTALL_STATE_PATH = '.intent/install-state.json' @@ -43,6 +43,19 @@ function parseEntry(value: unknown): InstallStateEntry | null { if ( typeof value.targetDirectory !== 'string' || typeof value.path !== 'string' || + value.path.length === 0 || + posix.isAbsolute(value.path) || + value.path.includes('\\') || + /^[A-Za-z]:/.test(value.path) || + value.path + .split('/') + .some( + (segment) => + segment === '' || + segment === '.' || + segment === '..' || + /[. ]$/.test(segment), + ) || typeof value.alias !== 'string' || typeof value.skillPath !== 'string' || typeof value.linkTarget !== 'string' || @@ -97,15 +110,26 @@ export function readInstallState(root: string): ReadInstallStateResult { export function readInstallStateForLinks(root: string): ReadInstallStateResult { const result = readInstallState(root) if (result.status !== 'found') return result + const projectRoot = resolve(root) + const entries = result.state.entries.map((entry) => ({ + ...entry, + path: resolve(projectRoot, ...entry.path.split('/')), + })) + if ( + entries.some((entry) => { + const projectRelativePath = relative(projectRoot, entry.path) + return ( + projectRelativePath === '..' || + projectRelativePath.startsWith(`..${sep}`) || + isAbsolute(projectRelativePath) + ) + }) + ) { + return { status: 'malformed' } + } return { status: 'found', - state: { - version: 1, - entries: result.state.entries.map((entry) => ({ - ...entry, - path: join(root, ...entry.path.split('/')), - })), - }, + state: { version: 1, entries }, } } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index e035be39..f3358349 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -334,7 +334,7 @@ describe('cli commands', () => { expect(await main(['sync'])).toBe(0) expect(logSpy.mock.calls.flat().join('\n')).toContain( [ - 'New dependencies with skills found:', + 'Pending skills by source:', '', 'pending 1 skill', '', @@ -791,7 +791,7 @@ describe('cli commands', () => { expect(await main(['install', '--no-input'])).toBe(1) expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'New dependencies with skills found:', + 'Pending skills by source:', ) expect(errorSpy).toHaveBeenCalledWith( 'Intent sync requires review before automation can continue.', diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 3a09e2df..6ac74f21 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -833,9 +833,13 @@ describe('consumer install', () => { ).toBe(true) }) - it('keeps an existing package-level entry when accepting a new skill', async () => { + it('accepts a pending skill without rebaselining a changed sibling', async () => { const root = createProject() - addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + rmSync(join(root, 'node_modules', '@tanstack', 'query'), { + recursive: true, + force: true, + }) + addSkillPackage(root, 'demo-pkg', ['alpha']) await runConsumerInstall({ discovered: scanForIntents(root, { scope: 'local' }).packages, prompts: createPrompts(), @@ -843,9 +847,18 @@ describe('consumer install', () => { }) const packageJsonPath = join(root, 'package.json') const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) - packageJson.intent.skills = ['demo-pkg'] - packageJson.intent.exclude = ['@tanstack/query'] + packageJson.intent.skills = ['demo-pkg#alpha'] writeJson(packageJsonPath, packageJson) + const lockBefore = readIntentLockfile(join(root, 'intent.lock')) + expect(lockBefore.status).toBe('found') + if (lockBefore.status !== 'found') return + const alphaHash = lockBefore.lockfile.sources[0]!.skills[0]!.contentHash + writeFileSync( + join(root, 'node_modules', 'demo-pkg', 'skills', 'alpha', 'SKILL.md'), + '---\nname: alpha\ndescription: changed alpha guidance\n---\n', + 'utf8', + ) + addSkillPackage(root, 'demo-pkg', ['beta']) await runSyncCommand( { cwd: root }, @@ -863,15 +876,123 @@ describe('consumer install', () => { }, ) - expect( - readIntentConsumerConfig(readFileSync(packageJsonPath, 'utf8')).skills, - ).toEqual(['demo-pkg']) + const lockAfter = readIntentLockfile(join(root, 'intent.lock')) + expect(lockAfter.status).toBe('found') + if (lockAfter.status !== 'found') return + const demoSource = lockAfter.lockfile.sources.find( + (source) => source.id === 'demo-pkg', + ) + expect({ + alphaHash: demoSource?.skills.find( + (skill) => skill.path === 'skills/alpha', + )?.contentHash, + alphaLinked: existsSync( + join(root, '.agents', 'skills', 'npm-demo-pkg-alpha'), + ), + betaLocked: demoSource?.skills.some( + (skill) => skill.path === 'skills/beta', + ), + betaLinked: existsSync( + join(root, '.agents', 'skills', 'npm-demo-pkg-beta'), + ), + }).toEqual({ + alphaHash, + alphaLinked: false, + betaLocked: true, + betaLinked: true, + }) + }) + + it('preserves enabled siblings when no pending skills are selected', async () => { + const root = createProject() + rmSync(join(root, 'node_modules', '@tanstack', 'query'), { + recursive: true, + force: true, + }) + 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'] + writeJson(packageJsonPath, packageJson) + addSkillPackage(root, 'demo-pkg', ['alpha', 'beta']) + + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => Promise.resolve('review'), + selectSkills: () => + Promise.resolve({ mode: 'individual', enabled: [] }), + }, + }, + ) + + const config = readIntentConsumerConfig( + readFileSync(packageJsonPath, 'utf8'), + ) + expect(config.skills).toEqual(['demo-pkg#alpha']) + expect(config.exclude).toEqual(['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(false) + }) + + it('leaves a new skill under package-level trust pending baseline review', 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'] + packageJson.intent.exclude = ['@tanstack/query'] + writeJson(packageJsonPath, packageJson) + addSkillPackage(root, 'demo-pkg', ['beta']) + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + let output = '' + + try { + await runSyncCommand( + { cwd: root }, + { + review: 'interactive', + prompts: { + complete: () => {}, + reviewNewDependencies: () => + Promise.reject(new Error('package-level trust must not prompt')), + selectSkills: () => + Promise.reject(new Error('package-level trust must not select')), + }, + }, + ) + output = log.mock.calls.flat().join('\n') + } finally { + log.mockRestore() + } + + expect(output).toContain('New skills found in enabled dependencies:') + expect(output).toContain('demo-pkg 1 skill') + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-alpha')), ).toBe(true) + expect( + existsSync(join(root, '.agents', 'skills', 'npm-demo-pkg-beta')), + ).toBe(false) }) it('writes a package-level entry when all skills in a new package are accepted', async () => { @@ -1135,7 +1256,7 @@ describe('consumer install', () => { } } - expect(output).toContain('New dependencies with skills found:') + expect(output).toContain('Pending skills by source:') 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) diff --git a/packages/intent/tests/sync.test.ts b/packages/intent/tests/sync.test.ts index 584dba78..7c6017ab 100644 --- a/packages/intent/tests/sync.test.ts +++ b/packages/intent/tests/sync.test.ts @@ -17,6 +17,7 @@ import { wireIntentSyncPrepare } from '../src/commands/sync/prepare.js' import { parseInstallState, readInstallState, + readInstallStateForLinks, serializeInstallState, writeInstallState, } from '../src/commands/sync/state.js' @@ -99,6 +100,32 @@ describe('sync state', () => { ).toBeNull() }) + it.each([ + '', + '/absolute/link', + 'outside\\link', + 'C:/link', + 'C:link', + './link', + 'links/./link', + 'links//link', + '../outside/link', + 'links/../outside', + '.. /link', + 'links/.. /outside', + 'links/link.', + 'links/link ', + ])('rejects invalid persisted path %j', (path) => { + expect( + parseInstallState( + JSON.stringify({ + ...state, + entries: [{ ...state.entries[0], path }], + }), + ), + ).toBeNull() + }) + it('writes atomically only when state changes and reports malformed state', () => { const root = tempRoot('intent-sync-state-') expect(writeInstallState(root, state)).toBe(true) @@ -126,6 +153,52 @@ describe('managed sync links', () => { } } + it('rejects persisted link paths that traverse outside the project', () => { + const parent = tempRoot('intent-sync-state-path-') + const root = join(parent, 'project') + const outsideDirectory = join(parent, 'outside') + const source = join(parent, 'source') + const outsideLink = join(outsideDirectory, 'link') + mkdirSync(join(root, '.intent'), { recursive: true }) + mkdirSync(outsideDirectory, { recursive: true }) + mkdirSync(source, { recursive: true }) + symlinkSync(source, outsideLink, 'dir') + writeFileSync( + join(root, '.intent', 'install-state.json'), + JSON.stringify({ + version: 1, + entries: [ + { + targetDirectory: '../outside', + path: '../outside/link', + alias: 'link', + source: { kind: 'npm', id: 'pkg' }, + skillPath: 'skills/core', + linkTarget: source, + }, + ], + }), + 'utf8', + ) + + const stateResult = readInstallStateForLinks(root) + const result = reconcileManagedLinks({ + dryRun: false, + expected: [], + stateResult, + }) + + expect({ + stateStatus: stateResult.status, + removed: result.removed, + outsideLinkExists: existsSync(outsideLink), + }).toEqual({ + stateStatus: 'malformed', + removed: [], + outsideLinkExists: true, + }) + }) + it('creates, leaves unchanged, repairs owned links, and cleans owned stale links', () => { const root = tempRoot('intent-sync-links-') const link = expected(root) From 9de089f5d7b0f3ad09ce42cd76bfe46ef679f2ce Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sun, 26 Jul 2026 22:05:00 -0700 Subject: [PATCH 40/56] fix(cli): make automation idempotent and preserve compatibility --- packages/intent/src/commands/exclude.ts | 14 +- .../intent/src/commands/install/command.ts | 28 +- .../intent/src/commands/install/config.ts | 33 +- .../intent/src/commands/install/consumer.ts | 21 +- packages/intent/src/commands/list.ts | 42 ++- packages/intent/src/core/intent-core.ts | 4 +- packages/intent/src/core/source-policy.ts | 6 +- packages/intent/src/hooks/install.ts | 5 +- packages/intent/tests/cli.test.ts | 335 +++++++++++++++--- packages/intent/tests/hooks-install.test.ts | 1 - packages/intent/tests/install-config.test.ts | 15 + 11 files changed, 393 insertions(+), 111 deletions(-) diff --git a/packages/intent/src/commands/exclude.ts b/packages/intent/src/commands/exclude.ts index eb6d9aa9..5af3cab8 100644 --- a/packages/intent/src/commands/exclude.ts +++ b/packages/intent/src/commands/exclude.ts @@ -5,6 +5,7 @@ import { compileExcludePatterns } from '../core/excludes.js' import { writeTextFileAtomic } from '../shared/atomic-write.js' import { readIntentConsumerConfig, + readIntentExcludeList, updateIntentConsumerConfigText, } from './install/config.js' import type { IntentConsumerConfig } from './install/config.js' @@ -109,17 +110,24 @@ export function runExcludeCommand( const action = normalizeAction(actionArg) const cwd = process.cwd() const text = readPackageJsonText(cwd) - const config = readConfig(text) - const currentExcludes = config.exclude if (action === 'list') { if (patternArg) { fail('Unexpected pattern for list. Use: intent exclude list [--json]') } - printExcludes(currentExcludes, options.json) + try { + printExcludes(readIntentExcludeList(text), options.json) + } catch (err) { + fail( + `Invalid package.json intent configuration: ${err instanceof Error ? err.message : String(err)}`, + ) + } return } + const config = readConfig(text) + const currentExcludes = config.exclude + const pattern = normalizePattern(patternArg, action) validatePattern(pattern) diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 118fd1c6..40c54164 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -22,11 +22,13 @@ import type { ScanResult } from '../../shared/types.js' async function runInstallWithPrompts({ dryRun, + existingLockReview, prompts, root, selection, }: { dryRun?: boolean + existingLockReview?: 'fail' prompts: InstallerPrompter root: string selection?: SkillSelection @@ -38,6 +40,7 @@ async function runInstallWithPrompts({ await runConsumerInstall({ discovered: scanForIntents(root, { scope: 'local' }).packages, dryRun, + existingLockReview, prompts, root, selection, @@ -60,31 +63,33 @@ async function runDeclarativeInstall( } throw error } + const { hasExplicitIntentSkills, readIntentConsumerConfig } = + await import('./config.js') + const config = readIntentConsumerConfig(packageJson) + 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 { readIntentLockfile } = await import('../../core/lockfile/lockfile.js') - if (readIntentLockfile(join(root, 'intent.lock')).status === 'found') { + const hasExistingLock = + readIntentLockfile(join(root, 'intent.lock')).status === 'found' + if (hasExistingLock && install?.method !== 'hooks') { 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) { + if (!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, @@ -102,6 +107,7 @@ async function runDeclarativeInstall( } await runInstallWithPrompts({ dryRun: options.dryRun, + existingLockReview: hasExistingLock ? 'fail' : undefined, prompts, root, selection, diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts index 0c723d2a..79369cbf 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -190,31 +190,24 @@ export function readIntentConsumerConfig(text: string): IntentConsumerConfig { } } +export function readIntentExcludeList(text: string): Array { + const intent = parsePackageJson(text).intent + if (!isRecord(intent) || !Array.isArray(intent.exclude)) return [] + return intent.exclude.flatMap((entry) => { + if (typeof entry !== 'string') return [] + const trimmed = entry.trim() + return trimmed === '' ? [] : [trimmed] + }) +} + 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], - ) + equalsArray(left.skills, right.skills) && + equalsArray(left.exclude, right.exclude) && + equalsInstall(left.install, right.install) ) } diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index a26e5a9d..ffd2c782 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -66,6 +66,7 @@ export interface InstallerPrompter { export interface RunConsumerInstallOptions { discovered: ReadonlyArray dryRun?: boolean + existingLockReview?: 'fail' prompts: InstallerPrompter root: string selection?: SkillSelection @@ -92,6 +93,7 @@ function countSkills(entries: ReadonlyArray<{ skillCount: number }>): number { export async function runConsumerInstall({ discovered, dryRun = false, + existingLockReview, prompts, root, selection: fixedSelection, @@ -113,16 +115,25 @@ export async function runConsumerInstall({ const newDependencies = countSkills(summary.newDependencies) const newSkills = countSkills(summary.newSkills) const changed = countSkills(summary.changed) + const hasChanges = + newDependencies > 0 || newSkills > 0 || changed > 0 || summary.removed > 0 if ( - newDependencies === 0 && - newSkills === 0 && - changed === 0 && - summary.removed === 0 && - existingLock.status === 'found' + !hasChanges && + existingLock.status === 'found' && + existingLockReview !== 'fail' ) { prompts.complete('Project is up to date.') return } + if ( + hasChanges && + existingLock.status === 'found' && + existingLockReview === 'fail' + ) { + throw new Error( + 'Intent install requires review before automation can continue.', + ) + } console.log( `Install changes: ${newDependencies} new ${newDependencies === 1 ? 'dependency' : 'dependencies'}, ${newSkills} new ${newSkills === 1 ? 'skill' : 'skills'}, ${changed} changed, ${summary.removed} removed.`, ) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index d85550a3..9eff3ee5 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,5 +1,6 @@ import { detectIntentAudience } from '../shared/environment.js' import { formatIntentCommand } from '../shared/command-runner.js' +import { containsLocalPath } from '../shared/local-path.js' import { listIntentSkills } from '../core/index.js' import { coreOptionsFromGlobalFlags, @@ -59,6 +60,18 @@ function printVersionConflicts(result: IntentSkillList): void { } } +function visibleWarnings( + result: IntentSkillList, + audience: string, +): Array { + if (audience !== 'agent') return result.warnings + return result.warnings.filter((warning) => !containsLocalPath(warning)) +} + +function redactPackageRoot(entry: T): T { + return { ...entry, packageRoot: '' } +} + function groupSkillsByPackageRoot( skills: Array, ): Map> { @@ -142,6 +155,7 @@ export async function runListCommand( why: explain, }) const noticeOptions = noticeOptionsFromGlobalFlags(options) + const warnings = visibleWarnings(result, audience) printListDebug(result) if (options.json) { @@ -150,21 +164,35 @@ export async function runListCommand( packageManager: _packageManager, ...jsonResult } = result - console.log(JSON.stringify(jsonResult, null, 2)) + const outputResult = + audience === 'agent' + ? { + ...jsonResult, + skills: jsonResult.skills.map(redactPackageRoot), + excludedSkills: jsonResult.excludedSkills?.map(redactPackageRoot), + packages: jsonResult.packages.map(redactPackageRoot), + warnings, + conflicts: [], + } + : jsonResult + console.log(JSON.stringify(outputResult, null, 2)) return } const { computeSkillNameWidth, printSkillTree, printTable } = await import('../shared/display.js') - if (result.packages.length === 0 && result.excludedSkills?.length === 0) { + if ( + result.packages.length === 0 && + (result.excludedSkills?.length ?? 0) === 0 + ) { console.log('No intent-enabled packages found.') if (options.showHidden && result.hiddenSourceCount > 0) { printHiddenSources(result, audience, explain) } - if (result.warnings.length > 0) { + if (warnings.length > 0) { console.log() - printWarnings(result.warnings) + printWarnings(warnings) } if (audience === 'human') { printNotices(result.notices, noticeOptions) @@ -186,7 +214,9 @@ export async function runListCommand( printTable(['PACKAGE', 'SOURCE', 'VERSION', 'SKILLS'], rows) } - printVersionConflicts(result) + if (audience === 'human') { + printVersionConflicts(result) + } if (options.showHidden) { printHiddenSources(result, audience, explain) @@ -243,7 +273,7 @@ export async function runListCommand( console.log() } - printWarnings(result.warnings) + printWarnings(warnings) if (audience === 'human') { printNotices(result.notices, noticeOptions) } diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index 02d47147..c7fc7c73 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -145,7 +145,7 @@ export function listIntentSkills( pkg.kind, ) return decision.source - ? `Allowed by intent.skills[${JSON.stringify(decision.source.raw)}]` + ? `Allowed by intent.skills[${JSON.stringify(decision.source.raw)}]` : undefined })() : undefined, @@ -182,7 +182,7 @@ export function listIntentSkills( description: skill.description, type: skill.type, framework: skill.framework, - why: `Excluded by intent.exclude[${JSON.stringify(pattern)}]`, + why: `Excluded by intent.exclude[${JSON.stringify(pattern)}]`, excluded: true, }), ) diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index ab686f5a..61cc0602 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -52,12 +52,12 @@ export interface LoadRefusal { message: string } -export type ExplicitSkillSource = Extract< +type ExplicitSkillSource = Extract< SkillSourcesConfig, { mode: 'explicit' } >['sources'][number] -export interface SkillSourcePolicyDecision { +interface SkillSourcePolicyDecision { permitted: boolean source: ExplicitSkillSource | null } @@ -292,7 +292,7 @@ export interface SourcePolicyResult { sourcePolicy: CompiledSkillSourcePolicy } -export interface ExcludedSkill { +interface ExcludedSkill { package: IntentPackage skill: IntentPackage['skills'][number] pattern: string diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 4bc9550e..0bda9042 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -9,11 +9,10 @@ import type { HookAgent, HookInstallScope } from './types.js' type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' -export type HookInstallResult = { +type HookInstallResult = { agent: HookAgent configPath: string | null scope: HookInstallScope - scriptPath: string | null status: HookInstallStatus reason?: string } @@ -96,7 +95,6 @@ function installAgentHook({ configPath: null, reason: 'project scope is not supported; use --scope user', scope, - scriptPath: null, status: 'skipped', } } @@ -122,7 +120,6 @@ function installAgentHook({ agent, configPath, scope, - scriptPath: null, status: configStatus, } } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index f3358349..34d267fa 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -74,6 +74,56 @@ function writeInstalledIntentPackage( }) } +function writeConflictingQueryPackages(root: string): { + queryV4Dir: string + queryV5Dir: string +} { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + dependencies: { + 'consumer-a': '1.0.0', + 'consumer-b': '1.0.0', + }, + }) + + const consumerADir = join(root, 'node_modules', 'consumer-a') + const consumerBDir = join(root, 'node_modules', 'consumer-b') + const queryV4Dir = join(consumerADir, 'node_modules', '@tanstack', 'query') + const queryV5Dir = join(consumerBDir, 'node_modules', '@tanstack', 'query') + + writeJson(join(consumerADir, 'package.json'), { + name: 'consumer-a', + version: '1.0.0', + dependencies: { '@tanstack/query': '4.0.0' }, + }) + writeJson(join(consumerBDir, 'package.json'), { + name: 'consumer-b', + version: '1.0.0', + dependencies: { '@tanstack/query': '5.0.0' }, + }) + writeJson(join(queryV4Dir, 'package.json'), { + name: '@tanstack/query', + version: '4.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeJson(join(queryV5Dir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(queryV4Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v4 skill', + }) + writeSkillMd(join(queryV5Dir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Query v5 skill', + }) + + return { queryV4Dir, queryV5Dir } +} + let originalCwd: string let logSpy: ReturnType let infoSpy: ReturnType @@ -860,10 +910,60 @@ describe('cli commands', () => { process.chdir(root) expect(await main(['install', '--no-input'])).toBe(0) - + const claudeHooks = join(root, '.claude', 'settings.json') + const codexHooks = join(root, '.codex', 'hooks.json') 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) + expect(existsSync(claudeHooks)).toBe(true) + expect(existsSync(codexHooks)).toBe(true) + + rmSync(claudeHooks) + expect(await main(['install', '--no-input'])).toBe(0) + expect(existsSync(claudeHooks)).toBe(true) + expect(existsSync(codexHooks)).toBe(true) + }) + + it('rejects configured hook content drift before writing package, lock, or hooks', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-hooks-drift-'), + ) + 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) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const claudeHooks = join(root, '.claude', 'settings.json') + writeFileSync( + join(root, 'node_modules', 'verified', 'skills', 'core', 'SKILL.md'), + '---\nname: core\ndescription: Changed skill\n---\n', + ) + rmSync(claudeHooks) + errorSpy.mockClear() + + expect(await main(['install', '--no-input'])).toBe(1) + + expect(errorSpy).toHaveBeenCalledWith( + 'Intent install requires review before automation can continue.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + expect(existsSync(claudeHooks)).toBe(false) }) it('rejects configured Copilot hook bootstrap before writes', async () => { @@ -899,6 +999,51 @@ describe('cli commands', () => { } }) + it('rejects configured Copilot hooks before writes when a lock exists', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-copilot-locked-'), + ) + 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 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') + 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, + ) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + for (const path of ['.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-'), @@ -1043,6 +1188,69 @@ describe('cli commands', () => { expect(logSpy).toHaveBeenCalledWith('No excludes configured.') }) + it('lists excludes when released config has null skills', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-exclude-list-null-skills-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: null }, + }) + process.chdir(root) + + const exitCode = await main(['exclude', 'list']) + + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith('No excludes configured.') + }) + + it('lists only nonblank string excludes from released config', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-exclude-list-legacy-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: [], + exclude: ['', ' legacy-pkg ', null, 42], + }, + }) + process.chdir(root) + + const exitCode = await main(['exclude', 'list', '--json']) + const output = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + + expect(exitCode).toBe(0) + expect(JSON.parse(output)).toEqual(['legacy-pkg']) + }) + + it('keeps exclude mutations strict for released config', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-exclude-write-legacy-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: null, exclude: ['legacy-pkg'] }, + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.chdir(root) + + expect(await main(['exclude', 'add', 'new-pkg'])).toBe(1) + + expect(errorSpy).toHaveBeenCalledWith( + 'Invalid package.json intent configuration: intent.skills must be an array of strings.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + }) + it('adds and lists an exclude pattern', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-add-')) tempDirs.push(root) @@ -1594,6 +1802,23 @@ describe('cli commands', () => { expect(parsed.warnings).toEqual([]) }) + it('prints the empty default list message', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-empty-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: [] }, + }) + process.chdir(root) + + const exitCode = await main(['list']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('No intent-enabled packages found.') + }) + it('prints full load commands for every skill in human list output', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-load-commands-')) tempDirs.push(root) @@ -1672,10 +1897,10 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain( - 'Allowed by intent.skills["@tanstack/query#fetching"]', + 'Allowed by intent.skills["@tanstack/query#fetching"]', ) expect(output).toContain( - 'Allowed by intent.skills["@tanstack/query#query/cache"]', + 'Allowed by intent.skills["@tanstack/query#query/cache"]', ) expect(output.indexOf('Allowed by')).toBeLessThan(output.indexOf('Load:')) }) @@ -1715,7 +1940,7 @@ describe('cli commands', () => { expect(whyOutput).toContain('@tanstack/query\n') expect(whyOutput).toContain('fetching (excluded)') expect(whyOutput).toContain( - `Excluded by intent.exclude[${JSON.stringify(pattern)}]`, + `Excluded by intent.exclude[${JSON.stringify(pattern)}]`, ) expect(whyOutput).not.toContain('Load:') }, @@ -1741,7 +1966,7 @@ describe('cli commands', () => { expect(await main(['list', '--why'])).toBe(0) const output = logSpy.mock.calls.flat().join('\n') - expect(output).toContain('Allowed by intent.skills["@tanstack/query"]') + expect(output).toContain('Allowed by intent.skills["@tanstack/query"]') }) it.each([ @@ -2946,70 +3171,68 @@ describe('cli commands', () => { ) }) - it('explains which package version was chosen when conflicts exist', async () => { + it('keeps full version conflict paths in human list output', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-conflicts-')) tempDirs.push(root) + const { queryV4Dir, queryV5Dir } = writeConflictingQueryPackages(root) - writeJson(join(root, 'package.json'), { - name: 'app', - private: true, - dependencies: { - 'consumer-a': '1.0.0', - 'consumer-b': '1.0.0', - }, - }) - - const consumerADir = join(root, 'node_modules', 'consumer-a') - const consumerBDir = join(root, 'node_modules', 'consumer-b') - const queryV4Dir = join(consumerADir, 'node_modules', '@tanstack', 'query') - const queryV5Dir = join(consumerBDir, 'node_modules', '@tanstack', 'query') - - writeJson(join(consumerADir, 'package.json'), { - name: 'consumer-a', - version: '1.0.0', - dependencies: { '@tanstack/query': '4.0.0' }, - }) - writeJson(join(consumerBDir, 'package.json'), { - name: 'consumer-b', - version: '1.0.0', - dependencies: { '@tanstack/query': '5.0.0' }, - }) - writeJson(join(queryV4Dir, 'package.json'), { - name: '@tanstack/query', - version: '4.0.0', - intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, - }) - writeJson(join(queryV5Dir, 'package.json'), { - name: '@tanstack/query', - version: '5.0.0', - intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, - }) - writeSkillMd(join(queryV4Dir, 'skills', 'fetching'), { - name: 'fetching', - description: 'Query v4 skill', - }) - writeSkillMd(join(queryV5Dir, 'skills', 'fetching'), { - name: 'fetching', - description: 'Query v5 skill', - }) - - process.env.INTENT_AUDIENCE = 'agent' + process.env.INTENT_AUDIENCE = 'human' process.chdir(root) const exitCode = await main(['list']) 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}`, - ) + + logSpy.mockClear() + expect(await main(['list', '--json'])).toBe(0) + const jsonOutput = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + expect(jsonOutput).toContain(queryV4Dir) + expect(jsonOutput).toContain(queryV5Dir) + }) + + it('redacts version conflict paths from agent list output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-conflicts-agent-')) + tempDirs.push(root) + const { queryV4Dir, queryV5Dir } = writeConflictingQueryPackages(root) + + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + expect(await main(['list'])).toBe(0) + const output = logSpy.mock.calls.flat().join('\n') + expect(output).not.toContain('Version conflicts:') + expect(output).not.toContain(queryV4Dir) + expect(output).not.toContain(queryV5Dir) + + logSpy.mockClear() + expect(await main(['list', '--json'])).toBe(0) + const jsonOutput = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + const parsed = JSON.parse(jsonOutput) as { conflicts: Array } + expect(parsed.conflicts).toEqual([]) + expect(jsonOutput).not.toContain(queryV4Dir) + expect(jsonOutput).not.toContain(queryV5Dir) + }) + + it('redacts scanner warning paths from agent list output', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-warnings-agent-')) + tempDirs.push(root) + const malformedPackageDir = join(root, 'node_modules', 'malformed') + mkdirSync(join(malformedPackageDir, 'skills'), { recursive: true }) + writeFileSync(join(malformedPackageDir, 'package.json'), '{') + + process.env.INTENT_AUDIENCE = 'agent' + process.chdir(root) + + expect(await main(['list', '--json'])).toBe(0) + const jsonOutput = String(logSpy.mock.calls.at(-1)?.[0] ?? '') + expect(jsonOutput).not.toContain(root) }) it('validates a well-formed skills directory', async () => { diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index e95e5196..ef07373d 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -321,7 +321,6 @@ describe('hook installer', () => { configPath: null, reason: 'project scope is not supported; use --scope user', scope: 'project', - scriptPath: null, status: 'skipped', }), ).toBe( diff --git a/packages/intent/tests/install-config.test.ts b/packages/intent/tests/install-config.test.ts index eb07425b..d3670acb 100644 --- a/packages/intent/tests/install-config.test.ts +++ b/packages/intent/tests/install-config.test.ts @@ -3,6 +3,7 @@ import { INSTALL_TARGETS, installTargetsForMethod, readIntentConsumerConfig, + readIntentExcludeList, updateIntentConsumerConfigText, } from '../src/commands/install/config.js' import type { @@ -135,4 +136,18 @@ describe('installer configuration', () => { readIntentConsumerConfig('{"intent":{"exclude":[" "]}}'), ).toThrow('must not contain blank entries') }) + + it('reads only normalized string excludes from released JSONC config', () => { + expect( + readIntentExcludeList(`{ + // released config shapes remain readable + "intent": { + "skills": null, + "exclude": ["", " legacy-pkg ", null, 42], + }, + }`), + ).toEqual(['legacy-pkg']) + expect(readIntentExcludeList('{"intent":{"exclude":null}}')).toEqual([]) + expect(readIntentExcludeList('{"intent":{"exclude":"pkg"}}')).toEqual([]) + }) }) From 988a5a9900a9830c22108e87e4f3a6f7c2a8f90f Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Mon, 27 Jul 2026 08:43:43 -0700 Subject: [PATCH 41/56] fix(catalog): keep session context fresh and terminating --- packages/intent/src/catalog-lock.ts | 6 +- packages/intent/src/catalog.ts | 27 +++- packages/intent/src/session-catalog.ts | 51 ++++--- packages/intent/tests/catalog-api.test.ts | 127 +++++++++++++++++- packages/intent/tests/session-catalog.test.ts | 71 +++++++++- 5 files changed, 250 insertions(+), 32 deletions(-) diff --git a/packages/intent/src/catalog-lock.ts b/packages/intent/src/catalog-lock.ts index 4eb68d48..08db9c27 100644 --- a/packages/intent/src/catalog-lock.ts +++ b/packages/intent/src/catalog-lock.ts @@ -13,7 +13,7 @@ import type { ReadFs } from './shared/utils.js' export interface LockCheckedCatalogueDiscovery { result: IntentSkillList - verification: Array + verification: Array | null } export function applyCatalogueLock( @@ -22,7 +22,7 @@ export function applyCatalogueLock( readFs: ReadFs = getProjectReadFs(workspaceRoot), ): LockCheckedCatalogueDiscovery { const locked = readIntentLockfile(join(workspaceRoot, 'intent.lock')) - if (locked.status === 'missing') return { result, verification: [] } + if (locked.status === 'missing') return { result, verification: null } const fsCache = createIntentFsCache() fsCache.useFs(readFs) @@ -102,6 +102,6 @@ export function applyCatalogueLock( } : {}), }, - verification, + verification: withheldCount > 0 ? null : verification, } } diff --git a/packages/intent/src/catalog.ts b/packages/intent/src/catalog.ts index cf718475..80e3a29e 100644 --- a/packages/intent/src/catalog.ts +++ b/packages/intent/src/catalog.ts @@ -53,21 +53,34 @@ export async function runSessionCatalogueHook({ agent: HookAgent event?: Record }): Promise { - try { - const eventName = getLifecycleEventName(agent, event) - if (!eventName) return + const eventName = getLifecycleEventName(agent, event) + if (!eventName) return + let context: string + try { const result = await getIntentCatalogContext({ cwd: getEventCwd(event) }) + context = result.context + } catch (error) { + logHookFailure(error) + context = + 'Intent skills are unavailable because the catalogue could not be built. Run `intent catalog` outside the agent session for details.' + } + + try { process.stdout.write( - JSON.stringify(formatHookOutput(agent, eventName, result.context)), + JSON.stringify(formatHookOutput(agent, eventName, context)), ) } catch (error) { - console.error( - `[intent catalog] hook failed open: ${error instanceof Error ? error.message : String(error)}`, - ) + logHookFailure(error) } } +function logHookFailure(error: unknown): void { + 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 diff --git a/packages/intent/src/session-catalog.ts b/packages/intent/src/session-catalog.ts index 408314ab..0386db45 100644 --- a/packages/intent/src/session-catalog.ts +++ b/packages/intent/src/session-catalog.ts @@ -15,17 +15,18 @@ import { } from 'node:fs' import { createHash } from 'node:crypto' import { homedir, tmpdir, userInfo } from 'node:os' -import { dirname, join, relative, resolve } from 'node:path' +import { dirname, isAbsolute, 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 * as NodePath from 'node:path' import type { IntentSkillList } from './core/index.js' import type { ReadFs } from './shared/utils.js' -const CACHE_SCHEMA_VERSION = 3 +const CACHE_SCHEMA_VERSION = 4 const DEFAULT_MAX_CONTEXT_BYTES = 8_000 const DEFAULT_MAX_SKILLS = 50 const MIN_CONTEXT_BYTES = 512 @@ -64,7 +65,7 @@ export interface SessionCatalogue { export interface DiscoveredSessionCatalogue { result: IntentSkillList - verification: Array + verification: Array | null } interface IntentSessionCatalogueCache { @@ -211,15 +212,17 @@ export async function getSessionCatalogue(options: { const refreshed = await discover() const catalogue = buildSessionCatalogue(refreshed.result) - const entry: IntentSessionCatalogueCache = { - schemaVersion: CACHE_SCHEMA_VERSION, - workspaceRoot, - policyRoot: normalizedPolicyRoot, - dependencyFingerprint, - catalogue, - verification: refreshed.verification, + if (cache.enabled && refreshed.verification !== null) { + const entry: IntentSessionCatalogueCache = { + schemaVersion: CACHE_SCHEMA_VERSION, + workspaceRoot, + policyRoot: normalizedPolicyRoot, + dependencyFingerprint, + catalogue, + verification: refreshed.verification, + } + writeCache(cachePath, entry) } - if (cache.enabled) writeCache(cachePath, entry) return { cachePath, @@ -283,25 +286,33 @@ function normalizeRoot(root: string): string { : normalized } -function policyManifestPaths( +export function policyManifestPaths( workspaceRoot: string, policyRoot: string, + pathApi: Pick< + typeof NodePath, + 'dirname' | 'isAbsolute' | 'join' | 'relative' + > = { dirname, isAbsolute, join, relative }, ): Array { - const relativePolicyRoot = relative(workspaceRoot, policyRoot) + const relativePolicyRoot = pathApi.relative(workspaceRoot, policyRoot) if ( - relativePolicyRoot.startsWith('..') || - relativePolicyRoot.startsWith('/') + relativePolicyRoot.split(/[\\/]/, 1)[0] === '..' || + pathApi.isAbsolute(relativePolicyRoot) ) { - return [join(policyRoot, 'package.json')] + return [pathApi.join(policyRoot, 'package.json')] } const manifests: Array = [] let directory = policyRoot - while (directory !== workspaceRoot) { - manifests.push(join(directory, 'package.json')) - directory = dirname(directory) + while (pathApi.relative(workspaceRoot, directory) !== '') { + manifests.push(pathApi.join(directory, 'package.json')) + const parent = pathApi.dirname(directory) + if (parent === directory) { + return [pathApi.join(policyRoot, 'package.json')] + } + directory = parent } - manifests.push(join(workspaceRoot, 'package.json')) + manifests.push(pathApi.join(workspaceRoot, 'package.json')) return manifests } diff --git a/packages/intent/tests/catalog-api.test.ts b/packages/intent/tests/catalog-api.test.ts index b6761e72..66f40847 100644 --- a/packages/intent/tests/catalog-api.test.ts +++ b/packages/intent/tests/catalog-api.test.ts @@ -1,13 +1,26 @@ -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, vi } from 'vitest' +import { applyCatalogueLock } from '../src/catalog-lock.js' import { getIntentCatalogContext, runSessionCatalogueHook, } from '../src/catalog.js' +import { listIntentSkills } from '../src/core/index.js' import { computeSkillContentHash } from '../src/core/lockfile/hash.js' import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { getProjectReadFs } from '../src/discovery/scanner.js' +import { + formatSessionCatalogue, + getSessionCatalogue, +} from '../src/session-catalog.js' const roots: Array = [] @@ -69,6 +82,22 @@ function fixture(): { root: string; packageRoot: string; skillDir: string } { return { root, packageRoot, skillDir } } +async function getFixtureCatalogContext(root: string, cacheDir: string) { + const readFs = getProjectReadFs(root) + const result = await getSessionCatalogue({ + cacheDir, + root, + readFs, + discover: () => + applyCatalogueLock( + listIntentSkills({ audience: 'agent', cwd: root }), + root, + readFs, + ), + }) + return { ...result, context: formatSessionCatalogue(result.catalogue) } +} + afterEach(() => { vi.restoreAllMocks() for (const root of roots.splice(0)) { @@ -77,6 +106,44 @@ afterEach(() => { }) describe('getIntentCatalogContext', () => { + it('refreshes without replacing an existing cache after intent.lock is removed', async () => { + const { root, skillDir } = fixture() + const cacheDir = join(root, 'cache') + const first = await getFixtureCatalogContext(root, cacheDir) + const cachedBytes = readFileSync(first.cachePath) + rmSync(join(root, 'intent.lock')) + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Changed unverified guidance\n---\n', + ) + + const changed = await getFixtureCatalogContext(root, cacheDir) + + expect(first.cacheStatus).toBe('miss') + expect(changed.cacheStatus).toBe('refresh') + expect(changed.context).toContain('Changed unverified guidance') + expect(changed.context).not.toContain('Core package guidance') + expect(readFileSync(first.cachePath)).toEqual(cachedBytes) + }) + + it('rediscovers changed context when intent.lock is missing', async () => { + const { root, skillDir } = fixture() + rmSync(join(root, 'intent.lock')) + + const first = 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(first.context).toContain('Core package guidance') + expect(changed.cacheStatus).toBe('miss') + expect(changed.context).toContain('Changed guidance') + expect(changed.context).not.toContain('Core package guidance') + }) + it('reuses accepted context and withholds drifted skill content', async () => { const { root, skillDir } = fixture() @@ -97,6 +164,37 @@ describe('getIntentCatalogContext', () => { expect(changed.context).toContain('@fixture/package#sibling') }) + it('restores an accepted skill after its exact locked content returns', async () => { + const { root, skillDir } = fixture() + const cacheDir = join(root, 'cache') + const originalSkill = readFileSync(join(skillDir, 'SKILL.md')) + const first = await getFixtureCatalogContext(root, cacheDir) + const cachedBytes = readFileSync(first.cachePath) + + expect(first.cacheStatus).toBe('miss') + expect(first.context).toContain('@fixture/package#core') + expect(first.context).toContain('@fixture/package#sibling') + + writeFileSync( + join(skillDir, 'SKILL.md'), + '---\nname: core\ndescription: Drifted guidance\n---\n', + ) + + const drifted = await getFixtureCatalogContext(root, cacheDir) + + expect(drifted.cacheStatus).toBe('refresh') + expect(drifted.context).not.toContain('@fixture/package#core') + expect(drifted.context).toContain('@fixture/package#sibling') + expect(readFileSync(first.cachePath)).toEqual(cachedBytes) + + writeFileSync(join(skillDir, 'SKILL.md'), originalSkill) + const restored = await getFixtureCatalogContext(root, cacheDir) + + expect(restored.cacheStatus).toBe('hit') + expect(restored.context).toContain('@fixture/package#core') + expect(restored.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') @@ -115,6 +213,33 @@ describe('getIntentCatalogContext', () => { }) describe('runSessionCatalogueHook', () => { + it('writes generic Copilot context when the catalogue cannot be built', async () => { + const { root } = fixture() + writeFileSync(join(root, 'intent.lock'), '{broken') + 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(stdout).toHaveBeenCalledTimes(1) + const output = String(stdout.mock.calls[0]![0]) + expect(JSON.parse(output)).toEqual({ + additionalContext: + 'Intent skills are unavailable because the catalogue could not be built. Run `intent catalog` outside the agent session for details.', + }) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0]![0])).toContain( + '[intent catalog] hook failed open:', + ) + expect(output).not.toContain('Invalid intent.lock JSON') + expect(output).not.toContain(root) + }) + it('writes the documented Copilot output shape', async () => { const { root } = fixture() const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) diff --git a/packages/intent/tests/session-catalog.test.ts b/packages/intent/tests/session-catalog.test.ts index 1558fdb4..134072bc 100644 --- a/packages/intent/tests/session-catalog.test.ts +++ b/packages/intent/tests/session-catalog.test.ts @@ -13,12 +13,13 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, join, win32 } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { buildSessionCatalogue, formatSessionCatalogue, getSessionCatalogue, + policyManifestPaths, } from '../src/session-catalog.js' import { computeSkillContentHash } from '../src/core/lockfile/hash.js' import { nodeReadFs } from '../src/shared/utils.js' @@ -168,6 +169,29 @@ describe('session catalogue formatting', () => { }) describe('session catalogue cache', () => { + it('does not persist a discovery without lock verification', async () => { + const root = tempRoot('intent-catalog-no-lock-') + const cacheDir = join(root, 'cache') + writeFileSync(join(root, 'package.json'), '{}') + let discoveries = 0 + const options = { + cacheDir, + root, + discover: () => { + discoveries += 1 + return { result: result([]), verification: null } + }, + } + + const first = await getSessionCatalogue(options) + const second = await getSessionCatalogue(options) + + expect(first.cacheStatus).toBe('miss') + expect(second.cacheStatus).toBe('miss') + expect(discoveries).toBe(2) + expect(existsSync(first.cachePath)).toBe(false) + }) + it('fails open when the default temporary directory cannot be resolved', async () => { const root = tempRoot('intent-catalog-missing-temp-') const missingTempRoot = join(root, 'missing') @@ -599,3 +623,48 @@ describe('session catalogue cache', () => { expect(discoveries).toBe(2) }) }) + +describe('session catalogue policy manifests', () => { + it('uses only the policy manifest when Windows roots are on different drives', () => { + expect(policyManifestPaths('C:\\workspace', 'D:\\policy', win32)).toEqual([ + 'D:\\policy\\package.json', + ]) + }) + + it('includes nested policy ancestors on the same Windows drive', () => { + expect( + policyManifestPaths( + 'C:\\workspace', + 'C:\\workspace\\packages\\app', + win32, + ), + ).toEqual([ + 'C:\\workspace\\packages\\app\\package.json', + 'C:\\workspace\\packages\\package.json', + 'C:\\workspace\\package.json', + ]) + }) + + it('recognizes equivalent Windows roots with different casing and trailing separators', () => { + expect( + policyManifestPaths( + 'C:\\WORKSPACE\\', + 'c:\\workspace\\packages\\app\\', + win32, + ), + ).toEqual([ + 'c:\\workspace\\packages\\app\\package.json', + 'c:\\workspace\\packages\\package.json', + 'C:\\WORKSPACE\\package.json', + ]) + }) + + it('includes an in-workspace child whose name starts with two dots', () => { + expect( + policyManifestPaths('C:\\workspace', 'C:\\workspace\\..cfg', win32), + ).toEqual([ + 'C:\\workspace\\..cfg\\package.json', + 'C:\\workspace\\package.json', + ]) + }) +}) From 3204e7c94fa13291473704be07bbad1990422308 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Mon, 27 Jul 2026 21:25:53 -0700 Subject: [PATCH 42/56] fix(sync): harden skill verification and managed link cleanup --- packages/intent/package.json | 3 + packages/intent/src/commands/sync/command.ts | 50 ++++++-- packages/intent/src/commands/sync/links.ts | 38 +++++- packages/intent/src/core/lockfile/hash.ts | 41 +++---- packages/intent/src/shared/local-path.ts | 4 +- packages/intent/src/shared/utils.ts | 3 + .../intent/tests/consumer-install.test.ts | 104 ++++++++++++++++ packages/intent/tests/hash.test.ts | 43 ++++++- packages/intent/tests/local-path.test.ts | 5 + packages/intent/tests/sync.test.ts | 113 +++++++++++++++++- 10 files changed, 354 insertions(+), 50 deletions(-) diff --git a/packages/intent/package.json b/packages/intent/package.json index 891de85e..3a2af697 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -4,6 +4,9 @@ "description": "Ship compositional knowledge for AI coding agents alongside your npm packages", "license": "MIT", "type": "module", + "engines": { + "node": ">=20.12.0" + }, "repository": { "type": "git", "url": "https://github.com/TanStack/intent" diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index 5c1f1c29..19a93a92 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -414,19 +414,43 @@ export async function runSyncCommand( ) } - const { discovered, policy } = scanForConfiguredIntents({ - root, - config: parseSkillSources(config.skills), - exclude: config.exclude, - }) - const { expected, inventory } = buildSyncLinkPlan({ - config, - currentSources: buildCurrentLockfileSources(policy.packages), - discovered, - lock, - packages: policy.packages, - root, - }) + let discovery: ReturnType + let plan: ReturnType + try { + discovery = scanForConfiguredIntents({ + root, + config: parseSkillSources(config.skills), + exclude: config.exclude, + }) + plan = buildSyncLinkPlan({ + config, + currentSources: buildCurrentLockfileSources(discovery.policy.packages), + discovered: discovery.discovered, + lock, + packages: discovery.policy.packages, + root, + }) + } catch (error) { + const links = reconcileManagedLinks({ + dryRun: options.dryRun === true, + expected: [], + stateResult: readInstallStateForLinks(root), + }) + if (!options.dryRun) { + writeManagedLinkState(root, links) + } + if (links.conflicts.length > 0) { + throw new Error( + `Intent sync could not revoke managed links after verification failed: ${links.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + { cause: error }, + ) + } + throw error + } + const { discovered, policy } = discovery + const { expected, inventory } = plan const links = reconcileManagedLinks({ dryRun: options.dryRun === true, expected, diff --git a/packages/intent/src/commands/sync/links.ts b/packages/intent/src/commands/sync/links.ts index 5e28b18b..fcaf555e 100644 --- a/packages/intent/src/commands/sync/links.ts +++ b/packages/intent/src/commands/sync/links.ts @@ -95,14 +95,30 @@ function createLink(path: string, target: string): void { // `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 { +function removeLink(path: string): boolean { try { unlinkSync(path) - } catch { - rmdirSync(path) + return true + } catch (unlinkError) { + if (process.platform !== 'win32') { + if (isRemovalConflict(unlinkError)) return false + throw unlinkError + } + try { + rmdirSync(path) + return true + } catch (rmdirError) { + if (isRemovalConflict(rmdirError)) return false + throw rmdirError + } } } +function isRemovalConflict(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM' +} + function compareStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } @@ -111,10 +127,12 @@ export function reconcileManagedLinks({ dryRun, expected, stateResult, + removeLink: removeManagedLink = removeLink, }: { dryRun: boolean expected: ReadonlyArray stateResult: ReadInstallStateResult + removeLink?: (path: string) => boolean }): LinkReconciliation { const result: LinkReconciliation = { created: [], @@ -134,6 +152,8 @@ export function reconcileManagedLinks({ const target = sourceTarget(entry) if (!target) { result.conflicts.push(entry.path) + const priorEntry = priorByPath.get(entry.path) + if (priorEntry) result.entries.push(priorEntry) continue } const priorEntry = priorByPath.get(entry.path) @@ -156,7 +176,11 @@ export function reconcileManagedLinks({ } if (current === priorEntry.linkTarget) { if (!dryRun) { - removeLink(entry.path) + if (!removeManagedLink(entry.path)) { + result.conflicts.push(entry.path) + result.entries.push(priorEntry) + continue + } createLink(entry.path, target) } result.repaired.push(entry.path) @@ -178,7 +202,11 @@ export function reconcileManagedLinks({ isLink(priorEntry.path) && resolveLinkTarget(priorEntry.path) === priorEntry.linkTarget ) { - if (!dryRun) removeLink(priorEntry.path) + if (!dryRun && !removeManagedLink(priorEntry.path)) { + result.conflicts.push(priorEntry.path) + result.entries.push(priorEntry) + continue + } result.removed.push(priorEntry.path) continue } diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index fb74b3a3..363e406a 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -1,6 +1,5 @@ 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' @@ -21,9 +20,9 @@ export interface ComputeSkillContentHashOptions { } type HashEntry = { path: string; content: Buffer } -type HashReadFs = ReadFs & { opendirSync?: typeof opendirSync } +type HashReadFs = ReadFs -const nodeHashReadFs: HashReadFs = { ...nodeReadFs, opendirSync } +const nodeHashReadFs: HashReadFs = nodeReadFs const HASH_ENTRY_SEPARATOR = Buffer.from([0]) function compareStrings(a: string, b: string): number { @@ -108,11 +107,11 @@ function readDirectoryEntries( fs: HashReadFs, path: string, ): Array> { - if (!('opendirSync' in fs)) { + if (!fs.opendirSync) { return fs.readdirSync(path, { encoding: 'utf8', withFileTypes: true }) } - const directory = fs.opendirSync!(path, { encoding: 'utf8' }) + const directory = fs.opendirSync(path, { encoding: 'utf8' }) const entries: Array> = [] try { for (;;) { @@ -193,7 +192,9 @@ export function computeSkillContentHash({ hashState.entryCount += 1 if (hashState.entryCount > HASH_LIMITS.maxEntryCount) throw new Error('Hash entry count limit exceeded.') - const logicalPath = `${logicalDir}/${entry.name}` + const logicalPath = logicalDir + ? `${logicalDir}/${entry.name}` + : entry.name const physicalPath = join(physicalDir, entry.name) const realPath = resolveInPackage( fs, @@ -212,24 +213,14 @@ export function computeSkillContentHash({ } } - 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) - } + const skillFile = resolveInPackage( + fs, + join(realSkillDir, 'SKILL.md'), + realPackageRoot, + 'SKILL.md', + ) + if (!fs.lstatSync(skillFile).isFile()) + throw new Error('SKILL.md is not a regular file.') + collect(realSkillDir, '', 0) return hashEntries(hashState.entries) } diff --git a/packages/intent/src/shared/local-path.ts b/packages/intent/src/shared/local-path.ts index 6e0db508..1f6f0ad6 100644 --- a/packages/intent/src/shared/local-path.ts +++ b/packages/intent/src/shared/local-path.ts @@ -33,6 +33,7 @@ const USER_DATA_POSIX_ROOTS = new Set([ 'srv', 'workspace', ]) +const HOME_POSIX_ROOTS = new Set(['Users', 'home']) export function containsLocalPath(value: string): boolean { if ( @@ -60,7 +61,8 @@ function isLikelyLocalPosixPath(candidate: string): boolean { (root !== undefined && SYSTEM_POSIX_ROOTS.has(root)) || (root !== undefined && USER_DATA_POSIX_ROOTS.has(root) && - segments.length >= 3) + (segments.length >= 3 || + (HOME_POSIX_ROOTS.has(root) && segments.length >= 2))) ) } diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index 7b97369f..ec8057f8 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -4,6 +4,7 @@ import { existsSync, lstatSync, openSync, + opendirSync, readFileSync, readSync, readdirSync, @@ -43,6 +44,7 @@ export interface ReadFs { openSync?: typeof openSync readSync?: typeof readSync closeSync?: typeof closeSync + opendirSync?: typeof opendirSync } export const nodeReadFs: ReadFs = { @@ -54,6 +56,7 @@ export const nodeReadFs: ReadFs = { openSync, readSync, closeSync, + opendirSync, } /** diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 6ac74f21..6a46a83e 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -6,6 +6,8 @@ import { readFileSync, realpathSync, rmSync, + symlinkSync, + unlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -47,6 +49,10 @@ vi.mock('@clack/prompts', async (importOriginal) => { } }) +function createDirectoryLink(target: string, path: string): void { + symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir') +} + const roots: Array = [] function writeJson(path: string, value: unknown): void { @@ -369,6 +375,104 @@ describe('consumer install', () => { expect(advisory).not.toHaveBeenCalled() }) + it('revokes only exact owned links when sync rejects escaped skill content', async () => { + const root = createProject() + const packageRoot = join(root, 'node_modules', '@tanstack', 'query') + const mutationRoot = join(packageRoot, 'skills', 'mutation') + mkdirSync(mutationRoot, { recursive: true }) + writeFileSync( + join(mutationRoot, 'SKILL.md'), + '---\nname: mutation\ndescription: Mutation guidance\n---\n', + 'utf8', + ) + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const packageBefore = readFileSync(join(root, 'package.json'), 'utf8') + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const fetchingLink = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + const mutationLink = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-mutation', + ) + const retargeted = join(root, 'retargeted-skill') + mkdirSync(retargeted) + unlinkSync(mutationLink) + createDirectoryLink(retargeted, mutationLink) + const outside = join(root, 'outside-content') + mkdirSync(outside) + writeFileSync(join(outside, 'unsafe.md'), 'unsafe', 'utf8') + createDirectoryLink( + outside, + join(packageRoot, 'skills', 'fetching', 'references'), + ) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow( + 'Intent sync could not revoke managed links after verification failed: .agents/skills/npm-tanstack-query-mutation.', + ) + + expect(existsSync(fetchingLink)).toBe(false) + expect(existsSync(join(fetchingLink, 'references', 'unsafe.md'))).toBe( + false, + ) + expect(realpathSync(mutationLink)).toBe(realpathSync(retargeted)) + expect(readInstallState(root)).toMatchObject({ + status: 'found', + state: { + entries: [{ path: '.agents/skills/npm-tanstack-query-mutation' }], + }, + }) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + }) + + it('reports managed links that cannot be revoked after verification fails', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const link = join(root, '.agents', 'skills', 'npm-tanstack-query-fetching') + const state = readInstallState(root) + expect(state.status).toBe('found') + if (state.status !== 'found') throw new Error('Expected install state.') + unlinkSync(link) + createDirectoryLink(join(root, 'retargeted-skill'), link) + const outside = join(root, 'outside-content') + mkdirSync(outside) + createDirectoryLink( + outside, + join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'references', + ), + ) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow( + 'Intent sync could not revoke managed links after verification failed: .agents/skills/npm-tanstack-query-fetching.', + ) + expect(readInstallState(root)).toEqual(state) + }) + it('installs hooks with policy and lock state without links or prepare', async () => { const root = createProject() const prompts = createPrompts({ diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index 326dccbb..03d4c3ef 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -84,17 +84,34 @@ describe('computeSkillContentHash', () => { }, ) - it('ignores unrelated sibling files', () => { + it('includes arbitrary top-level files', () => { const { root, skill } = skillRoot() + writeFileSync(join(skill, 'EXTRA.md'), 'One') const baseline = computeSkillContentHash({ packageRoot: root, skillDir: skill, }) - writeFileSync(join(skill, 'notes.md'), 'Not a supported resource') + writeFileSync(join(skill, 'EXTRA.md'), 'Two') expect( computeSkillContentHash({ packageRoot: root, skillDir: skill }), - ).toBe(baseline) + ).not.toBe(baseline) + }) + + it('includes files under arbitrary top-level directories', () => { + const { root, skill } = skillRoot() + mkdirSync(join(skill, 'examples')) + writeFileSync(join(skill, 'examples', 'usage.md'), 'One') + const baseline = computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + }) + + writeFileSync(join(skill, 'examples', 'usage.md'), 'Two') + + expect( + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).not.toBe(baseline) }) it('preserves binary bytes', () => { @@ -151,4 +168,24 @@ describe('computeSkillContentHash', () => { computeSkillContentHash({ packageRoot: root, skillDir: skill }), ).toThrow() }) + + it.each([ + ['the skill root', ['outside.md']], + ['an arbitrary nested directory', ['examples', 'nested', 'outside.md']], + ])('rejects an escaping symlink under %s', (_location, linkParts) => { + const { root, skill } = skillRoot() + const outside = mkdtempSync(join(tmpdir(), 'intent-hash-outside-')) + roots.push(outside) + writeFileSync(join(outside, 'outside.md'), 'Outside') + mkdirSync(join(skill, ...linkParts.slice(0, -1)), { recursive: true }) + try { + symlinkSync(join(outside, 'outside.md'), join(skill, ...linkParts)) + } catch { + return + } + + expect(() => + computeSkillContentHash({ packageRoot: root, skillDir: skill }), + ).toThrow('escapes package root through a symlink') + }) }) diff --git a/packages/intent/tests/local-path.test.ts b/packages/intent/tests/local-path.test.ts index 29d00f1a..461e254e 100644 --- a/packages/intent/tests/local-path.test.ts +++ b/packages/intent/tests/local-path.test.ts @@ -4,7 +4,9 @@ import { containsLocalPath } from '../src/shared/local-path.js' describe('containsLocalPath', () => { it.each([ 'C:\\Users\\person\\project\\SKILL.md', + '/Users/alice', '/Users/person/project/SKILL.md', + '/home/alice', '/home/person/project/package.json', './packages/router/SKILL.md', 'node_modules/@scope/package/skills/core/SKILL.md', @@ -16,6 +18,9 @@ describe('containsLocalPath', () => { it.each([ '/users/:id', '/posts/:slug', + '/media/logo', + '/opt/pricing', + '/workspace/list', 'Use the router/search API', '@scope/package#skill', ])('preserves non-filesystem value %s', (value) => { diff --git a/packages/intent/tests/sync.test.ts b/packages/intent/tests/sync.test.ts index 7c6017ab..bffcb6c3 100644 --- a/packages/intent/tests/sync.test.ts +++ b/packages/intent/tests/sync.test.ts @@ -34,6 +34,10 @@ function tempRoot(name: string): string { return root } +function createDirectoryLink(target: string, path: string): void { + symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir') +} + afterEach(() => { for (const root of tempDirs.splice(0)) rmSync(root, { recursive: true, force: true }) @@ -162,7 +166,7 @@ describe('managed sync links', () => { mkdirSync(join(root, '.intent'), { recursive: true }) mkdirSync(outsideDirectory, { recursive: true }) mkdirSync(source, { recursive: true }) - symlinkSync(source, outsideLink, 'dir') + createDirectoryLink(source, outsideLink) writeFileSync( join(root, '.intent', 'install-state.json'), JSON.stringify({ @@ -244,7 +248,7 @@ describe('managed sync links', () => { 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') + createDirectoryLink(join(root, 'somewhere-else'), link.path) const conflict = reconcileManagedLinks({ dryRun: false, expected: [link], @@ -268,7 +272,7 @@ describe('managed sync links', () => { const root = tempRoot('intent-sync-unreadable-') const link = expected(root) mkdirSync(join(root, '.github', 'skills'), { recursive: true }) - symlinkSync(join(root, 'missing'), link.path, 'dir') + createDirectoryLink(join(root, 'missing'), link.path) const result = reconcileManagedLinks({ dryRun: false, @@ -294,6 +298,109 @@ describe('managed sync links', () => { expect(result.conflicts).toEqual([link.path]) expect(result.repaired).toEqual([]) }) + + it('keeps owned state and continues when removing a stale link fails', () => { + const root = tempRoot('intent-sync-remove-failure-') + const source = join(root, 'source') + const blockedPath = join(root, '.github', 'skills', 'blocked') + const removablePath = join(root, '.github', 'skills', 'removable') + mkdirSync(source, { recursive: true }) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + createDirectoryLink(source, blockedPath) + createDirectoryLink(source, removablePath) + const entries = [blockedPath, removablePath].map((path) => ({ + targetDirectory: '.github/skills', + path, + alias: path === blockedPath ? 'blocked' : 'removable', + source: { kind: 'npm' as const, id: 'pkg' }, + skillPath: 'skills/core', + linkTarget: source, + })) + + const result = reconcileManagedLinks({ + dryRun: false, + expected: [], + stateResult: { status: 'found', state: { version: 1, entries } }, + removeLink: (path) => { + if (path === blockedPath) return false + unlinkSync(path) + return true + }, + }) + + expect(result.conflicts).toEqual([blockedPath]) + expect(result.removed).toEqual([removablePath]) + expect(result.entries).toEqual([entries[0]]) + expect(lstatSync(blockedPath).isSymbolicLink()).toBe(true) + expect(existsSync(removablePath)).toBe(false) + }) + + it('does not report a failed owned-link repair as repaired', () => { + const root = tempRoot('intent-sync-repair-failure-') + const link = expected(root) + const priorTarget = join(root, 'prior-source') + mkdirSync(priorTarget, { recursive: true }) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + createDirectoryLink(priorTarget, link.path) + const priorEntry = { + targetDirectory: link.targetDirectory, + path: link.path, + alias: link.alias, + source: link.source, + skillPath: link.skillPath, + linkTarget: priorTarget, + } + + const result = reconcileManagedLinks({ + dryRun: false, + expected: [link], + stateResult: { + status: 'found', + state: { version: 1, entries: [priorEntry] }, + }, + removeLink: () => false, + }) + + expect(result.conflicts).toEqual([link.path]) + expect(result.repaired).toEqual([]) + expect(result.entries).toEqual([priorEntry]) + expect(lstatSync(link.path).isSymbolicLink()).toBe(true) + }) + + it('does not swallow unexpected removal errors', () => { + const root = tempRoot('intent-sync-remove-error-') + const source = join(root, 'source') + const path = join(root, '.github', 'skills', 'owned') + mkdirSync(source, { recursive: true }) + mkdirSync(join(root, '.github', 'skills'), { recursive: true }) + createDirectoryLink(source, path) + + expect(() => + reconcileManagedLinks({ + dryRun: false, + expected: [], + stateResult: { + status: 'found', + state: { + version: 1, + entries: [ + { + targetDirectory: '.github/skills', + path, + alias: 'owned', + source: { kind: 'npm', id: 'pkg' }, + skillPath: 'skills/core', + linkTarget: source, + }, + ], + }, + }, + removeLink: () => { + throw new Error('unexpected removal error') + }, + }), + ).toThrow('unexpected removal error') + }) }) describe('sync managed text', () => { From fa7a832f97a547d5c48f669e8da638b4bb644f3d Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 28 Jul 2026 14:49:28 -0700 Subject: [PATCH 43/56] fix(install): support PnP hashing and preserve link state --- .../intent/src/commands/install/command.ts | 16 ++- .../intent/src/commands/install/consumer.ts | 15 ++- packages/intent/src/commands/sync/command.ts | 77 ++++++++++-- packages/intent/src/commands/sync/links.ts | 13 ++ packages/intent/src/core/source-policy.ts | 6 +- .../intent/tests/consumer-install.test.ts | 114 ++++++++++++++++-- .../integration/pnp-berry-corepack.test.ts | 56 ++++++++- 7 files changed, 268 insertions(+), 29 deletions(-) diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 40c54164..818e054d 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -33,15 +33,21 @@ async function runInstallWithPrompts({ root: string selection?: SkillSelection }): Promise { - const [{ runConsumerInstall }, { scanForIntents }] = await Promise.all([ - import('./consumer.js'), - import('../../discovery/scanner.js'), - ]) + const [{ runConsumerInstall }, { createIntentFsCache }, { scanForIntents }] = + await Promise.all([ + import('./consumer.js'), + import('../../discovery/fs-cache.js'), + import('../../discovery/scanner.js'), + ]) + const fsCache = createIntentFsCache() + const scanOptions = { scope: 'local' as const, fsCache } + const scan = scanForIntents(root, scanOptions) await runConsumerInstall({ - discovered: scanForIntents(root, { scope: 'local' }).packages, + discovered: scan.packages, dryRun, existingLockReview, prompts, + readFs: fsCache.getReadFs(), root, selection, }) diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index ffd2c782..d17e1d73 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -10,8 +10,9 @@ 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 { nodeReadFs } from '../../shared/utils.js' import { runSyncCommand } from '../sync/command.js' -import { reconcileManagedLinks } from '../sync/links.js' +import { hasNonNativeLinkSource, reconcileManagedLinks } from '../sync/links.js' import { buildSyncLinkPlan } from '../sync/plan.js' import { wireIntentSyncPrepare } from '../sync/prepare.js' import { readInstallStateForLinks } from '../sync/state.js' @@ -36,6 +37,7 @@ import type { } from './config.js' import type { SkillSelection } from './plan.js' import type { HookAgent } from '../../hooks/types.js' +import type { ReadFs } from '../../shared/utils.js' import type { IntentPackage } from '../../shared/types.js' interface ConsumerInstallConfig extends IntentConsumerConfig { @@ -68,6 +70,7 @@ export interface RunConsumerInstallOptions { dryRun?: boolean existingLockReview?: 'fail' prompts: InstallerPrompter + readFs?: ReadFs root: string selection?: SkillSelection } @@ -95,6 +98,7 @@ export async function runConsumerInstall({ dryRun = false, existingLockReview, prompts, + readFs = nodeReadFs, root, selection: fixedSelection, }: RunConsumerInstallOptions): Promise { @@ -107,7 +111,7 @@ export async function runConsumerInstall({ const existingLock = readIntentLockfile(join(root, 'intent.lock')) const inventory = buildInstallDeltaInventory( discovered, - buildCurrentLockfileSources(discovered), + buildCurrentLockfileSources(discovered, readFs), existingLock, existingConfig, ) @@ -193,7 +197,7 @@ export async function runConsumerInstall({ ) const lockfile = { lockfileVersion: 1 as const, - sources: buildCurrentLockfileSources(policy.packages), + sources: buildCurrentLockfileSources(policy.packages, readFs), } if (method === 'symlink') { const linkPlan = buildSyncLinkPlan({ @@ -204,6 +208,11 @@ export async function runConsumerInstall({ packages: policy.packages, root, }) + if (hasNonNativeLinkSource(linkPlan.expected, readFs)) { + throw new Error( + 'Archive-backed/PnP sources cannot use symlink delivery; use hooks instead by setting intent.install.method to "hooks".', + ) + } const preflight = reconcileManagedLinks({ dryRun: true, expected: linkPlan.expected, diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index 19a93a92..3bb0250d 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -2,6 +2,7 @@ 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 { createIntentFsCache } from '../../discovery/fs-cache.js' import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' import { readIntentLockfile, @@ -21,7 +22,7 @@ import { } from '../install/config.js' import { buildSkillSelectionPlan } from '../install/plan.js' import { updateIntentGitignore } from './gitignore.js' -import { reconcileManagedLinks } from './links.js' +import { hasNonNativeLinkSource, reconcileManagedLinks } from './links.js' import { buildSyncLinkPlan } from './plan.js' import { INSTALL_STATE_PATH, @@ -32,6 +33,7 @@ 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' +import type { ReadFs } from '../../shared/utils.js' type SyncSkillSelection = Extract @@ -180,6 +182,7 @@ async function reviewNewDependencies({ packages, reviewedPackages, prompts, + readFs, root, }: { config: ReturnType @@ -188,6 +191,7 @@ async function reviewNewDependencies({ packages: Array reviewedPackages: Array prompts: SyncReviewPrompter + readFs: ReadFs root: string }): Promise { const decision = await prompts.reviewNewDependencies( @@ -294,7 +298,7 @@ async function reviewNewDependencies({ excludeMatchers: compileExcludePatterns(updatedConfig.exclude), }, ) - const currentSources = buildCurrentLockfileSources(policy.packages) + const currentSources = buildCurrentLockfileSources(policy.packages, readFs) const selectedPendingIds = new Set(selection.enabled) const selectedPathsBySource = new Map( packages.flatMap((pkg) => { @@ -359,6 +363,11 @@ async function reviewNewDependencies({ packages: policy.packages, root, }).expected + if (hasNonNativeLinkSource(expected, readFs)) { + fail( + 'Archive-backed/PnP sources cannot use symlink delivery; use hooks instead by setting intent.install.method to "hooks".', + ) + } const stateResult = readInstallStateForLinks(root) const preflight = reconcileManagedLinks({ dryRun: true, @@ -383,6 +392,13 @@ async function reviewNewDependencies({ stateResult, }) writeManagedLinkState(root, links) + if (links.conflicts.length > 0) { + fail( + `Intent sync found managed link conflicts: ${links.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } prompts.complete( 'Installed selected skills using the existing delivery settings.', ) @@ -413,28 +429,40 @@ export async function runSyncCommand( `Intent sync adapter for method "${config.install.method}" is not implemented yet.`, ) } + const stateResult = readInstallStateForLinks(root) + if (stateResult.status === 'malformed') { + fail( + `Intent install state is malformed at ${INSTALL_STATE_PATH}. Restore a valid copy, or remove the existing Intent-managed links and ${INSTALL_STATE_PATH}, then run \`intent install\` again.`, + ) + } let discovery: ReturnType let plan: ReturnType + const fsCache = createIntentFsCache() try { discovery = scanForConfiguredIntents({ root, config: parseSkillSources(config.skills), exclude: config.exclude, + fsCache, }) plan = buildSyncLinkPlan({ config, - currentSources: buildCurrentLockfileSources(discovery.policy.packages), + currentSources: buildCurrentLockfileSources( + discovery.policy.packages, + fsCache.getReadFs(), + ), discovered: discovery.discovered, lock, packages: discovery.policy.packages, root, }) } catch (error) { + if (stateResult.status === 'missing') throw error const links = reconcileManagedLinks({ dryRun: options.dryRun === true, expected: [], - stateResult: readInstallStateForLinks(root), + stateResult, }) if (!options.dryRun) { writeManagedLinkState(root, links) @@ -451,11 +479,40 @@ export async function runSyncCommand( } const { discovered, policy } = discovery const { expected, inventory } = plan - const links = reconcileManagedLinks({ - dryRun: options.dryRun === true, + if (hasNonNativeLinkSource(expected, fsCache.getReadFs())) { + fail( + 'Archive-backed/PnP sources cannot use symlink delivery; use hooks instead by setting intent.install.method to "hooks".', + ) + } + const preflight = reconcileManagedLinks({ + dryRun: true, expected, - stateResult: readInstallStateForLinks(root), + stateResult, }) + if (preflight.conflicts.length > 0) { + fail( + `Intent sync found managed link conflicts: ${preflight.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } + const links = options.dryRun + ? preflight + : reconcileManagedLinks({ + dryRun: false, + expected, + stateResult, + }) + if (!options.dryRun) { + writeManagedLinkState(root, links) + } + if (links.conflicts.length > 0) { + fail( + `Intent sync found managed link conflicts: ${links.conflicts + .map((path) => toProjectRelativePath(root, path)) + .join(', ')}.`, + ) + } const summaries = { newDependencies: inventory.packages .map((pkg) => ({ @@ -489,15 +546,10 @@ export async function runSyncCommand( conflicts: links.conflicts.map((path) => toProjectRelativePath(root, path)), ...summaries, } - if (!options.dryRun) { - writeManagedLinkState(root, links) - } const interactiveReview = summaries.newDependencies.length > 0 && shouldReviewInteractively(options, runtime) output(result, options.json === true, interactiveReview) - if (links.conflicts.length > 0) - fail('Intent sync found managed link conflicts.') if ( runtime.review === 'fail' && (summaries.newDependencies.length > 0 || @@ -560,6 +612,7 @@ export async function runSyncCommand( packages, reviewedPackages, prompts, + readFs: fsCache.getReadFs(), root, }) } diff --git a/packages/intent/src/commands/sync/links.ts b/packages/intent/src/commands/sync/links.ts index fcaf555e..12db950d 100644 --- a/packages/intent/src/commands/sync/links.ts +++ b/packages/intent/src/commands/sync/links.ts @@ -1,6 +1,7 @@ import { lstatSync, mkdirSync, + existsSync as nativeExistsSync, readlinkSync, realpathSync, rmdirSync, @@ -9,6 +10,7 @@ import { } from 'node:fs' import { dirname, isAbsolute, relative, resolve } from 'node:path' import type { InstallStateEntry, ReadInstallStateResult } from './state.js' +import type { ReadFs } from '../../shared/utils.js' export interface ExpectedLink { path: string @@ -29,6 +31,17 @@ export interface LinkReconciliation { entries: Array } +export function hasNonNativeLinkSource( + expected: ReadonlyArray, + readFs: ReadFs, +): boolean { + return expected.some( + (entry) => + readFs.existsSync(entry.sourceDirectory) && + !nativeExistsSync(entry.sourceDirectory), + ) +} + function exists(path: string): boolean { try { lstatSync(path) diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 61cc0602..30cd5165 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -17,6 +17,7 @@ import { readPackageJson } from './package-json.js' import { parseSkillSources } from './skill-sources.js' import { resolveProjectContext } from './project-context.js' import type { SkillUse } from '../skills/use.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js' import type { ExcludeMatcher } from './excludes.js' import type { ProjectContext } from './project-context.js' @@ -301,16 +302,19 @@ interface ExcludedSkill { export function scanForConfiguredIntents({ config, exclude, + fsCache, root, }: { config: SkillSourcesConfig exclude: Array + fsCache?: IntentFsCache root: string }): { discovered: Array policy: SourcePolicyResult } { - const scan = scanForIntents(root, { scope: 'local' }) + const scanOptions = { scope: 'local' as const, fsCache } + const scan = scanForIntents(root, scanOptions) const discovered = scan.packages.filter((pkg) => pkg.source === 'local') return { discovered, diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 6a46a83e..213b3535 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -375,7 +375,66 @@ describe('consumer install', () => { expect(advisory).not.toHaveBeenCalled() }) - it('revokes only exact owned links when sync rejects escaped skill content', async () => { + it('preserves malformed install state and managed links when sync fails', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const statePath = join(root, '.intent', 'install-state.json') + const gitignorePath = join(root, '.gitignore') + const linkPath = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + writeFileSync(statePath, '{malformed ownership state\n', 'utf8') + const stateBefore = readFileSync(statePath) + const gitignoreBefore = readFileSync(gitignorePath) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow( + /install state is malformed.*remove.*install-state\.json/i, + ) + + expect(readFileSync(statePath)).toEqual(stateBefore) + expect(readFileSync(gitignorePath)).toEqual(gitignoreBefore) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) + + it('preserves missing install state and existing links when sync conflicts', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const statePath = join(root, '.intent', 'install-state.json') + const gitignorePath = join(root, '.gitignore') + const linkPath = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + unlinkSync(statePath) + const gitignoreBefore = readFileSync(gitignorePath) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow( + 'Intent sync found managed link conflicts: .agents/skills/npm-tanstack-query-fetching.', + ) + + expect(existsSync(statePath)).toBe(false) + expect(readFileSync(gitignorePath)).toEqual(gitignoreBefore) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) + + it('revokes exact-owned links while preserving conflicting links after verification fails', async () => { const root = createProject() const packageRoot = join(root, 'node_modules', '@tanstack', 'query') const mutationRoot = join(packageRoot, 'skills', 'mutation') @@ -427,12 +486,12 @@ describe('consumer install', () => { false, ) expect(realpathSync(mutationLink)).toBe(realpathSync(retargeted)) - expect(readInstallState(root)).toMatchObject({ - status: 'found', - state: { - entries: [{ path: '.agents/skills/npm-tanstack-query-mutation' }], - }, - }) + const state = readInstallState(root) + expect(state.status).toBe('found') + if (state.status !== 'found') throw new Error('Expected install state.') + expect(state.state.entries.map((entry) => entry.path)).toEqual([ + '.agents/skills/npm-tanstack-query-mutation', + ]) expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(packageBefore) expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) }) @@ -473,6 +532,47 @@ describe('consumer install', () => { expect(readInstallState(root)).toEqual(state) }) + it('preserves missing ownership state after verification fails', async () => { + const root = createProject() + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts(), + root, + }) + const statePath = join(root, '.intent', 'install-state.json') + const gitignorePath = join(root, '.gitignore') + const linkPath = join( + root, + '.agents', + 'skills', + 'npm-tanstack-query-fetching', + ) + unlinkSync(statePath) + const gitignoreBefore = readFileSync(gitignorePath) + const outside = join(root, 'outside-content') + mkdirSync(outside) + createDirectoryLink( + outside, + join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'references', + ), + ) + + await expect( + runSyncCommand({ cwd: root }, { review: 'reminder' }), + ).rejects.toThrow(/escapes package root/i) + + expect(existsSync(statePath)).toBe(false) + expect(readFileSync(gitignorePath)).toEqual(gitignoreBefore) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) + it('installs hooks with policy and lock state without links or prepare', async () => { const root = createProject() const prompts = createPrompts({ diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index 193cc64d..5451aada 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -1,7 +1,9 @@ -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, + readFileSync, readdirSync, realpathSync, rmSync, @@ -119,6 +121,21 @@ function scaffoldBerryProject(): string { return dir } +function configureInstall(cwd: string, method: 'hooks' | 'symlink'): void { + const packageJsonPath = join(cwd, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + writeJson(packageJsonPath, { + ...packageJson, + intent: { + skills: ['@repro/skills-leaf'], + install: { + method, + targets: method === 'hooks' ? ['claude'] : ['agents'], + }, + }, + }) +} + describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { it('discovers and loads skills from a zip-backed dependency', () => { const cwd = scaffoldBerryProject() @@ -149,4 +166,41 @@ describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { ) expect(load).toContain('# Core') }, 120_000) + + it('installs hooks and locks a zip-backed dependency', () => { + const cwd = scaffoldBerryProject() + configureInstall(cwd, 'hooks') + + execFileSync('node', [cliPath, 'install', '--no-input'], { + cwd, + encoding: 'utf8', + timeout: CMD_TIMEOUT_MS, + maxBuffer: 5 * 1024 * 1024, + }) + + expect(existsSync(join(cwd, 'intent.lock'))).toBe(true) + expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(true) + }, 120_000) + + it('rejects symlink delivery for a zip-backed dependency before writing', () => { + const cwd = scaffoldBerryProject() + configureInstall(cwd, 'symlink') + + const result = spawnSync('node', [cliPath, 'install', '--no-input'], { + cwd, + encoding: 'utf8', + timeout: CMD_TIMEOUT_MS, + maxBuffer: 5 * 1024 * 1024, + }) + const output = `${result.stdout}\n${result.stderr}` + + expect(result.status).not.toBe(0) + expect(output).toMatch(/archive-backed|PnP/i) + expect(output).toMatch(/cannot use symlink delivery.*use hooks/i) + expect(output).not.toContain('ENOTDIR') + expect(existsSync(join(cwd, 'intent.lock'))).toBe(false) + expect( + existsSync(join(cwd, '.agents', 'skills', 'npm-repro-skills-leaf-core')), + ).toBe(false) + }, 120_000) }) From b74bd77ed617a748b90431f6d839b723ef7beef4 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 28 Jul 2026 14:58:00 -0700 Subject: [PATCH 44/56] fix(sync): report conflicts before failing --- packages/intent/src/commands/sync/command.ts | 92 ++++++++++++-------- packages/intent/tests/cli.test.ts | 49 +++++++++++ 2 files changed, 105 insertions(+), 36 deletions(-) diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index 3bb0250d..af76373a 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -115,6 +115,24 @@ function writeManagedLinkState(root: string, links: LinkReconciliation): void { ]) } +function buildSyncCommandResult( + root: string, + links: LinkReconciliation, + summaries: Pick< + SyncCommandResult, + 'newDependencies' | 'newSkills' | 'changed' + >, +): SyncCommandResult { + return { + created: links.created.map((path) => toProjectRelativePath(root, path)), + repaired: links.repaired.map((path) => toProjectRelativePath(root, path)), + removed: links.removed.map((path) => toProjectRelativePath(root, path)), + unchanged: links.unchanged.map((path) => toProjectRelativePath(root, path)), + conflicts: links.conflicts.map((path) => toProjectRelativePath(root, path)), + ...summaries, + } +} + function output( result: SyncCommandResult, json: boolean, @@ -484,12 +502,48 @@ export async function runSyncCommand( 'Archive-backed/PnP sources cannot use symlink delivery; use hooks instead by setting intent.install.method to "hooks".', ) } + const summaries = { + newDependencies: inventory.packages + .map((pkg) => ({ + name: sourceName(pkg), + skillCount: pkg.skills.filter((skill) => skill.policy === 'pending') + .length, + })) + .filter((entry) => entry.skillCount > 0), + newSkills: inventory.packages + .map((pkg) => ({ + name: sourceName(pkg), + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'new', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + changed: inventory.packages + .map((pkg) => ({ + name: sourceName(pkg), + skillCount: pkg.skills.filter( + (skill) => skill.policy === 'enabled' && skill.lock === 'changed', + ).length, + })) + .filter((entry) => entry.skillCount > 0), + } + const interactiveReview = + summaries.newDependencies.length > 0 && + shouldReviewInteractively(options, runtime) const preflight = reconcileManagedLinks({ dryRun: true, expected, stateResult, }) if (preflight.conflicts.length > 0) { + const reportedLinks = options.dryRun + ? preflight + : { ...preflight, created: [], repaired: [], removed: [] } + output( + buildSyncCommandResult(root, reportedLinks, summaries), + options.json === true, + false, + ) fail( `Intent sync found managed link conflicts: ${preflight.conflicts .map((path) => toProjectRelativePath(root, path)) @@ -506,49 +560,15 @@ export async function runSyncCommand( if (!options.dryRun) { writeManagedLinkState(root, links) } + const result = buildSyncCommandResult(root, links, summaries) if (links.conflicts.length > 0) { + output(result, options.json === true, false) fail( `Intent sync found managed link conflicts: ${links.conflicts .map((path) => toProjectRelativePath(root, path)) .join(', ')}.`, ) } - const summaries = { - newDependencies: inventory.packages - .map((pkg) => ({ - name: sourceName(pkg), - skillCount: pkg.skills.filter((skill) => skill.policy === 'pending') - .length, - })) - .filter((entry) => entry.skillCount > 0), - newSkills: inventory.packages - .map((pkg) => ({ - name: sourceName(pkg), - skillCount: pkg.skills.filter( - (skill) => skill.policy === 'enabled' && skill.lock === 'new', - ).length, - })) - .filter((entry) => entry.skillCount > 0), - changed: inventory.packages - .map((pkg) => ({ - name: sourceName(pkg), - skillCount: pkg.skills.filter( - (skill) => skill.policy === 'enabled' && skill.lock === 'changed', - ).length, - })) - .filter((entry) => entry.skillCount > 0), - } - const result = { - created: links.created.map((path) => toProjectRelativePath(root, path)), - repaired: links.repaired.map((path) => toProjectRelativePath(root, path)), - removed: links.removed.map((path) => toProjectRelativePath(root, path)), - unchanged: links.unchanged.map((path) => toProjectRelativePath(root, path)), - conflicts: links.conflicts.map((path) => toProjectRelativePath(root, path)), - ...summaries, - } - const interactiveReview = - summaries.newDependencies.length > 0 && - shouldReviewInteractively(options, runtime) output(result, options.json === true, interactiveReview) if ( runtime.review === 'fail' && diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 34d267fa..30ffdbd0 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -433,6 +433,55 @@ describe('cli commands', () => { ) }) + it('reports sync preflight conflicts before failing without writing', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-sync-conflict-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { + skills: ['verified#core'], + 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) + const statePath = join(root, '.intent', 'install-state.json') + const conflictPath = '.agents/skills/npm-verified-core' + const linkPath = join(root, conflictPath) + rmSync(statePath) + logSpy.mockClear() + + expect(await main(['sync', '--json'])).toBe(1) + + expect(logSpy).toHaveBeenCalledTimes(1) + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + created: [], + repaired: [], + removed: [], + conflicts: [conflictPath], + }) + expect(existsSync(statePath)).toBe(false) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + + logSpy.mockClear() + + expect(await main(['sync'])).toBe(1) + + const output = logSpy.mock.calls.flat().join('\n') + expect(output).toContain('Intent sync: 0 created, 0 repaired, 0 removed.') + expect(output).toContain(`Conflicts: ${conflictPath}.`) + expect(existsSync(statePath)).toBe(false) + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true) + }) + it('rejects the removed install --print-prompt option', async () => { const exitCode = await main(['install', '--print-prompt']) From 7745c7115490e30a6f9127236509a5e6a8d03d5d Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 28 Jul 2026 15:16:37 -0700 Subject: [PATCH 45/56] chore(lint): support TypeScript 6 --- benchmarks/intent/helpers.ts | 2 +- packages/intent/src/shared/utils.ts | 2 +- packages/intent/tests/staleness.test.ts | 4 +- pnpm-lock.yaml | 171 ++++++++++++------------ pnpm-workspace.yaml | 1 + 5 files changed, 94 insertions(+), 86 deletions(-) diff --git a/benchmarks/intent/helpers.ts b/benchmarks/intent/helpers.ts index 011fd52b..fb9bd58f 100644 --- a/benchmarks/intent/helpers.ts +++ b/benchmarks/intent/helpers.ts @@ -142,7 +142,7 @@ async function loadBuiltCliMain(): Promise< ) } - return module.main as (argv?: Array) => Promise + return module.main }, ) diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index ec8057f8..ddbf71ed 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -164,7 +164,7 @@ export function getDeps( for (const field of fields) { const d = pkgJson[field] if (d && typeof d === 'object') { - for (const name of Object.keys(d as Record)) { + for (const name of Object.keys(d)) { deps.add(name) } } diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index fa266570..b85954d9 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -73,11 +73,11 @@ function mockFetchVersion(version: string): void { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ version }), - } as Response) + }) } function mockFetchNotOk(): void { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false }) } beforeEach(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93c363cc..fe581541 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: pino: 10.3.1 + typescript-eslint: 8.65.0 undici-types: 8.4.1 packageExtensionsChecksum: sha256-RTw5AJ+OM+YIsEdtsFGhLkoKUR+JYXJDfUxe9dr2paA= @@ -22,7 +23,7 @@ importers: version: 2.31.0(@types/node@25.0.9) '@tanstack/eslint-config': specifier: 0.4.0 - version: 0.4.0(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + version: 0.4.0(@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) '@tanstack/typedoc-config': specifier: ^0.3.3 version: 0.3.3(typescript@6.0.3) @@ -34,7 +35,7 @@ importers: version: 9.39.4(jiti@2.7.0) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)) knip: specifier: ^6.16.1 version: 6.16.1 @@ -1245,43 +1246,43 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.56.1': - resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.56.1 + '@typescript-eslint/parser': ^8.65.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.56.1': - resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.56.1': - resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/types@8.53.0': resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==} @@ -1291,21 +1292,25 @@ packages: resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.56.1': - resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -3587,8 +3592,8 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -3674,12 +3679,12 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x - typescript-eslint@8.56.1: - resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} @@ -4874,15 +4879,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@tanstack/eslint-config@0.4.0(@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint/js': 10.0.1(eslint@9.39.4(jiti@2.7.0)) '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.4(jiti@2.7.0)) eslint: 9.39.4(jiti@2.7.0) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-n: 17.24.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) globals: 17.4.0 - typescript-eslint: 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + typescript-eslint: 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - '@typescript-eslint/utils' @@ -4956,60 +4961,60 @@ snapshots: '@types/node': 25.0.9 optional: true - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/parser': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.65.0 eslint: 9.39.4(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 eslint: 9.39.4(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.56.1': + '@typescript-eslint/scope-manager@8.65.0': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 9.39.4(jiti@2.7.0) - ts-api-utils: 2.4.0(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -5018,35 +5023,37 @@ snapshots: '@typescript-eslint/types@8.56.1': {} - '@typescript-eslint/typescript-estree@8.56.1(typescript@6.0.3)': + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.65.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/project-service': 8.65.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.4 tinyglobby: 0.2.17 - ts-api-utils: 2.4.0(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) eslint: 9.39.4(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.56.1': + '@typescript-eslint/visitor-keys@8.65.0': dependencies: - '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.0 '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -5884,7 +5891,7 @@ snapshots: eslint: 9.39.4(jiti@2.7.0) eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@typescript-eslint/types': 8.53.0 comment-parser: 1.4.4 @@ -5897,7 +5904,7 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) transitivePeerDependencies: - supports-color @@ -5916,11 +5923,11 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0)): dependencies: eslint: 9.39.4(jiti@2.7.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) eslint-scope@8.4.0: dependencies: @@ -7567,7 +7574,7 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@2.4.0(typescript@6.0.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -7645,12 +7652,12 @@ snapshots: typescript: 6.0.3 yaml: 2.8.3 - typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) eslint: 9.39.4(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c9f7ae3..7bc02e80 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ trustPolicy: 'no-downgrade' overrides: pino: 10.3.1 + typescript-eslint: 8.65.0 undici-types: 8.4.1 packageExtensions: From ca0853df79d105a00e078174c7eca6fd0332bfd8 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 28 Jul 2026 19:29:16 -0700 Subject: [PATCH 46/56] fix(intent): disambiguate package-prefixed skill aliases --- packages/intent/src/commands/install/plan.ts | 49 ++++++----- packages/intent/src/core/intent-core.ts | 65 ++++++++------ packages/intent/src/core/load-resolution.ts | 8 +- packages/intent/src/core/source-policy.ts | 89 +++++++++++--------- packages/intent/tests/core.test.ts | 69 +++++++++++++++ packages/intent/tests/install-plan.test.ts | 12 +++ packages/intent/tests/source-policy.test.ts | 36 ++++---- 7 files changed, 218 insertions(+), 110 deletions(-) diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index 3d3523f7..841f1dc4 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -111,6 +111,7 @@ export function skillSelectionId( function classifySkillPolicy( pkg: Pick, skillName: string, + packageSkills: ReadonlyArray, sources: SkillSourcesConfig, sourcePolicy: CompiledSkillSourcePolicy, excludes: Array, @@ -122,7 +123,7 @@ function classifySkillPolicy( ) { return 'excluded' } - return sourcePolicy.permitsSkill(pkg.name, skillName, pkg.kind) + return sourcePolicy.permitsSkill(pkg.name, skillName, pkg.kind, packageSkills) ? 'enabled' : 'pending' } @@ -215,26 +216,30 @@ export function buildSkillSelectionPlan( 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.`, + packages: packages.map((pkg) => { + const packageSkills = sortedSkills(pkg) + return { + name: pkg.name, + kind: pkg.kind, + skills: packageSkills.map((skill) => { + const id = skillSelectionId(pkg, skill) + const status = classifySkillPolicy( + pkg, + skill.name, + packageSkills, + sources, + sourcePolicy, + excludeMatchers, ) - } - return { id, status } - }), - })), + 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() @@ -357,13 +362,15 @@ export function buildInstallDeltaInventory( seen.add(key) const current = currentByKey.get(key) const locked = lockedByKey.get(key) + const packageSkills = sortedSkills(pkg) return { name: pkg.name, kind: pkg.kind, - skills: sortedSkills(pkg).map((skill) => { + skills: packageSkills.map((skill) => { const policy = classifySkillPolicy( pkg, skill.name, + packageSkills, sources, sourcePolicy, excludes, diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index c7fc7c73..6b54a39d 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -143,6 +143,7 @@ export function listIntentSkills( pkg.name, skill.name, pkg.kind, + pkg.skills, ) return decision.source ? `Allowed by intent.skills[${JSON.stringify(decision.source.raw)}]` @@ -380,13 +381,16 @@ function resolveIntentSkillInCwd( const scanOptions = toScanOptions(options) const scope = getScanScope(scanOptions) - const fastPathResolved = resolveSkillUseFastPath( - parsedUse, - options, - projectContext, - cwd, - fsCache, - ) + const skipFastPath = + config.mode === 'explicit' && + sourcePolicy.matchers.some( + (matcher) => + matcher.matchesSkill !== undefined && + matcher.matchesPackage(parsedUse.packageName), + ) + const fastPathResolved = skipFastPath + ? null + : resolveSkillUseFastPath(parsedUse, options, projectContext, cwd, fsCache) if (fastPathResolved) { if (!sourcePolicy.permits(parsedUse.packageName, fastPathResolved.kind)) { const lateRefusal = packageNotListedRefusal(use, parsedUse.packageName) @@ -395,7 +399,7 @@ function resolveIntentSkillInCwd( if ( !sourcePolicy.permitsSkill( parsedUse.packageName, - parsedUse.skillName, + fastPathResolved.skillName, fastPathResolved.kind, ) ) { @@ -426,7 +430,11 @@ function resolveIntentSkillInCwd( ) } - const { scan: scanResult, droppedNames } = scanForPolicedIntents({ + const { + scan: scanResult, + discoveredPackages, + droppedNames, + } = scanForPolicedIntents({ cwd, scanOptions: withFsCache(scanOptions, fsCache), coreOptions: options, @@ -437,15 +445,29 @@ 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, - ) + let resolved: ReturnType + try { + resolved = resolveSkillUse(use, { + ...scanResult, + packages: discoveredPackages, + }) + } catch (err) { + if (err instanceof ResolveSkillUseError) { + throw new IntentCoreError(err.code, err.message, { + suggestedSkills: err.suggestedSkills, + }) + } + throw err + } + const resolvedPackage = discoveredPackages.find( + (pkg) => pkg.name === resolved.packageName, + )! if ( - survivingPackage && !sourcePolicy.permitsSkill( - parsedUse.packageName, - parsedUse.skillName, - survivingPackage.kind, + resolved.packageName, + resolved.skillName, + resolved.kind, + resolvedPackage.skills, ) ) { const lateRefusal = skillNotListedRefusal( @@ -455,17 +477,6 @@ function resolveIntentSkillInCwd( ) throw new IntentCoreError(lateRefusal.code, lateRefusal.message) } - let resolved: ReturnType - try { - resolved = resolveSkillUse(use, scanResult) - } catch (err) { - if (err instanceof ResolveSkillUseError) { - throw new IntentCoreError(err.code, err.message, { - suggestedSkills: err.suggestedSkills, - }) - } - throw err - } return toResolvedIntentSkill( cwd, diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index 1dec200b..7cf198c3 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -181,12 +181,10 @@ function getWorkspaceLoadFastPathCandidateDirs( return candidates } -export type FastPathResolveResult = ResolveSkillResult - function resolveScannedPackageSkill( scanned: ReturnType, parsedUse: SkillUse, -): FastPathResolveResult | null { +): ResolveSkillResult | null { const pkg = scanned.package if (!pkg || pkg.name !== parsedUse.packageName) return null @@ -217,7 +215,7 @@ function resolveFromPackageRoots( parsedUse: SkillUse, cwd: string, fsCache: IntentFsCache, -): FastPathResolveResult | null { +): ResolveSkillResult | null { for (const packageRoot of packageRoots) { const scanned = scanIntentPackageAtRoot(packageRoot, { fallbackName: parsedUse.packageName, @@ -251,7 +249,7 @@ export function resolveSkillUseFastPath( context = resolveProjectContext({ cwd: process.cwd() }), cwd = context.cwd, fsCache = createIntentFsCache(), -): FastPathResolveResult | null { +): ResolveSkillResult | null { if (options.globalOnly) return null if (shouldSkipFastPathForYarnPnp(context, cwd)) return null diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 30cd5165..c39f6c2f 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -63,15 +63,19 @@ interface SkillSourcePolicyDecision { source: ExplicitSkillSource | null } +type PackageSkills = ReadonlyArray + interface SkillSourceMatcher { source: ExplicitSkillSource matchesPackage: ( packageName: string, packageKind?: 'npm' | 'workspace', ) => boolean - matchesSkill: - | ((packageName: string, skillName: string) => boolean) - | undefined + matchesSkill?: ( + packageName: string, + skillName: string, + packageSkills?: PackageSkills, + ) => boolean } export interface CompiledSkillSourcePolicy { @@ -81,15 +85,13 @@ export interface CompiledSkillSourcePolicy { packageName: string, skillName: string, packageKind?: 'npm' | 'workspace', + packageSkills?: PackageSkills, ) => boolean - explainPermits: ( - packageName: string, - packageKind?: 'npm' | 'workspace', - ) => SkillSourcePolicyDecision explainPermitsSkill: ( packageName: string, skillName: string, packageKind?: 'npm' | 'workspace', + packageSkills?: PackageSkills, ) => SkillSourcePolicyDecision } @@ -122,8 +124,16 @@ function compileSkillSourceMatcher( matchesSkill: matchesSkillName === undefined ? undefined - : (packageName, skillName) => - skillNameVariants(packageName, skillName).some(matchesSkillName), + : (packageName, skillName, packageSkills) => { + if (matchesSkillName(skillName)) return true + + return skillNameVariants(packageName, skillName).some( + (variant) => + variant !== skillName && + !packageSkills?.some((skill) => skill.name === variant) && + matchesSkillName(variant), + ) + }, } } @@ -137,7 +147,6 @@ export function compileSkillSourcePolicy( matchers: [], permits: () => true, permitsSkill: () => true, - explainPermits: () => ({ permitted: true, source: null }), explainPermitsSkill: () => ({ permitted: true, source: null }), } case 'empty': @@ -145,45 +154,35 @@ export function compileSkillSourcePolicy( matchers: [], permits: () => false, permitsSkill: () => false, - explainPermits: () => ({ permitted: false, source: null }), explainPermitsSkill: () => ({ permitted: false, source: null }), } case 'explicit': { const matchers = config.sources.map(compileSkillSourceMatcher) + const explainPermitsSkill = ( + packageName: string, + skillName: string, + packageKind?: 'npm' | 'workspace', + packageSkills?: PackageSkills, + ): SkillSourcePolicyDecision => { + const matcher = matchers.find( + (candidate) => + candidate.matchesPackage(packageName, packageKind) && + (candidate.matchesSkill === undefined || + candidate.matchesSkill(packageName, skillName, packageSkills)), + ) + return { + permitted: matcher !== undefined, + source: matcher?.source ?? null, + } + } return { matchers, permits: (packageName, packageKind) => matchers.some((matcher) => matcher.matchesPackage(packageName, packageKind), ), - permitsSkill: (packageName, skillName, packageKind) => - matchers.some( - (matcher) => - matcher.matchesPackage(packageName, packageKind) && - (matcher.matchesSkill === undefined || - matcher.matchesSkill(packageName, skillName)), - ), - explainPermits: (packageName, packageKind) => { - const matcher = matchers.find((candidate) => - candidate.matchesPackage(packageName, packageKind), - ) - return { - permitted: matcher !== undefined, - source: matcher?.source ?? null, - } - }, - explainPermitsSkill: (packageName, skillName, packageKind) => { - const matcher = matchers.find( - (candidate) => - candidate.matchesPackage(packageName, packageKind) && - (candidate.matchesSkill === undefined || - candidate.matchesSkill(packageName, skillName)), - ) - return { - permitted: matcher !== undefined, - source: matcher?.source ?? null, - } - }, + permitsSkill: (...args) => explainPermitsSkill(...args).permitted, + explainPermitsSkill, } } } @@ -346,11 +345,13 @@ export function applySourcePolicy( const excludedSkills: Array = [] for (const pkg of scanResult.packages) { + const permitsSkill = (skillName: string) => + sourcePolicy.permitsSkill(pkg.name, skillName, pkg.kind, pkg.skills) 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)) { + if (permitsSkill(skill.name)) { excludedSkills.push({ package: pkg, skill, @@ -372,7 +373,7 @@ export function applySourcePolicy( const skills: Array = [] const hiddenSkills: Array = [] for (const skill of pkg.skills) { - if (!sourcePolicy.permitsSkill(pkg.name, skill.name, pkg.kind)) { + if (!permitsSkill(skill.name)) { hiddenSkills.push(skill.name) continue } @@ -423,7 +424,9 @@ export function applySourcePolicy( (pkg) => matcher.matchesPackage(pkg.name, pkg.kind) && (matchesSkill === undefined || - pkg.skills.some((skill) => matchesSkill(pkg.name, skill.name))), + pkg.skills.some((skill) => + matchesSkill(pkg.name, skill.name, pkg.skills), + )), ) if (notDiscovered) { emit( @@ -471,6 +474,7 @@ export interface PolicedScan { hiddenSourceCount: number hiddenSources: Array excludedSkills: Array + discoveredPackages: Array scan: ScanResult excludePatterns: Array droppedNames: Array @@ -510,6 +514,7 @@ export function scanForPolicedIntents(params: { hiddenSourceCount: policy.hiddenSourceCount, hiddenSources: audience === 'agent' ? [] : policy.hiddenSources, excludedSkills: audience === 'agent' ? [] : policy.excludedSkills, + discoveredPackages: scanResult.packages, scan: { ...scanResult, packages: policy.packages, diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 3a74fd47..211ab3bd 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -792,6 +792,75 @@ describe('loadIntentSkill', () => { ) }) + it.each([ + ['@scope/ui#ui/theme', ['ui/theme'], true], + ['@scope/ui#ui/*', ['ui/theme'], true], + ['@scope/ui#ui/theme', ['theme', 'ui/theme'], false], + ['@scope/ui#ui/*', ['theme', 'ui/theme'], false], + ] as const)( + 'loads canonical policy %s from skills %j without redirecting an exact sibling', + (policy, skillNames, shortAliasAllowed) => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: [policy] }, + }) + for (const skillName of skillNames) { + writeInstalledIntentPackage(root, { + name: '@scope/ui', + version: '1.0.0', + skillName, + description: skillName, + }) + } + + expect( + loadIntentSkill('@scope/ui#ui/theme', { cwd: root }).skillName, + ).toBe('ui/theme') + if (shortAliasAllowed) { + expect( + loadIntentSkill('@scope/ui#theme', { cwd: root }).skillName, + ).toBe('ui/theme') + } else { + expect(() => loadIntentSkill('@scope/ui#theme', { cwd: root })).toThrow( + 'Cannot load skill use "@scope/ui#theme": skill "@scope/ui#theme" is not listed in intent.skills.', + ) + } + }, + ) + + it('does not authorize a prefixed skill through an ambiguous short alias', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['@scope/ui#theme'] }, + }) + writeInstalledIntentPackage(root, { + name: '@scope/ui', + version: '1.0.0', + skillName: 'theme', + description: 'Theme', + }) + writeInstalledIntentPackage(root, { + name: '@scope/ui', + version: '1.0.0', + skillName: 'ui/theme', + description: 'Prefixed theme', + }) + + expect(loadIntentSkill('@scope/ui#theme', { cwd: root }).skillName).toBe( + 'theme', + ) + expect(() => loadIntentSkill('@scope/ui#ui/theme', { cwd: root })).toThrow( + 'Cannot load skill use "@scope/ui#ui/theme": skill "@scope/ui#ui/theme" is not listed in intent.skills.', + ) + + writeFileSync(join(root, '.pnp.cjs'), 'module.exports = {}\n') + expect(() => loadIntentSkill('@scope/ui#ui/theme', { cwd: root })).toThrow( + 'Cannot load skill use "@scope/ui#ui/theme": skill "@scope/ui#ui/theme" is not listed in intent.skills.', + ) + }) + it('refuses a prefixed skill excluded by canonical name when loaded by short alias', () => { const appDir = join(root, 'packages', 'app') const routerDir = join(root, 'packages', 'router-core') diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts index 1e9a0c21..bbab81d5 100644 --- a/packages/intent/tests/install-plan.test.ts +++ b/packages/intent/tests/install-plan.test.ts @@ -77,6 +77,18 @@ describe('installer selection planning', () => { ) }) + it('leaves a prefixed skill pending when an exact sibling owns its short alias', () => { + expect(() => + buildSkillSelectionPlan([pkg('@scope/ui', ['theme', 'ui/theme'])], { + mode: 'configured-policy', + skills: ['@scope/ui#theme'], + exclude: [], + }), + ).toThrow( + 'Configured policy leaves "@scope/ui#ui/theme" 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' }), diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 7113d3ec..7370ed59 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -269,10 +269,6 @@ 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' }, @@ -301,10 +297,6 @@ describe('compiled policy explanations', () => { (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, @@ -388,13 +380,27 @@ describe('applySourcePolicy — skill-level allowlist entries', () => { 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.each(['@scope/ui#theme', '@scope/ui#the*'])( + 'matches a prefixed skill by its unambiguous short alias with %s', + (source) => { + const result = applySourcePolicy( + { packages: [pkg('@scope/ui', ['ui/theme', 'ui/layout'])] }, + { config: config([source]), excludeMatchers: [] }, + ) + expect(skillNames(result.packages)).toEqual([['ui/theme']]) + }, + ) + + it.each(['@scope/ui#theme', '@scope/ui#the*'])( + 'prefers an exact skill name over a prefixed skill short alias with %s', + (source) => { + const result = applySourcePolicy( + { packages: [pkg('@scope/ui', ['theme', 'ui/theme'])] }, + { config: config([source]), excludeMatchers: [] }, + ) + expect(skillNames(result.packages)).toEqual([['theme']]) + }, + ) it('reports a skill entry that matched no discovered skill', () => { const result = applySourcePolicy( From 417a778021576e03fc51debe8ed3d0e08127e095 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 28 Jul 2026 19:51:07 -0700 Subject: [PATCH 47/56] fix(sync): contain and recover managed link writes --- .../intent/src/commands/install/consumer.ts | 1 + packages/intent/src/commands/sync/command.ts | 38 ++--- packages/intent/src/commands/sync/links.ts | 98 ++++++++--- packages/intent/tests/sync.test.ts | 153 +++++++++++++++--- 4 files changed, 216 insertions(+), 74 deletions(-) diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index d17e1d73..c6206806 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -214,6 +214,7 @@ export async function runConsumerInstall({ ) } const preflight = reconcileManagedLinks({ + root, dryRun: true, expected: linkPlan.expected, stateResult: readInstallStateForLinks(root), diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index af76373a..e1fc34ba 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -20,7 +20,10 @@ import { readIntentConsumerConfig, updateIntentConsumerConfigText, } from '../install/config.js' -import { buildSkillSelectionPlan } from '../install/plan.js' +import { + buildSkillSelectionPlan, + summarizeInstallDeltaInventory, +} from '../install/plan.js' import { updateIntentGitignore } from './gitignore.js' import { hasNonNativeLinkSource, reconcileManagedLinks } from './links.js' import { buildSyncLinkPlan } from './plan.js' @@ -388,6 +391,7 @@ async function reviewNewDependencies({ } const stateResult = readInstallStateForLinks(root) const preflight = reconcileManagedLinks({ + root, dryRun: true, expected, stateResult, @@ -405,6 +409,7 @@ async function reviewNewDependencies({ ) writeIntentLockfile(join(root, 'intent.lock'), prospectiveLock) const links = reconcileManagedLinks({ + root, dryRun: false, expected, stateResult, @@ -478,6 +483,7 @@ export async function runSyncCommand( } catch (error) { if (stateResult.status === 'missing') throw error const links = reconcileManagedLinks({ + root, dryRun: options.dryRun === true, expected: [], stateResult, @@ -502,35 +508,14 @@ export async function runSyncCommand( 'Archive-backed/PnP sources cannot use symlink delivery; use hooks instead by setting intent.install.method to "hooks".', ) } - const summaries = { - newDependencies: inventory.packages - .map((pkg) => ({ - name: sourceName(pkg), - skillCount: pkg.skills.filter((skill) => skill.policy === 'pending') - .length, - })) - .filter((entry) => entry.skillCount > 0), - newSkills: inventory.packages - .map((pkg) => ({ - name: sourceName(pkg), - skillCount: pkg.skills.filter( - (skill) => skill.policy === 'enabled' && skill.lock === 'new', - ).length, - })) - .filter((entry) => entry.skillCount > 0), - changed: inventory.packages - .map((pkg) => ({ - name: sourceName(pkg), - skillCount: pkg.skills.filter( - (skill) => skill.policy === 'enabled' && skill.lock === 'changed', - ).length, - })) - .filter((entry) => entry.skillCount > 0), - } + const { newDependencies, newSkills, changed } = + summarizeInstallDeltaInventory(inventory) + const summaries = { newDependencies, newSkills, changed } const interactiveReview = summaries.newDependencies.length > 0 && shouldReviewInteractively(options, runtime) const preflight = reconcileManagedLinks({ + root, dryRun: true, expected, stateResult, @@ -553,6 +538,7 @@ export async function runSyncCommand( const links = options.dryRun ? preflight : reconcileManagedLinks({ + root, dryRun: false, expected, stateResult, diff --git a/packages/intent/src/commands/sync/links.ts b/packages/intent/src/commands/sync/links.ts index 12db950d..38f7ec44 100644 --- a/packages/intent/src/commands/sync/links.ts +++ b/packages/intent/src/commands/sync/links.ts @@ -73,6 +73,30 @@ function isInside(path: string, parent: string): boolean { return value === '' || (!value.startsWith('..') && !isAbsolute(value)) } +function hasContainedParent(path: string, root: string): boolean { + try { + const resolvedRoot = resolve(root) + const resolvedParent = resolve(dirname(path)) + if (!isInside(resolvedParent, resolvedRoot)) return false + const realRoot = realpathSync(resolvedRoot) + let existingParent = resolvedParent + for (;;) { + try { + lstatSync(existingParent) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return false + } + const parent = dirname(existingParent) + if (parent === existingParent) return false + existingParent = parent + } + return isInside(realpathSync(existingParent), realRoot) + } catch { + return false + } +} + function sourceTarget(expected: ExpectedLink): string | null { try { const packageRoot = realpathSync(expected.packageRoot) @@ -97,13 +121,19 @@ function stateEntry( } } -function createLink(path: string, target: string): void { - mkdirSync(dirname(path), { recursive: true }) - if (process.platform === 'win32') { - symlinkSync(target, path, 'junction') - return +function createLink(path: string, target: string): boolean { + try { + mkdirSync(dirname(path), { recursive: true }) + if (process.platform === 'win32') { + symlinkSync(target, path, 'junction') + } else { + symlinkSync(relative(dirname(path), target), path, 'dir') + } + return true + } catch (error) { + if (typeof (error as NodeJS.ErrnoException).code === 'string') return false + throw error } - symlinkSync(relative(dirname(path), target), path, 'dir') } // `rmSync` with recursive+force silently leaves some directory symlinks in place. @@ -137,14 +167,18 @@ function compareStrings(left: string, right: string): number { } export function reconcileManagedLinks({ + root, dryRun, expected, stateResult, + createLink: createManagedLink = createLink, removeLink: removeManagedLink = removeLink, }: { + root: string dryRun: boolean expected: ReadonlyArray stateResult: ReadInstallStateResult + createLink?: (path: string, target: string) => boolean removeLink?: (path: string) => boolean }): LinkReconciliation { const result: LinkReconciliation = { @@ -158,27 +192,38 @@ export function reconcileManagedLinks({ 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])) + const preserveConflict = ( + path: string, + priorEntry?: InstallStateEntry, + ): void => { + result.conflicts.push(path) + if (priorEntry) result.entries.push(priorEntry) + } for (const entry of [...expected].sort((left, right) => compareStrings(left.path, right.path), )) { + const priorEntry = priorByPath.get(entry.path) + if (!hasContainedParent(entry.path, root)) { + preserveConflict(entry.path, priorEntry) + continue + } const target = sourceTarget(entry) if (!target) { - result.conflicts.push(entry.path) - const priorEntry = priorByPath.get(entry.path) - if (priorEntry) result.entries.push(priorEntry) + preserveConflict(entry.path, priorEntry) continue } - const priorEntry = priorByPath.get(entry.path) if (!exists(entry.path)) { - if (!dryRun) createLink(entry.path, target) + if (!dryRun && !createManagedLink(entry.path, target)) { + preserveConflict(entry.path, priorEntry) + continue + } 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) + preserveConflict(entry.path, priorEntry) continue } const current = resolveLinkTarget(entry.path) @@ -188,25 +233,28 @@ export function reconcileManagedLinks({ continue } if (current === priorEntry.linkTarget) { - if (!dryRun) { - if (!removeManagedLink(entry.path)) { - result.conflicts.push(entry.path) - result.entries.push(priorEntry) - continue - } - createLink(entry.path, target) + if ( + !dryRun && + (!removeManagedLink(entry.path) || + !createManagedLink(entry.path, target)) + ) { + preserveConflict(entry.path, priorEntry) + continue } result.repaired.push(entry.path) result.entries.push(stateEntry(entry, target)) continue } - result.conflicts.push(entry.path) - result.entries.push(priorEntry) + preserveConflict(entry.path, priorEntry) } if (stateResult.status === 'found') { for (const priorEntry of prior) { if (expectedByPath.has(priorEntry.path)) continue + if (!hasContainedParent(priorEntry.path, root)) { + preserveConflict(priorEntry.path, priorEntry) + continue + } if (!exists(priorEntry.path)) { result.removed.push(priorEntry.path) continue @@ -216,15 +264,13 @@ export function reconcileManagedLinks({ resolveLinkTarget(priorEntry.path) === priorEntry.linkTarget ) { if (!dryRun && !removeManagedLink(priorEntry.path)) { - result.conflicts.push(priorEntry.path) - result.entries.push(priorEntry) + preserveConflict(priorEntry.path, priorEntry) continue } result.removed.push(priorEntry.path) continue } - result.conflicts.push(priorEntry.path) - result.entries.push(priorEntry) + preserveConflict(priorEntry.path, priorEntry) } } diff --git a/packages/intent/tests/sync.test.ts b/packages/intent/tests/sync.test.ts index bffcb6c3..721a6ab6 100644 --- a/packages/intent/tests/sync.test.ts +++ b/packages/intent/tests/sync.test.ts @@ -9,7 +9,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, 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' @@ -157,6 +157,63 @@ describe('managed sync links', () => { } } + function ownedEntry( + link: ReturnType, + linkTarget = link.sourceDirectory, + ) { + const { targetDirectory, path, alias, source, skillPath } = link + return { targetDirectory, path, alias, source, skillPath, linkTarget } + } + + it('rejects outside-parent create, repair, and stale removal', () => { + const parent = tempRoot('intent-sync-outside-parent-') + const root = join(parent, 'project') + const outside = join(parent, 'outside') + const priorTarget = join(parent, 'prior-source') + mkdirSync(root) + mkdirSync(join(outside, 'skills'), { recursive: true }) + mkdirSync(priorTarget) + createDirectoryLink(outside, join(root, '.github')) + const link = expected(root) + + const created = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { status: 'missing' }, + }) + expect(created.conflicts).toEqual([link.path]) + expect(existsSync(link.path)).toBe(false) + + createDirectoryLink(priorTarget, link.path) + const priorEntry = ownedEntry(link, priorTarget) + const stateResult = { + status: 'found' as const, + state: { version: 1 as const, entries: [priorEntry] }, + } + const repair = reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult, + }) + const stale = reconcileManagedLinks({ + root, + dryRun: false, + expected: [], + stateResult, + }) + + expect({ + repair: [repair.conflicts, repair.repaired, repair.entries], + stale: [stale.conflicts, stale.removed, stale.entries], + }).toEqual({ + repair: [[link.path], [], [priorEntry]], + stale: [[link.path], [], [priorEntry]], + }) + expect(lstatSync(link.path).isSymbolicLink()).toBe(true) + }) + it('rejects persisted link paths that traverse outside the project', () => { const parent = tempRoot('intent-sync-state-path-') const root = join(parent, 'project') @@ -187,6 +244,7 @@ describe('managed sync links', () => { const stateResult = readInstallStateForLinks(root) const result = reconcileManagedLinks({ + root, dryRun: false, expected: [], stateResult, @@ -207,6 +265,7 @@ describe('managed sync links', () => { const root = tempRoot('intent-sync-links-') const link = expected(root) const first = reconcileManagedLinks({ + root, dryRun: false, expected: [link], stateResult: { status: 'missing' }, @@ -214,6 +273,7 @@ describe('managed sync links', () => { expect(first.created).toEqual([link.path]) expect(lstatSync(link.path).isSymbolicLink()).toBe(true) const second = reconcileManagedLinks({ + root, dryRun: false, expected: [link], stateResult: { @@ -224,6 +284,7 @@ describe('managed sync links', () => { expect(second.unchanged).toEqual([link.path]) unlinkSync(link.path) const repaired = reconcileManagedLinks({ + root, dryRun: false, expected: [link], stateResult: { @@ -233,6 +294,7 @@ describe('managed sync links', () => { }) expect(repaired.created).toEqual([link.path]) const cleanup = reconcileManagedLinks({ + root, dryRun: false, expected: [], stateResult: { @@ -250,6 +312,7 @@ describe('managed sync links', () => { mkdirSync(join(root, '.github', 'skills'), { recursive: true }) createDirectoryLink(join(root, 'somewhere-else'), link.path) const conflict = reconcileManagedLinks({ + root, dryRun: false, expected: [link], stateResult: { status: 'missing' }, @@ -260,6 +323,7 @@ describe('managed sync links', () => { path: join(root, '.github', 'skills', 'dry-run'), } const dryRun = reconcileManagedLinks({ + root, dryRun: true, expected: [dryRunLink], stateResult: { status: 'missing' }, @@ -275,22 +339,14 @@ describe('managed sync links', () => { createDirectoryLink(join(root, 'missing'), link.path) const result = reconcileManagedLinks({ + root, 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, - }, - ], + entries: [ownedEntry(link)], }, }, }) @@ -318,6 +374,7 @@ describe('managed sync links', () => { })) const result = reconcileManagedLinks({ + root, dryRun: false, expected: [], stateResult: { status: 'found', state: { version: 1, entries } }, @@ -335,36 +392,87 @@ describe('managed sync links', () => { expect(existsSync(removablePath)).toBe(false) }) - it('does not report a failed owned-link repair as repaired', () => { + it('records a fresh create failure and continues creating later links', () => { + const root = tempRoot('intent-sync-create-failure-') + const link = expected(root) + const createdBefore = { + ...link, + path: join(dirname(link.path), 'a-created'), + } + const blocked = { ...link, path: join(dirname(link.path), 'm-blocked') } + const created = { ...link, path: join(dirname(link.path), 'z-created') } + mkdirSync(dirname(link.path), { recursive: true }) + + const result = reconcileManagedLinks({ + root, + dryRun: false, + expected: [blocked, created, createdBefore], + stateResult: { status: 'missing' }, + createLink: (path, target) => { + if (path === blocked.path) return false + createDirectoryLink(target, path) + return true + }, + }) + + expect(result.conflicts).toEqual([blocked.path]) + expect(result.created).toEqual([createdBefore.path, created.path]) + expect(result.entries.map((entry) => entry.path)).toEqual([ + createdBefore.path, + created.path, + ]) + expect(existsSync(blocked.path)).toBe(false) + expect(lstatSync(createdBefore.path).isSymbolicLink()).toBe(true) + expect(lstatSync(created.path).isSymbolicLink()).toBe(true) + }) + + it('does not swallow unexpected creation errors', () => { + const root = tempRoot('intent-sync-create-error-') + const link = expected(root) + + expect(() => + reconcileManagedLinks({ + root, + dryRun: false, + expected: [link], + stateResult: { status: 'missing' }, + createLink: () => { + throw new Error('unexpected creation error') + }, + }), + ).toThrow('unexpected creation error') + }) + + it.each([ + { failure: 'removal', removeLink: () => false, linkRemains: true }, + { failure: 'creation', createLink: () => false, linkRemains: false }, + ])('retains prior state when owned-link repair $failure fails', (failure) => { const root = tempRoot('intent-sync-repair-failure-') const link = expected(root) const priorTarget = join(root, 'prior-source') mkdirSync(priorTarget, { recursive: true }) mkdirSync(join(root, '.github', 'skills'), { recursive: true }) createDirectoryLink(priorTarget, link.path) - const priorEntry = { - targetDirectory: link.targetDirectory, - path: link.path, - alias: link.alias, - source: link.source, - skillPath: link.skillPath, - linkTarget: priorTarget, - } + const priorEntry = ownedEntry(link, priorTarget) const result = reconcileManagedLinks({ + root, dryRun: false, expected: [link], stateResult: { status: 'found', state: { version: 1, entries: [priorEntry] }, }, - removeLink: () => false, + createLink: failure.createLink, + removeLink: failure.removeLink, }) expect(result.conflicts).toEqual([link.path]) expect(result.repaired).toEqual([]) expect(result.entries).toEqual([priorEntry]) - expect(lstatSync(link.path).isSymbolicLink()).toBe(true) + expect(existsSync(link.path)).toBe(failure.linkRemains) + if (failure.linkRemains) + expect(lstatSync(link.path).isSymbolicLink()).toBe(true) }) it('does not swallow unexpected removal errors', () => { @@ -377,6 +485,7 @@ describe('managed sync links', () => { expect(() => reconcileManagedLinks({ + root, dryRun: false, expected: [], stateResult: { From 5ceaef0cce77a545de1a0bcd93f28bead87a0b91 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 28 Jul 2026 20:14:50 -0700 Subject: [PATCH 48/56] fix(intent): bound hook input and preserve file modes --- packages/intent/src/catalog.ts | 68 +++++++++++++++---- packages/intent/src/commands/hooks/command.ts | 3 - packages/intent/src/hooks/install.ts | 41 ++--------- packages/intent/src/shared/atomic-write.ts | 13 +++- packages/intent/tests/hooks-install.test.ts | 13 +++- .../tests/integration/catalog-bundle.test.ts | 55 +++++++++++++++ packages/intent/tests/lockfile.test.ts | 22 +++++- 7 files changed, 161 insertions(+), 54 deletions(-) diff --git a/packages/intent/src/catalog.ts b/packages/intent/src/catalog.ts index 80e3a29e..bee2511d 100644 --- a/packages/intent/src/catalog.ts +++ b/packages/intent/src/catalog.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { applyCatalogueLock } from './catalog-lock.js' import { getProjectReadFs } from './discovery/scanner.js' import { @@ -48,17 +47,20 @@ export async function getIntentCatalogContext({ export async function runSessionCatalogueHook({ agent, - event = readEventFromStdin(), + event, }: { agent: HookAgent event?: Record }): Promise { - const eventName = getLifecycleEventName(agent, event) + const resolvedEvent = event ?? (await readEventFromStdin()) + const eventName = getLifecycleEventName(agent, resolvedEvent) if (!eventName) return let context: string try { - const result = await getIntentCatalogContext({ cwd: getEventCwd(event) }) + const result = await getIntentCatalogContext({ + cwd: getEventCwd(resolvedEvent), + }) context = result.context } catch (error) { logHookFailure(error) @@ -81,15 +83,57 @@ function logHookFailure(error: unknown): void { ) } -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 readEventFromStdin(): Promise> { + if (process.stdin.readableEnded || process.stdin.isTTY === true) { + return Promise.resolve({}) } + + return new Promise((resolve) => { + const chunks: Array = [] + let byteLength = 0 + let settled = false + const timeout = setTimeout(() => finish(true), 1_000) + + const onData = (chunk: Buffer | string): void => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + byteLength += buffer.byteLength + if (byteLength > 64 * 1024) { + finish(false) + return + } + chunks.push(buffer) + } + const onEnd = (): void => finish(true) + const onError = (): void => finish(false) + + function finish(parse: boolean): void { + if (settled) return + settled = true + clearTimeout(timeout) + process.stdin.off('data', onData) + process.stdin.off('end', onEnd) + process.stdin.off('error', onError) + process.stdin.pause() + + if (parse) { + try { + const value = JSON.parse( + Buffer.concat(chunks, byteLength).toString('utf8'), + ) as unknown + if (value && typeof value === 'object' && !Array.isArray(value)) { + resolve(value as Record) + return + } + } catch {} + } + resolve({}) + } + + process.stdin.on('data', onData) + process.stdin.on('end', onEnd) + process.stdin.on('error', onError) + process.stdin.resume() + }) } function getLifecycleEventName( diff --git a/packages/intent/src/commands/hooks/command.ts b/packages/intent/src/commands/hooks/command.ts index aca1eee4..3f2e999b 100644 --- a/packages/intent/src/commands/hooks/command.ts +++ b/packages/intent/src/commands/hooks/command.ts @@ -1,7 +1,6 @@ import { formatHookInstallResult, runInstallHooks, - validateHookInstallOptions, } from '../../hooks/install.js' export interface HooksInstallCommandOptions { @@ -12,8 +11,6 @@ export interface HooksInstallCommandOptions { export function runHooksInstallCommand( options: HooksInstallCommandOptions, ): void { - validateHookInstallOptions(options) - const results = runInstallHooks({ agents: options.agents, root: process.cwd(), diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 0bda9042..919f9810 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' -import { dirname, relative } from 'node:path' +import { relative } from 'node:path' import { detectPackageManager } from '../discovery/package-manager.js' +import { writeTextFileAtomic } from '../shared/atomic-write.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' @@ -48,14 +49,6 @@ export function runInstallHooks({ ) } -export function validateHookInstallOptions({ - agents, - scope, -}: Pick): void { - parseScope(scope) - parseAgents(agents) -} - export function formatHookInstallResult(result: HookInstallResult): string { if (result.status === 'skipped') { return `Skipped Intent hooks for ${result.agent}: ${result.reason}` @@ -135,35 +128,14 @@ function upsertAdapterHooks({ }): Record { switch (configKind) { case 'claude-settings': - return upsertClaudeHooks(config, catalogCommand) case 'codex-hooks': - return upsertCodexHooks(config, catalogCommand) + return upsertCommandHooks(config, catalogCommand) case 'copilot-hooks': return upsertCopilotHooks(config, catalogCommand) } } -function upsertClaudeHooks( - config: Record, - catalogCommand: string, -): Record { - const hooks = objectValue(config.hooks) - hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { - matcher: 'startup|resume|clear|compact', - hooks: [ - { - type: 'command', - command: catalogCommand, - timeout: 10, - statusMessage: CATALOG_STATUS_MESSAGE, - }, - ], - }) - hooks.PreToolUse = removeIntentHooks(arrayValue(hooks.PreToolUse)) - return { ...config, hooks } -} - -function upsertCodexHooks( +function upsertCommandHooks( config: Record, catalogCommand: string, ): Record { @@ -254,8 +226,7 @@ function updateJsonConfig( return 'unchanged' } - mkdirSync(dirname(filePath), { recursive: true }) - writeFileSync(filePath, next) + writeTextFileAtomic(filePath, next) return existed ? 'updated' : 'created' } diff --git a/packages/intent/src/shared/atomic-write.ts b/packages/intent/src/shared/atomic-write.ts index da4bd292..362c7e5a 100644 --- a/packages/intent/src/shared/atomic-write.ts +++ b/packages/intent/src/shared/atomic-write.ts @@ -1,7 +1,10 @@ +import { randomUUID } from 'node:crypto' import { + chmodSync, existsSync, mkdirSync, renameSync, + statSync, unlinkSync, writeFileSync, } from 'node:fs' @@ -12,10 +15,16 @@ export function writeTextFileAtomic(path: string, content: string): void { mkdirSync(directory, { recursive: true }) const temporaryPath = join( directory, - `.${basename(path)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`, + `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`, ) + const mode = existsSync(path) ? statSync(path).mode & 0o777 : undefined try { - writeFileSync(temporaryPath, content, 'utf8') + writeFileSync(temporaryPath, content, { + encoding: 'utf8', + flag: 'wx', + ...(mode === undefined ? {} : { mode }), + }) + if (mode !== undefined) chmodSync(temporaryPath, mode) renameSync(temporaryPath, path) } finally { try { diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index ef07373d..6b105b71 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -134,7 +135,6 @@ describe('hook installer', () => { expect(sessionCommand).toBe( `npx @tanstack/intent@${intentPackagePin} hooks run --agent copilot`, ) - expect(sessionCommand).toContain('@tanstack/intent') expect(config.hooks.PreToolUse).toEqual([]) expect( existsSync( @@ -199,6 +199,17 @@ describe('hook installer', () => { expect(second[0]).toMatchObject({ status: 'unchanged' }) }) + it('preserves the mode of an existing hook config', () => { + const root = tempRoot('intent-hooks-mode-') + const settingsPath = join(root, '.claude', 'settings.json') + mkdirSync(dirname(settingsPath), { recursive: true }) + writeFileSync(settingsPath, '{}\n', { mode: 0o600 }) + + runInstallHooks({ agents: 'claude', root, scope: 'project' }) + + expect(statSync(settingsPath).mode & 0o777).toBe(0o600) + }) + it('preserves sibling hooks when replacing an Intent hook entry', () => { const root = tempRoot('intent-hooks-sibling-') const settingsPath = join(root, '.claude', 'settings.json') diff --git a/packages/intent/tests/integration/catalog-bundle.test.ts b/packages/intent/tests/integration/catalog-bundle.test.ts index a5a63de6..c83a8236 100644 --- a/packages/intent/tests/integration/catalog-bundle.test.ts +++ b/packages/intent/tests/integration/catalog-bundle.test.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process' import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -36,4 +37,58 @@ describe('catalog bundle', () => { } } }) + + it('reads a complete lifecycle event without waiting for stdin to close', async () => { + const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') + const child = spawn( + process.execPath, + [ + resolve(packageDir, 'dist/cli.mjs'), + 'hooks', + 'run', + '--agent', + 'copilot', + ], + { + cwd: packageDir, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + let stdout = '' + let stderr = '' + let stdinError: Error | undefined + child.stdin.on('error', (error) => { + stdinError = error + }) + child.stdout.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk + }) + child.stderr.setEncoding('utf8').on('data', (chunk) => { + stderr += chunk + }) + child.stdin.write(JSON.stringify({ source: 'startup', cwd: packageDir })) + + const exitCode = await new Promise((resolveExit, reject) => { + const timeout = setTimeout(() => { + child.kill() + reject(new Error('catalog hook did not exit with stdin held open')) + }, 4_000) + child.once('error', (error) => { + clearTimeout(timeout) + reject(error) + }) + child.once('exit', (code) => { + clearTimeout(timeout) + resolveExit(code) + }) + }) + + expect( + exitCode, + [stderr, stdinError?.message].filter(Boolean).join('\n'), + ).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + additionalContext: expect.any(String), + }) + }, 6_000) }) diff --git a/packages/intent/tests/lockfile.test.ts b/packages/intent/tests/lockfile.test.ts index f33a286c..5d371884 100644 --- a/packages/intent/tests/lockfile.test.ts +++ b/packages/intent/tests/lockfile.test.ts @@ -1,4 +1,11 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -130,4 +137,17 @@ describe('intent lockfile', () => { }) expect(readFileSync(path, 'utf8')).toContain('"id": "example"') }) + + it.each([ + { label: '0o600', mode: 0o600 }, + { label: '0o664', mode: 0o664 }, + ])('preserves mode $label of an existing lockfile', ({ mode }) => { + const path = join(root(), 'intent.lock') + writeFileSync(path, '{"lockfileVersion":1,"sources":[]}\n') + chmodSync(path, mode) + + writeIntentLockfile(path, { lockfileVersion: 1, sources: [] }) + + expect(statSync(path).mode & 0o777).toBe(mode) + }) }) From 6736aa36db48ae639df19a8aa5efbf0aeb8aaead Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Tue, 28 Jul 2026 21:05:12 -0700 Subject: [PATCH 49/56] refactor(intent): consolidate installer identity helpers --- benchmarks/intent/helpers.ts | 17 +++++ benchmarks/intent/install-plan.bench.ts | 19 +---- benchmarks/intent/sync.bench.ts | 19 +---- packages/intent/src/catalog-lock.ts | 20 +++-- packages/intent/src/commands/install/plan.ts | 35 +++++---- packages/intent/src/commands/sync/command.ts | 45 +++++++----- packages/intent/src/commands/sync/links.ts | 14 ++-- packages/intent/src/commands/sync/plan.ts | 14 +++- packages/intent/src/core/excludes.ts | 10 +-- packages/intent/src/core/intent-core.ts | 16 +--- packages/intent/src/core/lockfile/hash.ts | 11 +-- .../intent/src/core/lockfile/lockfile-diff.ts | 30 +++++--- .../src/core/lockfile/lockfile-state.ts | 11 ++- packages/intent/src/core/lockfile/lockfile.ts | 15 +++- packages/intent/src/core/project-context.ts | 10 +-- packages/intent/src/discovery/scanner.ts | 10 +-- .../intent/src/setup/workspace-patterns.ts | 12 +-- packages/intent/src/shared/utils.ts | 12 ++- packages/intent/tests/cli.test.ts | 73 +++++-------------- packages/intent/tests/hooks-install.test.ts | 12 --- packages/intent/tests/local-path.test.ts | 26 +++++++ 21 files changed, 200 insertions(+), 231 deletions(-) diff --git a/benchmarks/intent/helpers.ts b/benchmarks/intent/helpers.ts index fb9bd58f..5aee3552 100644 --- a/benchmarks/intent/helpers.ts +++ b/benchmarks/intent/helpers.ts @@ -1,6 +1,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import type { IntentPackage } from '../../packages/intent/src/shared/types.js' let builtCliMainPromise: Promise< (argv?: Array) => Promise @@ -36,6 +37,22 @@ export type PackageOptions = { brokenIntent?: boolean } +export function createRepresentativeIntentPackages(): Array { + return 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 noop = () => undefined export function createBenchOptions( diff --git a/benchmarks/intent/install-plan.bench.ts b/benchmarks/intent/install-plan.bench.ts index b8615df5..234df780 100644 --- a/benchmarks/intent/install-plan.bench.ts +++ b/benchmarks/intent/install-plan.bench.ts @@ -1,24 +1,9 @@ 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' +import { createRepresentativeIntentPackages } from './helpers.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 packages = createRepresentativeIntentPackages() const packageJson = `${JSON.stringify( { diff --git a/benchmarks/intent/sync.bench.ts b/benchmarks/intent/sync.bench.ts index 9245aa1f..e7e25b4c 100644 --- a/benchmarks/intent/sync.bench.ts +++ b/benchmarks/intent/sync.bench.ts @@ -1,26 +1,11 @@ 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 { createRepresentativeIntentPackages } from './helpers.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 packages = createRepresentativeIntentPackages() const sources: Array = packages.map((pkg) => ({ kind: pkg.kind, diff --git a/packages/intent/src/catalog-lock.ts b/packages/intent/src/catalog-lock.ts index 08db9c27..92885a06 100644 --- a/packages/intent/src/catalog-lock.ts +++ b/packages/intent/src/catalog-lock.ts @@ -5,7 +5,11 @@ import { scanIntentPackageAtRoot, } from './discovery/scanner.js' import { buildCurrentLockfileSources } from './core/lockfile/lockfile-state.js' -import { readIntentLockfile } from './core/lockfile/lockfile.js' +import { + classifyLockfileHash, + readIntentLockfile, +} from './core/lockfile/lockfile.js' +import { sourceIdentityKey } from './core/types.js' import { formatSkillUse } from './skills/use.js' import type { CatalogueVerificationEntry } from './session-catalog.js' import type { IntentSkillList } from './core/index.js' @@ -43,17 +47,23 @@ export function applyCatalogueLock( fsCache.getReadFs(), )[0] if (!currentSource) continue + const currentSourceKey = sourceIdentityKey(currentSource) const lockedSource = locked.lockfile.sources.find( - (source) => - source.kind === currentSource.kind && source.id === currentSource.id, + (source) => sourceIdentityKey(source) === currentSourceKey, ) if (!lockedSource) continue const lockedSkills = new Map( - lockedSource.skills.map((skill) => [skill.path, skill.contentHash]), + lockedSource.skills.map((skill) => [skill.path, skill]), ) for (const skill of currentSource.skills) { - if (lockedSkills.get(skill.path) !== skill.contentHash) continue + if ( + classifyLockfileHash( + skill.contentHash, + lockedSkills.get(skill.path)?.contentHash, + ) !== 'accepted' + ) + continue const skillName = skill.path.slice('skills/'.length) allowedUses.add(formatSkillUse(currentSource.id, skillName)) verification.push({ diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index 841f1dc4..3999c013 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -5,6 +5,8 @@ import { } from '../../core/excludes.js' import { parseSkillSources } from '../../core/skill-sources.js' import { compileSkillSourcePolicy } from '../../core/source-policy.js' +import { sourceIdentityKey } from '../../core/types.js' +import { classifyLockfileHash } from '../../core/lockfile/lockfile.js' import type { IntentLockfileSource, ReadIntentLockfileResult, @@ -146,7 +148,7 @@ function sortedSkills(pkg: IntentPackage): Array { function assertUniqueDiscovery(packages: ReadonlyArray): void { const sources = new Set() for (const pkg of packages) { - const source = `${pkg.kind}\0${pkg.name}` + const source = sourceIdentityKey({ kind: pkg.kind, id: pkg.name }) if (sources.has(source)) { throw new Error(`Duplicate discovered source "${sourceEntry(pkg)}".`) } @@ -327,10 +329,6 @@ export function buildSkillSelectionPlan( } } -function sourceKey(source: Pick): string { - return `${source.kind}\0${source.id}` -} - function currentSkill( skill: SkillEntry, current: IntentLockfileSource | undefined, @@ -349,16 +347,19 @@ export function buildInstallDeltaInventory( const sourcePolicy = compileSkillSourcePolicy(sources) const excludes = compileExcludePatterns(config.exclude) const currentByKey = new Map( - currentSources.map((source) => [sourceKey(source), source]), + currentSources.map((source) => [sourceIdentityKey(source), source]), ) const lockedByKey = new Map( lockResult.status === 'found' - ? lockResult.lockfile.sources.map((source) => [sourceKey(source), source]) + ? lockResult.lockfile.sources.map((source) => [ + sourceIdentityKey(source), + source, + ]) : [], ) const seen = new Set() const packages = sortedPackages(discovered).map((pkg) => { - const key = sourceKey({ kind: pkg.kind, id: pkg.name }) + const key = sourceIdentityKey({ kind: pkg.kind, id: pkg.name }) seen.add(key) const current = currentByKey.get(key) const locked = lockedByKey.get(key) @@ -381,12 +382,10 @@ export function buildInstallDeltaInventory( 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' + const lock: InventoryLockStatus = classifyLockfileHash( + currentEntry?.contentHash, + lockedEntry?.contentHash, + ) return { id: skillSelectionId(pkg, skill), policy, @@ -402,8 +401,8 @@ export function buildInstallDeltaInventory( }> = [] if (lockResult.status === 'found') { for (const source of lockResult.lockfile.sources) { - const current = currentByKey.get(sourceKey(source)) - if (!seen.has(sourceKey(source)) || !current) { + const current = currentByKey.get(sourceIdentityKey(source)) + if (!seen.has(sourceIdentityKey(source)) || !current) { removed.push({ kind: source.kind, id: source.id, path: null }) continue } @@ -418,8 +417,8 @@ export function buildInstallDeltaInventory( packages, removed: removed.sort((left, right) => { const bySource = compareStrings( - `${left.kind}\0${left.id}`, - `${right.kind}\0${right.id}`, + sourceIdentityKey(left), + sourceIdentityKey(right), ) return bySource === 0 ? compareStrings(left.path ?? '', right.path ?? '') diff --git a/packages/intent/src/commands/sync/command.ts b/packages/intent/src/commands/sync/command.ts index e1fc34ba..aa09d811 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -4,6 +4,7 @@ import { fail } from '../../shared/cli-error.js' import { compileExcludePatterns } from '../../core/excludes.js' import { createIntentFsCache } from '../../discovery/fs-cache.js' import { buildCurrentLockfileSources } from '../../core/lockfile/lockfile-state.js' +import { sourceIdentityKey } from '../../core/types.js' import { readIntentLockfile, writeIntentLockfile, @@ -22,6 +23,7 @@ import { } from '../install/config.js' import { buildSkillSelectionPlan, + skillSelectionId, summarizeInstallDeltaInventory, } from '../install/plan.js' import { updateIntentGitignore } from './gitignore.js' @@ -173,10 +175,6 @@ 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 } @@ -260,7 +258,7 @@ async function reviewNewDependencies({ const pendingSelectionPlan = buildSkillSelectionPlan(packages, selection) const pendingSkillIds = new Set( packages.flatMap((pkg) => - pkg.skills.map((skill) => `${sourceName(pkg)}#${skill.name}`), + pkg.skills.map((skill) => skillSelectionId(pkg, skill)), ), ) const selectionPlan = buildSkillSelectionPlan(reviewedPackages, { @@ -270,7 +268,7 @@ async function reviewNewDependencies({ ...selection.enabled, ...reviewedPackages.flatMap((pkg) => pkg.skills - .map((skill) => `${sourceName(pkg)}#${skill.name}`) + .map((skill) => skillSelectionId(pkg, skill)) .filter((id) => !pendingSkillIds.has(id)), ), ]), @@ -324,16 +322,21 @@ async function reviewNewDependencies({ const selectedPathsBySource = new Map( packages.flatMap((pkg) => { const paths = pkg.skills - .filter((skill) => - selectedPendingIds.has(`${sourceName(pkg)}#${skill.name}`), - ) + .filter((skill) => selectedPendingIds.has(skillSelectionId(pkg, skill))) .map((skill) => `skills/${skill.name}`) - return paths.length > 0 ? [[sourceKey(pkg), new Set(paths)] as const] : [] + return paths.length > 0 + ? [ + [ + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + new Set(paths), + ] as const, + ] + : [] }), ) const selectedCurrentSources = new Map( currentSources.flatMap((source) => { - const key = `${source.kind}\0${source.id}` + const key = sourceIdentityKey(source) const selectedPaths = selectedPathsBySource.get(key) if (!selectedPaths) return [] return [ @@ -350,14 +353,14 @@ async function reviewNewDependencies({ }), ) const lockedKeys = new Set( - lock.lockfile.sources.map((source) => `${source.kind}\0${source.id}`), + lock.lockfile.sources.map((source) => sourceIdentityKey(source)), ) const prospectiveLock = { lockfileVersion: 1 as const, sources: [ ...lock.lockfile.sources.map((source) => { const selectedSource = selectedCurrentSources.get( - `${source.kind}\0${source.id}`, + sourceIdentityKey(source), ) if (!selectedSource) return source const selectedPaths = new Set( @@ -567,7 +570,7 @@ export async function runSyncCommand( if (interactiveReview) { const pendingSkills = new Map( inventory.packages.map((pkg) => [ - `${pkg.kind}\0${pkg.name}`, + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), new Set( pkg.skills .filter((skill) => skill.policy === 'pending') @@ -576,7 +579,9 @@ export async function runSyncCommand( ]), ) const packages = discovered.flatMap((pkg) => { - const skills = pendingSkills.get(sourceKey(pkg)) + const skills = pendingSkills.get( + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + ) if (!skills || skills.size === 0) return [] return [ { @@ -585,15 +590,19 @@ export async function runSyncCommand( }, ] }) - const reviewedKeys = new Set(packages.map(sourceKey)) + const reviewedKeys = new Set( + packages.map((pkg) => + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + ), + ) const enabledSkills = new Map( policy.packages.map((pkg) => [ - sourceKey(pkg), + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), new Set(pkg.skills.map((skill) => skill.name)), ]), ) const reviewedPackages = discovered.flatMap((pkg) => { - const key = sourceKey(pkg) + const key = sourceIdentityKey({ kind: pkg.kind, id: pkg.name }) if (!reviewedKeys.has(key)) return [] const pending = pendingSkills.get(key) const enabled = enabledSkills.get(key) diff --git a/packages/intent/src/commands/sync/links.ts b/packages/intent/src/commands/sync/links.ts index 38f7ec44..7a0e13ff 100644 --- a/packages/intent/src/commands/sync/links.ts +++ b/packages/intent/src/commands/sync/links.ts @@ -8,7 +8,8 @@ import { symlinkSync, unlinkSync, } from 'node:fs' -import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { dirname, relative, resolve } from 'node:path' +import { isPathWithin } from '../../shared/utils.js' import type { InstallStateEntry, ReadInstallStateResult } from './state.js' import type { ReadFs } from '../../shared/utils.js' @@ -68,16 +69,11 @@ function isLink(path: string): boolean { } } -function isInside(path: string, parent: string): boolean { - const value = relative(parent, path) - return value === '' || (!value.startsWith('..') && !isAbsolute(value)) -} - function hasContainedParent(path: string, root: string): boolean { try { const resolvedRoot = resolve(root) const resolvedParent = resolve(dirname(path)) - if (!isInside(resolvedParent, resolvedRoot)) return false + if (!isPathWithin(resolvedRoot, resolvedParent)) return false const realRoot = realpathSync(resolvedRoot) let existingParent = resolvedParent for (;;) { @@ -91,7 +87,7 @@ function hasContainedParent(path: string, root: string): boolean { if (parent === existingParent) return false existingParent = parent } - return isInside(realpathSync(existingParent), realRoot) + return isPathWithin(realRoot, realpathSync(existingParent)) } catch { return false } @@ -101,7 +97,7 @@ function sourceTarget(expected: ExpectedLink): string | null { try { const packageRoot = realpathSync(expected.packageRoot) const sourceDirectory = realpathSync(expected.sourceDirectory) - return isInside(sourceDirectory, packageRoot) ? sourceDirectory : null + return isPathWithin(packageRoot, sourceDirectory) ? sourceDirectory : null } catch { return null } diff --git a/packages/intent/src/commands/sync/plan.ts b/packages/intent/src/commands/sync/plan.ts index 967c9425..3bbc7d6e 100644 --- a/packages/intent/src/commands/sync/plan.ts +++ b/packages/intent/src/commands/sync/plan.ts @@ -1,4 +1,5 @@ import { dirname, join, resolve } from 'node:path' +import { sourceIdentityKey } from '../../core/types.js' import { buildInstallDeltaInventory } from '../install/plan.js' import { createSyncAliases, @@ -54,15 +55,20 @@ export function buildSyncLinkPlan({ })), ), ).map((entry) => [ - `${entry.kind}\0${entry.id}\0${entry.skill}`, + `${sourceIdentityKey(entry)}\0${entry.skill}`, entry.alias, ]), ) const sources = new Map( - discovered.map((pkg) => [`${pkg.kind}\0${pkg.name}`, pkg]), + discovered.map((pkg) => [ + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + pkg, + ]), ) const accepted = inventory.packages.flatMap((pkg) => { - const source = sources.get(`${pkg.kind}\0${pkg.name}`) + const source = sources.get( + sourceIdentityKey({ kind: pkg.kind, id: pkg.name }), + ) if (!source) return [] return pkg.skills.flatMap((skill) => { if (skill.policy !== 'enabled' || skill.lock !== 'accepted') return [] @@ -78,7 +84,7 @@ export function buildSyncLinkPlan({ config.install.targets, ) const expected = accepted.flatMap(({ pkg, skill, source }) => { - const identity = `${pkg.kind}\0${pkg.name}\0${skill.name}` + const identity = `${sourceIdentityKey({ kind: pkg.kind, id: pkg.name })}\0${skill.name}` const alias = aliases.get(identity) if (!alias) throw new Error(`Missing sync alias for ${identity}.`) return targetDirectories.map((target) => ({ diff --git a/packages/intent/src/core/excludes.ts b/packages/intent/src/core/excludes.ts index 74856276..841668e3 100644 --- a/packages/intent/src/core/excludes.ts +++ b/packages/intent/src/core/excludes.ts @@ -1,4 +1,5 @@ -import { dirname, isAbsolute, relative, resolve } from 'node:path' +import { dirname, resolve } from 'node:path' +import { isPathWithin } from '../shared/utils.js' import { resolveProjectContext } from './project-context.js' import { readPackageJson } from './package-json.js' import type { ProjectContext } from './project-context.js' @@ -22,11 +23,6 @@ function normalizeExcludePatterns(value: unknown): Array { .filter(Boolean) } -function isWithinOrEqual(path: string, parentDir: string): boolean { - const rel = relative(parentDir, path) - return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) -} - function readPackageExcludes(dir: string): Array { const pkg = readPackageJson(dir) const intent = pkg?.intent @@ -43,7 +39,7 @@ export function getConfigDirs( const dirs: Array = [] let dir = cwd - while (isWithinOrEqual(dir, root)) { + while (isPathWithin(root, dir)) { dirs.push(dir) if (dir === root) break diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index 6b54a39d..c4f8d967 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -1,5 +1,6 @@ -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { createIntentFsCache } from '../discovery/fs-cache.js' +import { isPathWithin } from '../shared/utils.js' import { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' import { formatSkillUse, parseSkillUse } from '../skills/use.js' import { @@ -210,17 +211,6 @@ function resolveFromCwd(cwd: string, path: string): string { return resolve(cwd, path) } -function isResolvedPathInsidePackageRoot( - path: string, - packageRoot: string, -): boolean { - const relativePath = relative(packageRoot, path) - return ( - relativePath === '' || - (!relativePath.startsWith('..') && !isAbsolute(relativePath)) - ) -} - function toResolvedIntentSkill( cwd: string, use: string, @@ -250,7 +240,7 @@ function toResolvedIntentSkill( resolveFromCwd(cwd, resolved.packageRoot), ) - if (!isResolvedPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { + if (!isPathWithin(realPackageRoot, realResolvedPath)) { throw new IntentCoreError( 'skill-path-outside-package', `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index 363e406a..e37009a9 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -1,7 +1,7 @@ import { isUtf8 } from 'node:buffer' import { createHash } from 'node:crypto' -import { isAbsolute, join, relative, resolve } from 'node:path' -import { nodeReadFs } from '../../shared/utils.js' +import { isAbsolute, join, resolve } from 'node:path' +import { isPathWithin, nodeReadFs } from '../../shared/utils.js' import type { Dirent } from 'node:fs' import type { ReadFs } from '../../shared/utils.js' @@ -29,11 +29,6 @@ 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') @@ -53,7 +48,7 @@ function resolveInPackage( `Failed to resolve ${label}: ${error instanceof Error ? error.message : String(error)}`, ) } - if (!isWithin(packageRoot, resolved)) { + if (!isPathWithin(packageRoot, resolved)) { throw new Error(`${label} escapes package root through a symlink.`) } return resolved diff --git a/packages/intent/src/core/lockfile/lockfile-diff.ts b/packages/intent/src/core/lockfile/lockfile-diff.ts index 48252904..2d8ebef3 100644 --- a/packages/intent/src/core/lockfile/lockfile-diff.ts +++ b/packages/intent/src/core/lockfile/lockfile-diff.ts @@ -1,4 +1,5 @@ -import { canonicalIntentLockfile } from './lockfile.js' +import { sourceIdentityKey } from '../types.js' +import { canonicalIntentLockfile, classifyLockfileHash } from './lockfile.js' import type { IntentLockfileSkill, IntentLockfileSource, @@ -31,10 +32,6 @@ 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, @@ -53,20 +50,23 @@ export function diffLockfileSources( sources: [...currentSources], }).sources const currentByKey = new Map( - current.map((source) => [sourceKey(source), source]), + current.map((source) => [sourceIdentityKey(source), source]), ) const lockedByKey = new Map( - locked.lockfile.sources.map((source) => [sourceKey(source), source]), + locked.lockfile.sources.map((source) => [ + sourceIdentityKey(source), + source, + ]), ) const addedSources = current.filter( - (source) => !lockedByKey.has(sourceKey(source)), + (source) => !lockedByKey.has(sourceIdentityKey(source)), ) const removedSources = locked.lockfile.sources.filter( - (source) => !currentByKey.has(sourceKey(source)), + (source) => !currentByKey.has(sourceIdentityKey(source)), ) const changedSources: Array = [] for (const lockedSource of locked.lockfile.sources) { - const currentSource = currentByKey.get(sourceKey(lockedSource)) + const currentSource = currentByKey.get(sourceIdentityKey(lockedSource)) if (!currentSource) continue const currentSkills = new Map( currentSource.skills.map((skill) => [skill.path, skill]), @@ -82,7 +82,11 @@ export function diffLockfileSources( ) const changedSkills = lockedSource.skills.flatMap((skill) => { const currentSkill = currentSkills.get(skill.path) - if (!currentSkill || currentSkill.contentHash === skill.contentHash) { + if ( + !currentSkill || + classifyLockfileHash(currentSkill.contentHash, skill.contentHash) !== + 'changed' + ) { return [] } return [ @@ -103,7 +107,9 @@ export function diffLockfileSources( }) } } - changedSources.sort((a, b) => compareStrings(sourceKey(a), sourceKey(b))) + changedSources.sort((a, b) => + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), + ) return { lockfile: 'found', addedSources, diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts index 59d2a6d0..f63d7d43 100644 --- a/packages/intent/src/core/lockfile/lockfile-state.ts +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -1,6 +1,7 @@ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import { nodeReadFs, toPosixPath } from '../../shared/utils.js' import { validateSkillPath } from '../skill-path.js' +import { sourceIdentityKey } from '../types.js' import { computeSkillContentHash } from './hash.js' import type { IntentLockfileSource } from './lockfile.js' import type { IntentPackage } from '../../shared/types.js' @@ -10,10 +11,6 @@ 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, @@ -86,7 +83,7 @@ export function buildCurrentLockfileSources( })) const identities = new Set() for (const source of sources) { - const identity = sourceKey(source) + const identity = sourceIdentityKey(source) if (identities.has(identity)) throw new Error( `Duplicate skill source identity: ${source.kind}:${source.id}.`, @@ -98,5 +95,7 @@ export function buildCurrentLockfileSources( `Duplicate skill path for source: ${source.kind}:${source.id}.`, ) } - return sources.sort((a, b) => compareStrings(sourceKey(a), sourceKey(b))) + return sources.sort((a, b) => + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), + ) } diff --git a/packages/intent/src/core/lockfile/lockfile.ts b/packages/intent/src/core/lockfile/lockfile.ts index 04b166a8..e05dabeb 100644 --- a/packages/intent/src/core/lockfile/lockfile.ts +++ b/packages/intent/src/core/lockfile/lockfile.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { writeTextFileAtomic } from '../../shared/atomic-write.js' import { validateSkillPaths } from '../skill-path.js' +import { sourceIdentityKey } from '../types.js' export interface IntentLockfileSkill { path: string @@ -26,8 +27,12 @@ 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 classifyLockfileHash( + currentHash: string | undefined, + lockedHash: string | undefined, +): 'new' | 'accepted' | 'changed' { + if (currentHash === undefined || lockedHash === undefined) return 'new' + return currentHash === lockedHash ? 'accepted' : 'changed' } function assertRecord(value: unknown, label: string): Record { @@ -79,7 +84,7 @@ export function canonicalIntentLockfile( const sources = lockfile.sources.map(canonicalSource) const seen = new Set() for (const source of sources) { - const key = sourceKey(source) + const key = sourceIdentityKey(source) if (seen.has(key)) { throw new Error( `Duplicate intent.lock source: ${source.kind}:${source.id}.`, @@ -89,7 +94,9 @@ export function canonicalIntentLockfile( } return { lockfileVersion: 1, - sources: sources.sort((a, b) => compareStrings(sourceKey(a), sourceKey(b))), + sources: sources.sort((a, b) => + compareStrings(sourceIdentityKey(a), sourceIdentityKey(b)), + ), } } diff --git a/packages/intent/src/core/project-context.ts b/packages/intent/src/core/project-context.ts index 2549587f..4bd653cb 100644 --- a/packages/intent/src/core/project-context.ts +++ b/packages/intent/src/core/project-context.ts @@ -1,5 +1,6 @@ import { existsSync, statSync } from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' +import { isPathWithin } from '../shared/utils.js' import { findWorkspaceRoot, readWorkspacePatterns, @@ -86,7 +87,7 @@ function resolveTargetSkillsDir( const packageSkillsDir = join(packageRoot, 'skills') - if (isWithinOrEqual(targetPath, packageSkillsDir)) { + if (isPathWithin(packageSkillsDir, targetPath)) { return packageSkillsDir } @@ -96,8 +97,3 @@ function resolveTargetSkillsDir( return null } - -function isWithinOrEqual(path: string, parentDir: string): boolean { - const rel = relative(parentDir, path) - return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/')) -} diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 764c4221..781c3a9c 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -4,9 +4,10 @@ // 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 { dirname, join, relative, resolve, sep } from 'node:path' import { detectGlobalNodeModules, + isPathWithin, nodeReadFs, parseFrontmatter, readScalarField, @@ -341,11 +342,6 @@ function getPackageShortName(packageName: string): string { return packageName.split('/').pop() ?? packageName } -function isWithinOrEqual(path: string, parentDir: string): boolean { - const rel = relative(parentDir, path) - return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) -} - function resolveSkillNameHintPath( skillsDir: string, hint: string, @@ -364,7 +360,7 @@ function resolveSkillNameHintPath( const resolvedSkillsDir = resolve(skillsDir) const childDir = resolve(resolvedSkillsDir, ...parts) - if (!isWithinOrEqual(childDir, resolvedSkillsDir)) return null + if (!isPathWithin(resolvedSkillsDir, childDir)) return null return { childDir, diff --git a/packages/intent/src/setup/workspace-patterns.ts b/packages/intent/src/setup/workspace-patterns.ts index fd14b20c..995b34ab 100644 --- a/packages/intent/src/setup/workspace-patterns.ts +++ b/packages/intent/src/setup/workspace-patterns.ts @@ -1,18 +1,8 @@ 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 { hasAnySkillFile } from '../shared/utils.js' +import { getParseYaml, 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(/\/+$/, '') diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index ddbf71ed..78509163 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -11,14 +11,14 @@ import { realpathSync, } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join, resolve, sep } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' 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 { +export function getParseYaml(): typeof ParseYaml { parseYaml ??= (requireFromHere('yaml') as { parse: typeof ParseYaml }).parse return parseYaml } @@ -66,6 +66,14 @@ export function toPosixPath(p: string): string { return p.split(sep).join('/') } +export function isPathWithin(parent: string, candidate: string): boolean { + const rel = relative(parent, candidate) + return ( + rel === '' || + (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)) + ) +} + export function createFsIdentityCache( getFs: () => ReadFs = () => nodeReadFs, ): (path: string) => string { diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 30ffdbd0..758b172b 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -14,7 +14,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' 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 { writeIntentLockfile } 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' @@ -33,6 +33,16 @@ function writeJson(filePath: string, data: unknown): void { writeFileSync(filePath, JSON.stringify(data, null, 2)) } +function writeIntentLock( + root: string, + packages: Parameters[0] = [], +): void { + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: buildCurrentLockfileSources(packages), + }) +} + function writeSkillMd(dir: string, frontmatter: Record): void { mkdirSync(dir, { recursive: true }) const yamlLines = Object.entries(frontmatter) @@ -314,13 +324,7 @@ describe('cli commands', () => { }) process.chdir(root) const discovered = scanForIntents(root, { scope: 'local' }).packages - writeFileSync( - join(root, 'intent.lock'), - serializeIntentLockfile({ - lockfileVersion: 1, - sources: buildCurrentLockfileSources(discovered), - }), - ) + writeIntentLock(root, discovered) expect(await main(['sync'])).toBe(0) const linkPath = join(root, '.github', 'skills', 'npm-verified-core') @@ -416,13 +420,7 @@ describe('cli commands', () => { description: 'Dry skill', }) const dryDiscovered = scanForIntents(dryRoot, { scope: 'local' }).packages - writeFileSync( - join(dryRoot, 'intent.lock'), - serializeIntentLockfile({ - lockfileVersion: 1, - sources: buildCurrentLockfileSources(dryDiscovered), - }), - ) + writeIntentLock(dryRoot, dryDiscovered) process.chdir(dryRoot) expect(await main(['sync', '--dry-run', '--json'])).toBe(0) expect( @@ -766,13 +764,7 @@ describe('cli commands', () => { description: 'Verified skill', }) const discovered = scanForIntents(root, { scope: 'local' }).packages - writeFileSync( - join(root, 'intent.lock'), - serializeIntentLockfile({ - lockfileVersion: 1, - sources: buildCurrentLockfileSources(discovered), - }), - ) + writeIntentLock(root, discovered) const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') writeFileSync( @@ -823,13 +815,7 @@ describe('cli commands', () => { description: 'Verified skill', }) const discovered = scanForIntents(root, { scope: 'local' }).packages - writeFileSync( - join(root, 'intent.lock'), - serializeIntentLockfile({ - lockfileVersion: 1, - sources: buildCurrentLockfileSources(discovered), - }), - ) + writeIntentLock(root, discovered) const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') writeFileSync( @@ -870,13 +856,7 @@ describe('cli commands', () => { description: 'Verified skill', }) const discovered = scanForIntents(root, { scope: 'local' }).packages - writeFileSync( - join(root, 'intent.lock'), - serializeIntentLockfile({ - lockfileVersion: 1, - sources: buildCurrentLockfileSources(discovered), - }), - ) + writeIntentLock(root, discovered) writeInstalledIntentPackage(root, { name: 'pending', version: '1.0.0', @@ -1068,13 +1048,7 @@ describe('cli commands', () => { description: 'Verified skill', }) const discovered = scanForIntents(root, { scope: 'local' }).packages - writeFileSync( - join(root, 'intent.lock'), - serializeIntentLockfile({ - lockfileVersion: 1, - sources: buildCurrentLockfileSources(discovered), - }), - ) + writeIntentLock(root, discovered) const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') process.chdir(root) @@ -1173,13 +1147,7 @@ describe('cli commands', () => { description: 'Verified skill', }) const discovered = scanForIntents(root, { scope: 'local' }).packages - writeFileSync( - join(root, 'intent.lock'), - serializeIntentLockfile({ - lockfileVersion: 1, - sources: buildCurrentLockfileSources(discovered), - }), - ) + writeIntentLock(root, discovered) process.chdir(root) const exitCode = await main(['install', '--no-input']) @@ -1205,10 +1173,7 @@ describe('cli commands', () => { install: { method: 'symlink', targets: ['agents'] }, }, }) - writeFileSync( - join(root, 'intent.lock'), - serializeIntentLockfile({ lockfileVersion: 1, sources: [] }), - ) + writeIntentLock(root) const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') process.chdir(root) diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 6b105b71..fc1d3a30 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -4,7 +4,6 @@ import { mkdtempSync, readFileSync, rmSync, - statSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -199,17 +198,6 @@ describe('hook installer', () => { expect(second[0]).toMatchObject({ status: 'unchanged' }) }) - it('preserves the mode of an existing hook config', () => { - const root = tempRoot('intent-hooks-mode-') - const settingsPath = join(root, '.claude', 'settings.json') - mkdirSync(dirname(settingsPath), { recursive: true }) - writeFileSync(settingsPath, '{}\n', { mode: 0o600 }) - - runInstallHooks({ agents: 'claude', root, scope: 'project' }) - - expect(statSync(settingsPath).mode & 0o777).toBe(0o600) - }) - it('preserves sibling hooks when replacing an Intent hook entry', () => { const root = tempRoot('intent-hooks-sibling-') const settingsPath = join(root, '.claude', 'settings.json') diff --git a/packages/intent/tests/local-path.test.ts b/packages/intent/tests/local-path.test.ts index 461e254e..6076b4af 100644 --- a/packages/intent/tests/local-path.test.ts +++ b/packages/intent/tests/local-path.test.ts @@ -1,5 +1,31 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { containsLocalPath } from '../src/shared/local-path.js' +import { isPathWithin } from '../src/shared/utils.js' + +describe('isPathWithin', () => { + const parent = join(tmpdir(), 'intent-path-parent') + const child = join(parent, 'child') + + it('recognizes the same path and child paths', () => { + expect(isPathWithin(parent, parent)).toBe(true) + expect(isPathWithin(parent, child)).toBe(true) + expect(isPathWithin(parent, join(parent, '..foo'))).toBe(true) + }) + + it('rejects parent and sibling paths', () => { + expect(isPathWithin(child, parent)).toBe(false) + expect(isPathWithin(parent, join(tmpdir(), 'intent-path-sibling'))).toBe( + false, + ) + }) + + it('is directional', () => { + expect(isPathWithin(parent, child)).toBe(true) + expect(isPathWithin(child, parent)).toBe(false) + }) +}) describe('containsLocalPath', () => { it.each([ From bbc208feaae2bcfb8e2939298c1b2eb6aef68df5 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 29 Jul 2026 07:59:58 -0700 Subject: [PATCH 50/56] fix(intent): cache verified catalogue state per skill --- packages/intent/src/catalog-lock.ts | 71 +++++++++------- packages/intent/src/commands/exclude.ts | 11 +-- .../intent/src/commands/install/config.ts | 10 --- packages/intent/src/core/lockfile/hash.ts | 27 +++--- .../src/core/lockfile/lockfile-state.ts | 30 ++++--- packages/intent/tests/catalog-api.test.ts | 80 +++++++++++++++--- packages/intent/tests/cli.test.ts | 83 +++++-------------- packages/intent/tests/hash.test.ts | 21 +++++ packages/intent/tests/install-config.test.ts | 15 ---- 9 files changed, 190 insertions(+), 158 deletions(-) diff --git a/packages/intent/src/catalog-lock.ts b/packages/intent/src/catalog-lock.ts index 92885a06..a1467728 100644 --- a/packages/intent/src/catalog-lock.ts +++ b/packages/intent/src/catalog-lock.ts @@ -4,7 +4,7 @@ import { getProjectReadFs, scanIntentPackageAtRoot, } from './discovery/scanner.js' -import { buildCurrentLockfileSources } from './core/lockfile/lockfile-state.js' +import { buildCurrentLockfileSkill } from './core/lockfile/lockfile-state.js' import { classifyLockfileHash, readIntentLockfile, @@ -20,6 +20,10 @@ export interface LockCheckedCatalogueDiscovery { verification: Array | null } +function formatWithheldWarning(count: number, reason: string): string { + return `${count} ${count === 1 ? 'skill was' : 'skills were'} withheld because ${reason}.` +} + export function applyCatalogueLock( result: IntentSkillList, workspaceRoot: string, @@ -32,6 +36,9 @@ export function applyCatalogueLock( fsCache.useFs(readFs) const allowedUses = new Set() const verification: Array = [] + let pendingCount = 0 + let changedCount = 0 + let unverifiableCount = 0 for (const summary of result.packages) { const scanned = scanIntentPackageAtRoot(summary.packageRoot, { @@ -42,35 +49,43 @@ export function applyCatalogueLock( }).package if (!scanned) continue - const currentSource = buildCurrentLockfileSources( - [scanned], - fsCache.getReadFs(), - )[0] - if (!currentSource) continue - const currentSourceKey = sourceIdentityKey(currentSource) + const currentSourceKey = sourceIdentityKey({ + kind: scanned.kind, + id: scanned.name, + }) const lockedSource = locked.lockfile.sources.find( (source) => sourceIdentityKey(source) === currentSourceKey, ) - if (!lockedSource) continue - const lockedSkills = new Map( - lockedSource.skills.map((skill) => [skill.path, skill]), + lockedSource?.skills.map((skill) => [skill.path, skill]) ?? [], ) - for (const skill of currentSource.skills) { - if ( - classifyLockfileHash( - skill.contentHash, - lockedSkills.get(skill.path)?.contentHash, - ) !== 'accepted' - ) + for (const scannedSkill of scanned.skills) { + let skill: ReturnType + try { + skill = buildCurrentLockfileSkill( + scanned, + scannedSkill, + fsCache.getReadFs(), + ) + } catch { + unverifiableCount += 1 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 status = classifyLockfileHash( + skill.contentHash, + lockedSkills.get(skill.path)?.contentHash, + ) + if (status === 'accepted') { + allowedUses.add(formatSkillUse(scanned.name, scannedSkill.name)) + } else { + if (status === 'new') pendingCount += 1 + else changedCount += 1 + } } } @@ -86,14 +101,14 @@ export function applyCatalogueLock( 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 + const warnings = [...result.warnings] + for (const [count, reason] of [ + [pendingCount, 'no matching intent.lock entry exists'], + [changedCount, 'installed content does not match intent.lock'], + [unverifiableCount, 'installed content could not be verified'], + ] as const) { + if (count > 0) warnings.push(formatWithheldWarning(count, reason)) + } return { result: { @@ -112,6 +127,6 @@ export function applyCatalogueLock( } : {}), }, - verification: withheldCount > 0 ? null : verification, + verification: unverifiableCount > 0 ? null : verification, } } diff --git a/packages/intent/src/commands/exclude.ts b/packages/intent/src/commands/exclude.ts index 5af3cab8..d41d5b25 100644 --- a/packages/intent/src/commands/exclude.ts +++ b/packages/intent/src/commands/exclude.ts @@ -5,7 +5,6 @@ import { compileExcludePatterns } from '../core/excludes.js' import { writeTextFileAtomic } from '../shared/atomic-write.js' import { readIntentConsumerConfig, - readIntentExcludeList, updateIntentConsumerConfigText, } from './install/config.js' import type { IntentConsumerConfig } from './install/config.js' @@ -110,22 +109,16 @@ export function runExcludeCommand( const action = normalizeAction(actionArg) const cwd = process.cwd() const text = readPackageJsonText(cwd) + const config = readConfig(text) if (action === 'list') { if (patternArg) { fail('Unexpected pattern for list. Use: intent exclude list [--json]') } - try { - printExcludes(readIntentExcludeList(text), options.json) - } catch (err) { - fail( - `Invalid package.json intent configuration: ${err instanceof Error ? err.message : String(err)}`, - ) - } + printExcludes(config.exclude, options.json) return } - const config = readConfig(text) const currentExcludes = config.exclude const pattern = normalizePattern(patternArg, action) diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts index 79369cbf..163bc06c 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -190,16 +190,6 @@ export function readIntentConsumerConfig(text: string): IntentConsumerConfig { } } -export function readIntentExcludeList(text: string): Array { - const intent = parsePackageJson(text).intent - if (!isRecord(intent) || !Array.isArray(intent.exclude)) return [] - return intent.exclude.flatMap((entry) => { - if (typeof entry !== 'string') return [] - const trimmed = entry.trim() - return trimmed === '' ? [] : [trimmed] - }) -} - function equalsConfig( left: IntentConsumerConfig, right: IntentConsumerConfig, diff --git a/packages/intent/src/core/lockfile/hash.ts b/packages/intent/src/core/lockfile/hash.ts index e37009a9..a225dafd 100644 --- a/packages/intent/src/core/lockfile/hash.ts +++ b/packages/intent/src/core/lockfile/hash.ts @@ -16,13 +16,10 @@ const HASH_LIMITS = { export interface ComputeSkillContentHashOptions { packageRoot: string skillDir: string - fs?: HashReadFs + fs?: ReadFs } type HashEntry = { path: string; content: Buffer } -type HashReadFs = ReadFs - -const nodeHashReadFs: HashReadFs = nodeReadFs const HASH_ENTRY_SEPARATOR = Buffer.from([0]) function compareStrings(a: string, b: string): number { @@ -77,14 +74,23 @@ function readBoundedFile(fs: ReadFs, filePath: string): Buffer { if (stats.size > HASH_LIMITS.maxFileBytes) { throw new Error(`Hash file size limit exceeded by ${filePath}.`) } - const descriptor = fs.openSync!(filePath, 'r') + const { openSync, readSync, closeSync } = fs + if (!openSync || !readSync || !closeSync) { + const content = fs.readFileSync(filePath) + if (content.length > HASH_LIMITS.maxFileBytes) { + throw new Error(`Hash file size limit exceeded by ${filePath}.`) + } + return content + } + + const descriptor = 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) + const bytesRead = readSync(descriptor, buffer, 0, buffer.length, null) if (bytesRead === 0) break total += bytesRead if (total > HASH_LIMITS.maxFileBytes) { @@ -93,15 +99,12 @@ function readBoundedFile(fs: ReadFs, filePath: string): Buffer { chunks.push(buffer.subarray(0, bytesRead)) } } finally { - fs.closeSync!(descriptor) + closeSync(descriptor) } return Buffer.concat(chunks, total) } -function readDirectoryEntries( - fs: HashReadFs, - path: string, -): Array> { +function readDirectoryEntries(fs: ReadFs, path: string): Array> { if (!fs.opendirSync) { return fs.readdirSync(path, { encoding: 'utf8', withFileTypes: true }) } @@ -126,7 +129,7 @@ function readDirectoryEntries( export function computeSkillContentHash({ packageRoot, skillDir, - fs = nodeHashReadFs, + fs = nodeReadFs, }: ComputeSkillContentHashOptions): string { const realPackageRoot = fs.realpathSync(resolve(packageRoot)) const requestedSkillDir = isAbsolute(skillDir) diff --git a/packages/intent/src/core/lockfile/lockfile-state.ts b/packages/intent/src/core/lockfile/lockfile-state.ts index f63d7d43..343f659a 100644 --- a/packages/intent/src/core/lockfile/lockfile-state.ts +++ b/packages/intent/src/core/lockfile/lockfile-state.ts @@ -3,7 +3,7 @@ import { nodeReadFs, toPosixPath } from '../../shared/utils.js' import { validateSkillPath } from '../skill-path.js' import { sourceIdentityKey } from '../types.js' import { computeSkillContentHash } from './hash.js' -import type { IntentLockfileSource } from './lockfile.js' +import type { IntentLockfileSkill, IntentLockfileSource } from './lockfile.js' import type { IntentPackage } from '../../shared/types.js' import type { ReadFs } from '../../shared/utils.js' @@ -60,6 +60,22 @@ function skillDirectoryPath( return { absolute, relative: validateSkillPath(packageRelativePath) } } +export function buildCurrentLockfileSkill( + pkg: IntentPackage, + skill: IntentPackage['skills'][number], + fs: ReadFs = nodeReadFs, +): IntentLockfileSkill { + const path = skillDirectoryPath(pkg, skill.path) + return { + path: path.relative, + contentHash: computeSkillContentHash({ + packageRoot: pkg.packageRoot, + skillDir: path.absolute, + fs, + }), + } +} + export function buildCurrentLockfileSources( packages: ReadonlyArray, fs: ReadFs = nodeReadFs, @@ -68,17 +84,7 @@ export function buildCurrentLockfileSources( 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, - }), - } - }) + .map((skill) => buildCurrentLockfileSkill(pkg, skill, fs)) .sort((a, b) => compareStrings(a.path, b.path)), })) const identities = new Set() diff --git a/packages/intent/tests/catalog-api.test.ts b/packages/intent/tests/catalog-api.test.ts index 66f40847..dd387c7e 100644 --- a/packages/intent/tests/catalog-api.test.ts +++ b/packages/intent/tests/catalog-api.test.ts @@ -21,6 +21,7 @@ import { formatSessionCatalogue, getSessionCatalogue, } from '../src/session-catalog.js' +import type { ReadFs } from '../src/shared/utils.js' const roots: Array = [] @@ -82,20 +83,31 @@ function fixture(): { root: string; packageRoot: string; skillDir: string } { return { root, packageRoot, skillDir } } -async function getFixtureCatalogContext(root: string, cacheDir: string) { - const readFs = getProjectReadFs(root) +async function getFixtureCatalogContext( + root: string, + cacheDir: string, + readFs: ReadFs = getProjectReadFs(root), +) { + let warnings: Array = [] const result = await getSessionCatalogue({ cacheDir, root, readFs, - discover: () => - applyCatalogueLock( + discover: () => { + const discovered = applyCatalogueLock( listIntentSkills({ audience: 'agent', cwd: root }), root, readFs, - ), + ) + warnings = discovered.result.warnings + return discovered + }, }) - return { ...result, context: formatSessionCatalogue(result.catalogue) } + return { + ...result, + context: formatSessionCatalogue(result.catalogue), + warnings, + } } afterEach(() => { @@ -185,18 +197,28 @@ describe('getIntentCatalogContext', () => { expect(drifted.cacheStatus).toBe('refresh') expect(drifted.context).not.toContain('@fixture/package#core') expect(drifted.context).toContain('@fixture/package#sibling') - expect(readFileSync(first.cachePath)).toEqual(cachedBytes) + expect(drifted.warnings).toContain( + '1 skill was withheld because installed content does not match intent.lock.', + ) + + const unchangedDrifted = await getFixtureCatalogContext(root, cacheDir) + + expect(unchangedDrifted.cacheStatus).toBe('hit') + expect(unchangedDrifted.context).not.toContain('@fixture/package#core') + expect(unchangedDrifted.context).toContain('@fixture/package#sibling') + expect(readFileSync(first.cachePath)).not.toEqual(cachedBytes) writeFileSync(join(skillDir, 'SKILL.md'), originalSkill) const restored = await getFixtureCatalogContext(root, cacheDir) - expect(restored.cacheStatus).toBe('hit') + expect(restored.cacheStatus).toBe('refresh') expect(restored.context).toContain('@fixture/package#core') expect(restored.context).toContain('@fixture/package#sibling') }) it('withholds a newly discovered skill without changing accepted siblings', async () => { const { root, packageRoot } = fixture() + const cacheDir = join(root, 'cache') const newSkillDir = join(packageRoot, 'skills', 'new-skill') mkdirSync(newSkillDir, { recursive: true }) writeFileSync( @@ -204,11 +226,45 @@ describe('getIntentCatalogContext', () => { '---\nname: new-skill\ndescription: New package guidance\n---\n', ) - const result = await getIntentCatalogContext({ cwd: root }) + const first = await getFixtureCatalogContext(root, cacheDir) + const second = await getFixtureCatalogContext(root, cacheDir) + + expect(first.cacheStatus).toBe('miss') + expect(first.context).toContain('@fixture/package#core') + expect(first.context).toContain('@fixture/package#sibling') + expect(first.context).not.toContain('@fixture/package#new-skill') + expect(first.warnings).toContain( + '1 skill was withheld because no matching intent.lock entry exists.', + ) + expect(second.cacheStatus).toBe('hit') + expect(second.context).toEqual(first.context) + }) + + it('withholds an unhashable skill without withholding a healthy sibling or caching', async () => { + const { root, skillDir } = fixture() + const cacheDir = join(root, 'cache') + const nodeFs = getProjectReadFs(root) + const readFs: ReadFs = { + ...nodeFs, + realpathSync: ((path: Parameters[0]) => { + if (String(path).startsWith(skillDir)) { + throw new Error('Injected unhashable skill') + } + return nodeFs.realpathSync(path) + }) as ReadFs['realpathSync'], + } + + const first = await getFixtureCatalogContext(root, cacheDir, readFs) + const second = await getFixtureCatalogContext(root, cacheDir, readFs) - expect(result.context).toContain('@fixture/package#core') - expect(result.context).toContain('@fixture/package#sibling') - expect(result.context).not.toContain('@fixture/package#new-skill') + expect(first.cacheStatus).toBe('miss') + expect(first.context).not.toContain('@fixture/package#core') + expect(first.context).toContain('@fixture/package#sibling') + expect(first.warnings).toContain( + '1 skill was withheld because installed content could not be verified.', + ) + expect(second.cacheStatus).toBe('miss') + expect(second.context).toEqual(first.context) }) }) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 758b172b..57ba0246 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1202,68 +1202,31 @@ describe('cli commands', () => { expect(logSpy).toHaveBeenCalledWith('No excludes configured.') }) - it('lists excludes when released config has null skills', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-exclude-list-null-skills-'), - ) - tempDirs.push(root) - writeJson(join(root, 'package.json'), { - name: 'app', - private: true, - intent: { skills: null }, - }) - process.chdir(root) - - const exitCode = await main(['exclude', 'list']) - - expect(exitCode).toBe(0) - expect(logSpy).toHaveBeenCalledWith('No excludes configured.') - }) - - it('lists only nonblank string excludes from released config', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-exclude-list-legacy-'), - ) - tempDirs.push(root) - writeJson(join(root, 'package.json'), { - name: 'app', - private: true, - intent: { - skills: [], - exclude: ['', ' legacy-pkg ', null, 42], - }, - }) - process.chdir(root) - - const exitCode = await main(['exclude', 'list', '--json']) - const output = String(logSpy.mock.calls.at(-1)?.[0] ?? '') - - expect(exitCode).toBe(0) - expect(JSON.parse(output)).toEqual(['legacy-pkg']) - }) - - it('keeps exclude mutations strict for released config', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-exclude-write-legacy-'), - ) - tempDirs.push(root) - writeJson(join(root, 'package.json'), { - name: 'app', - private: true, - intent: { skills: null, exclude: ['legacy-pkg'] }, - }) - const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') - process.chdir(root) + it.each([{ command: ['list'] }, { command: ['add', 'new-pkg'] }])( + 'keeps exclude mutations strict for released config ($command)', + async ({ command }) => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-exclude-write-legacy-'), + ) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: null, exclude: ['legacy-pkg'] }, + }) + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + process.chdir(root) - expect(await main(['exclude', 'add', 'new-pkg'])).toBe(1) + expect(await main(['exclude', ...command])).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Invalid package.json intent configuration: intent.skills must be an array of strings.', - ) - expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( - packageJsonBefore, - ) - }) + expect(errorSpy).toHaveBeenCalledWith( + 'Invalid package.json intent configuration: intent.skills must be an array of strings.', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + }, + ) it('adds and lists an exclude pattern', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-exclude-add-')) diff --git a/packages/intent/tests/hash.test.ts b/packages/intent/tests/hash.test.ts index 03d4c3ef..e2550d1b 100644 --- a/packages/intent/tests/hash.test.ts +++ b/packages/intent/tests/hash.test.ts @@ -9,6 +9,8 @@ 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' +import { nodeReadFs } from '../src/shared/utils.js' +import type { ReadFs } from '../src/shared/utils.js' const roots: Array = [] @@ -65,6 +67,25 @@ describe('computeSkillContentHash', () => { ).toBe(baseline) }) + it('hashes with a ReadFs that omits optional low-level methods', () => { + const { root, skill } = skillRoot() + const readFs: ReadFs = { + existsSync: nodeReadFs.existsSync, + lstatSync: nodeReadFs.lstatSync, + readFileSync: nodeReadFs.readFileSync, + readdirSync: nodeReadFs.readdirSync, + realpathSync: nodeReadFs.realpathSync, + } + + expect( + computeSkillContentHash({ + packageRoot: root, + skillDir: skill, + fs: readFs, + }), + ).toBe(computeSkillContentHash({ packageRoot: root, skillDir: skill })) + }) + it.each(['references', 'assets', 'scripts'])( 'includes files under %s', (directory) => { diff --git a/packages/intent/tests/install-config.test.ts b/packages/intent/tests/install-config.test.ts index d3670acb..eb07425b 100644 --- a/packages/intent/tests/install-config.test.ts +++ b/packages/intent/tests/install-config.test.ts @@ -3,7 +3,6 @@ import { INSTALL_TARGETS, installTargetsForMethod, readIntentConsumerConfig, - readIntentExcludeList, updateIntentConsumerConfigText, } from '../src/commands/install/config.js' import type { @@ -136,18 +135,4 @@ describe('installer configuration', () => { readIntentConsumerConfig('{"intent":{"exclude":[" "]}}'), ).toThrow('must not contain blank entries') }) - - it('reads only normalized string excludes from released JSONC config', () => { - expect( - readIntentExcludeList(`{ - // released config shapes remain readable - "intent": { - "skills": null, - "exclude": ["", " legacy-pkg ", null, 42], - }, - }`), - ).toEqual(['legacy-pkg']) - expect(readIntentExcludeList('{"intent":{"exclude":null}}')).toEqual([]) - expect(readIntentExcludeList('{"intent":{"exclude":"pkg"}}')).toEqual([]) - }) }) From c15daf5d21087b7c96d72537a91c1b6125b427fe Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 29 Jul 2026 08:41:13 -0700 Subject: [PATCH 51/56] test(intent): consolidate policy coverage and repair benchmarks --- benchmarks/intent/helpers.ts | 3 +- benchmarks/intent/validate.bench.ts | 9 +- packages/intent/src/commands/list.ts | 2 +- packages/intent/tests/cli.test.ts | 97 ++--- .../tests/released-config-compat.test.ts | 339 ------------------ 5 files changed, 59 insertions(+), 391 deletions(-) diff --git a/benchmarks/intent/helpers.ts b/benchmarks/intent/helpers.ts index 5aee3552..c3918955 100644 --- a/benchmarks/intent/helpers.ts +++ b/benchmarks/intent/helpers.ts @@ -21,6 +21,7 @@ type ConsoleSnapshot = { export type SkillOptions = { description: string + name?: string bodyLines?: number type?: 'core' | 'framework' requires?: Array @@ -185,7 +186,7 @@ export function writeSkill( options: SkillOptions, ): void { const frontmatter = [ - `name: ${JSON.stringify(skillName)}`, + `name: ${JSON.stringify(options.name ?? skillName)}`, `description: ${JSON.stringify(options.description)}`, ] diff --git a/benchmarks/intent/validate.bench.ts b/benchmarks/intent/validate.bench.ts index b05e998e..6a0cdef0 100644 --- a/benchmarks/intent/validate.bench.ts +++ b/benchmarks/intent/validate.bench.ts @@ -44,7 +44,6 @@ function createFixture(): ValidateFixture { writeSkill(root, domain, { description: `${domain} overview and guardrails`, bodyLines: 20, - type: 'core', }) for (let index = 1; index <= 4; index++) { @@ -53,8 +52,8 @@ function createFixture(): ValidateFixture { writeSkill(root, skillName, { description: `${domain} workflow ${index}`, + name: `workflow-${index}`, bodyLines: 18, - type: isFrameworkSkill ? 'framework' : 'core', requires: isFrameworkSkill ? [domain] : undefined, }) } @@ -124,10 +123,8 @@ describe('intent validate', () => { 'checks a shipped skills tree', async () => { const state = getFixture() - for (let index = 0; index < 3; index++) { - await state.runner.run(['validate']) - } + await state.runner.run(['validate']) }, - createBenchOptions(setup, teardown), + { ...createBenchOptions(setup, teardown), warmupIterations: 1 }, ) }) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 9eff3ee5..c2298d52 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -148,7 +148,7 @@ export async function runListCommand( options: ListCommandOptions, ): Promise { const audience = detectIntentAudience() - const explain = audience === 'human' && options.why === true && !options.json + const explain = audience === 'human' && options.why === true const result = listIntentSkills({ ...coreOptionsFromGlobalFlags(options), audience, diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 57ba0246..ad3217e7 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1843,13 +1843,19 @@ describe('cli commands', () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-why-')) tempDirs.push(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') + const excludePattern = '@tanstack/query#mutations' writeFileSync(join(root, 'pnpm-lock.yaml'), '') writeJson(join(root, 'package.json'), { name: 'app', private: true, intent: { - skills: ['@tanstack/query#fetching', '@tanstack/query#query/cache'], + skills: [ + '@tanstack/query#fetching', + '@tanstack/query#query/cache', + excludePattern, + ], + exclude: [excludePattern], }, }) writeJson(join(pkgDir, 'package.json'), { @@ -1865,6 +1871,10 @@ describe('cli commands', () => { name: 'query/cache', description: 'Query cache skill', }) + writeSkillMd(join(pkgDir, 'skills', 'mutations'), { + name: 'mutations', + description: 'Query mutations skill', + }) process.env.INTENT_AUDIENCE = 'human' process.chdir(root) @@ -1880,6 +1890,35 @@ describe('cli commands', () => { 'Allowed by intent.skills["@tanstack/query#query/cache"]', ) expect(output.indexOf('Allowed by')).toBeLessThan(output.indexOf('Load:')) + + logSpy.mockClear() + const jsonExitCode = await main(['list', '--json', '--why']) + const jsonOutput = logSpy.mock.calls.flat().join('\n') + const parsed = JSON.parse(jsonOutput) as { + skills: Array<{ use: string; why?: string }> + excludedSkills: Array<{ + use: string + why?: string + excluded: true + }> + } + + expect(jsonExitCode).toBe(0) + expect( + Object.fromEntries(parsed.skills.map(({ use, why }) => [use, why])), + ).toMatchObject({ + '@tanstack/query#fetching': + 'Allowed by intent.skills["@tanstack/query#fetching"]', + '@tanstack/query#query/cache': + 'Allowed by intent.skills["@tanstack/query#query/cache"]', + }) + expect(parsed.excludedSkills).toContainEqual( + expect.objectContaining({ + use: excludePattern, + excluded: true, + why: `Excluded by intent.exclude[${JSON.stringify(excludePattern)}]`, + }), + ) }) it.each(['@tanstack/query#fetching', '@tanstack/query'])( @@ -2722,51 +2761,21 @@ describe('cli commands', () => { ]) }) - it('rejects --global and --global-only together on list', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-mutual-excl-list-')) - tempDirs.push(root) - process.chdir(root) - - const exitCode = await main(['list', '--global', '--global-only']) - - expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Use either --global or --global-only, not both.', - ) - }) - - it('rejects --global and --global-only together on install', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-mutual-excl-install-'), - ) - tempDirs.push(root) - process.chdir(root) - - const exitCode = await main(['install', '--global', '--global-only']) - - expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Use either --global or --global-only, not both.', - ) - }) - - it('rejects --global and --global-only together on load', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-mutual-excl-load-')) - tempDirs.push(root) - process.chdir(root) + it.each([['list'], ['install'], ['load', '@tanstack/query#core']])( + 'rejects --global and --global-only together on %s', + async (...command) => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-mutual-excl-')) + tempDirs.push(root) + process.chdir(root) - const exitCode = await main([ - 'load', - '@tanstack/query#core', - '--global', - '--global-only', - ]) + const exitCode = await main([...command, '--global', '--global-only']) - expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Use either --global or --global-only, not both.', - ) - }) + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Use either --global or --global-only, not both.', + ) + }, + ) it('loads a local skill use as markdown', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-')) diff --git a/packages/intent/tests/released-config-compat.test.ts b/packages/intent/tests/released-config-compat.test.ts index 7b15b529..17256791 100644 --- a/packages/intent/tests/released-config-compat.test.ts +++ b/packages/intent/tests/released-config-compat.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest' import { compileExcludePatterns } from '../src/core/excludes.js' import { - ALLOW_ALL_NOTICE, - EMPTY_NOTE, - MIGRATION_NOTICE, applySourcePolicy, checkLoadAllowed, compileSkillSourcePolicy, @@ -36,342 +33,6 @@ function config(value: unknown) { } 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'], From b211fde95ef8a9d49dda8244330f603dba298827 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 29 Jul 2026 10:08:30 -0700 Subject: [PATCH 52/56] fix(intent): keep policy-hidden skills out of diagnostics --- packages/intent/src/catalog-lock.ts | 6 +- packages/intent/src/core/intent-core.ts | 14 +++-- packages/intent/tests/catalog-api.test.ts | 67 +++++++++++++++++++---- packages/intent/tests/core.test.ts | 53 ++++++++++++++++++ 4 files changed, 125 insertions(+), 15 deletions(-) diff --git a/packages/intent/src/catalog-lock.ts b/packages/intent/src/catalog-lock.ts index a1467728..b8025735 100644 --- a/packages/intent/src/catalog-lock.ts +++ b/packages/intent/src/catalog-lock.ts @@ -34,6 +34,7 @@ export function applyCatalogueLock( const fsCache = createIntentFsCache() fsCache.useFs(readFs) + const surfacedUses = new Set(result.skills.map((skill) => skill.use)) const allowedUses = new Set() const verification: Array = [] let pendingCount = 0 @@ -60,6 +61,9 @@ export function applyCatalogueLock( lockedSource?.skills.map((skill) => [skill.path, skill]) ?? [], ) for (const scannedSkill of scanned.skills) { + const use = formatSkillUse(scanned.name, scannedSkill.name) + if (!surfacedUses.has(use)) continue + let skill: ReturnType try { skill = buildCurrentLockfileSkill( @@ -81,7 +85,7 @@ export function applyCatalogueLock( lockedSkills.get(skill.path)?.contentHash, ) if (status === 'accepted') { - allowedUses.add(formatSkillUse(scanned.name, scannedSkill.name)) + allowedUses.add(use) } else { if (status === 'new') pendingCount += 1 else changedCount += 1 diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index c4f8d967..cd528d14 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -442,10 +442,16 @@ function resolveIntentSkillInCwd( packages: discoveredPackages, }) } catch (err) { - if (err instanceof ResolveSkillUseError) { - throw new IntentCoreError(err.code, err.message, { - suggestedSkills: err.suggestedSkills, - }) + if (!(err instanceof ResolveSkillUseError)) throw err + try { + resolveSkillUse(use, scanResult) + } catch (filteredError) { + if (filteredError instanceof ResolveSkillUseError) { + throw new IntentCoreError(filteredError.code, filteredError.message, { + suggestedSkills: filteredError.suggestedSkills, + }) + } + throw filteredError } throw err } diff --git a/packages/intent/tests/catalog-api.test.ts b/packages/intent/tests/catalog-api.test.ts index dd387c7e..b958129d 100644 --- a/packages/intent/tests/catalog-api.test.ts +++ b/packages/intent/tests/catalog-api.test.ts @@ -83,6 +83,22 @@ function fixture(): { root: string; packageRoot: string; skillDir: string } { return { root, packageRoot, skillDir } } +function getReadFsWithUnhashableDirectory( + root: string, + unhashableDirectory: string, +): ReadFs { + const nodeFs = getProjectReadFs(root) + return { + ...nodeFs, + realpathSync: ((path: Parameters[0]) => { + if (String(path).startsWith(unhashableDirectory)) { + throw new Error('Injected unhashable directory') + } + return nodeFs.realpathSync(path) + }) as ReadFs['realpathSync'], + } +} + async function getFixtureCatalogContext( root: string, cacheDir: string, @@ -243,16 +259,7 @@ describe('getIntentCatalogContext', () => { it('withholds an unhashable skill without withholding a healthy sibling or caching', async () => { const { root, skillDir } = fixture() const cacheDir = join(root, 'cache') - const nodeFs = getProjectReadFs(root) - const readFs: ReadFs = { - ...nodeFs, - realpathSync: ((path: Parameters[0]) => { - if (String(path).startsWith(skillDir)) { - throw new Error('Injected unhashable skill') - } - return nodeFs.realpathSync(path) - }) as ReadFs['realpathSync'], - } + const readFs = getReadFsWithUnhashableDirectory(root, skillDir) const first = await getFixtureCatalogContext(root, cacheDir, readFs) const second = await getFixtureCatalogContext(root, cacheDir, readFs) @@ -266,6 +273,46 @@ describe('getIntentCatalogContext', () => { expect(second.cacheStatus).toBe('miss') expect(second.context).toEqual(first.context) }) + + it('ignores an excluded unhashable skill during lock verification', () => { + const { root, packageRoot, skillDir } = fixture() + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'catalog-consumer', + private: true, + dependencies: { '@fixture/package': '1.0.0' }, + intent: { + skills: ['@fixture/package'], + exclude: ['@fixture/package#core'], + }, + }), + ) + const readFs = getReadFsWithUnhashableDirectory(root, skillDir) + + const locked = applyCatalogueLock( + listIntentSkills({ audience: 'agent', cwd: root }), + root, + readFs, + ) + + expect(locked.result.skills.map((skill) => skill.use)).toEqual([ + '@fixture/package#sibling', + ]) + expect(locked.result.warnings).not.toContain( + '1 skill was withheld because installed content could not be verified.', + ) + expect(locked.verification).toEqual([ + { + packageRoot, + skillPath: 'skills/sibling', + contentHash: computeSkillContentHash({ + packageRoot, + skillDir: join(packageRoot, 'skills', 'sibling'), + }), + }, + ]) + }) }) describe('runSessionCatalogueHook', () => { diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 211ab3bd..be902933 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -1032,6 +1032,59 @@ describe('loadIntentSkill', () => { ]) }) + it('redacts policy-hidden names from full-scan resolution errors', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { + skills: [ + '@scope/ui#ui/auth', + '@scope/ui#ui/auth-visible', + '@scope/missing', + ], + }, + }) + for (const skillName of ['ui/auth-visible', 'ui/auth-hidden']) { + writeInstalledIntentPackage(root, { + name: '@scope/ui', + version: '1.0.0', + skillName, + description: skillName, + }) + } + writeInstalledIntentPackage(root, { + name: '@scope/hidden', + version: '1.0.0', + skillName: 'secret', + description: 'Hidden package skill', + }) + + let skillError: unknown + try { + loadIntentSkill('@scope/ui#auth', { cwd: root }) + } catch (error) { + skillError = error + } + + expect(skillError).toBeInstanceOf(IntentCoreError) + expect((skillError as IntentCoreError).suggestedSkills).toEqual([ + 'ui/auth-visible', + ]) + expect((skillError as Error).message).toContain('ui/auth-visible') + expect((skillError as Error).message).not.toContain('ui/auth-hidden') + + let packageError: unknown + try { + loadIntentSkill('@scope/missing#auth', { cwd: root }) + } catch (error) { + packageError = error + } + + expect(packageError).toBeInstanceOf(IntentCoreError) + expect((packageError as Error).message).toContain('@scope/ui') + expect((packageError as Error).message).not.toContain('@scope/hidden') + }) + it('fails clearly when the package is excluded', () => { writeInstalledIntentPackage(root, { name: '@tanstack/devtools', From 34c3febd0ef711407583735abb772481fd0c3cf1 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 29 Jul 2026 14:46:47 -0700 Subject: [PATCH 53/56] rm some old code --- packages/intent/src/cli.ts | 64 +- packages/intent/src/commands/hooks/command.ts | 23 - .../intent/src/commands/install/command.ts | 85 +- .../intent/src/commands/install/config.ts | 5 - .../intent/src/commands/install/consumer.ts | 120 +-- packages/intent/src/commands/install/plan.ts | 38 - packages/intent/src/core/intent-core.ts | 28 +- packages/intent/src/core/skill-sources.ts | 7 +- packages/intent/src/core/source-policy.ts | 12 +- packages/intent/src/hooks/install.ts | 18 - packages/intent/tests/cli.test.ts | 816 ++---------------- .../intent/tests/consumer-install.test.ts | 110 ++- packages/intent/tests/core.test.ts | 28 +- packages/intent/tests/hooks-install.test.ts | 19 +- packages/intent/tests/install-plan.test.ts | 51 -- .../integration/pnp-berry-corepack.test.ts | 58 +- packages/intent/tests/integration/scaffold.ts | 2 + packages/intent/tests/skill-sources.test.ts | 10 +- packages/intent/tests/source-policy.test.ts | 42 +- 19 files changed, 388 insertions(+), 1148 deletions(-) delete mode 100644 packages/intent/src/commands/hooks/command.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 55ec68af..079879c7 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -7,7 +7,6 @@ import { fail, isCliFailure } from './shared/cli-error.js' import type { CAC } from 'cac' import type { HookAgent } from './catalog.js' import type { ExcludeCommandOptions } from './commands/exclude.js' -import type { HooksInstallCommandOptions } from './commands/hooks/command.js' import type { CatalogCommandOptions } from './commands/catalog.js' import type { InstallCommandOptions } from './commands/install/command.js' import type { ListCommandOptions } from './commands/list.js' @@ -140,26 +139,18 @@ function createCli(): CAC { cli .command('install', 'Configure trusted skill sources and delivery targets') .usage( - 'install [--map] [--dry-run] [--no-input] [--global] [--global-only] [--no-notices]', + 'install [--map] [--dry-run] [--global] [--global-only] [--no-notices]', ) .option('--map', 'Write explicit skill-to-task mappings') .option('--dry-run', 'Preview installation without writing files') - .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') .example('install') .example('install --map') .example('install --dry-run') - .example('install --no-input') .example('install --map --global') .action(async (options: InstallCommandOptions) => { - if (options.map && options.input === false) { - fail('Cannot combine --map and --no-input.') - } const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ import('./commands/support.js'), import('./commands/install/command.js'), @@ -180,48 +171,27 @@ function createCli(): CAC { }) cli - .command( - 'hooks [action]', - 'Manage agent hooks that surface available skills', - ) - .usage( - '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') + .command('hooks [action]', 'Run agent hooks that surface available skills') + .usage('hooks run --agent copilot|claude|codex') .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 & { agent?: string }, - ) => { - if (action === 'install') { - const { runHooksInstallCommand } = - await import('./commands/hooks/command.js') - runHooksInstallCommand(options) - return + .action(async (action: string | undefined, options: { agent?: string }) => { + if (action === 'run') { + if (!options.agent) { + fail('Missing hook agent. Expected copilot, claude, or codex.') } - - 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 + 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.') - }, - ) + fail('Unknown hooks action: expected run.') + }) cli .command('scaffold', 'Print maintainer scaffold prompt') diff --git a/packages/intent/src/commands/hooks/command.ts b/packages/intent/src/commands/hooks/command.ts deleted file mode 100644 index 3f2e999b..00000000 --- a/packages/intent/src/commands/hooks/command.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - formatHookInstallResult, - runInstallHooks, -} from '../../hooks/install.js' - -export interface HooksInstallCommandOptions { - agents?: string - scope?: string -} - -export function runHooksInstallCommand( - options: HooksInstallCommandOptions, -): void { - const results = runInstallHooks({ - agents: options.agents, - root: process.cwd(), - scope: options.scope, - }) - - for (const result of results) { - console.log(formatHookInstallResult(result)) - } -} diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 818e054d..b0ceacf8 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,5 +1,4 @@ -import { readFileSync } from 'node:fs' -import { join, relative } from 'node:path' +import { relative } from 'node:path' import { fail } from '../../shared/cli-error.js' import { detectIntentAudience } from '../../shared/environment.js' import { @@ -16,22 +15,17 @@ 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, - existingLockReview, prompts, root, - selection, }: { dryRun?: boolean - existingLockReview?: 'fail' prompts: InstallerPrompter root: string - selection?: SkillSelection }): Promise { const [{ runConsumerInstall }, { createIntentFsCache }, { scanForIntents }] = await Promise.all([ @@ -45,84 +39,14 @@ async function runInstallWithPrompts({ await runConsumerInstall({ discovered: scan.packages, dryRun, - existingLockReview, prompts, readFs: fsCache.getReadFs(), 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 { hasExplicitIntentSkills, readIntentConsumerConfig } = - await import('./config.js') - const config = readIntentConsumerConfig(packageJson) - 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 { readIntentLockfile } = await import('../../core/lockfile/lockfile.js') - const hasExistingLock = - readIntentLockfile(join(root, 'intent.lock')).status === 'found' - if (hasExistingLock && install?.method !== 'hooks') { - const { runSyncCommand } = await import('../sync/command.js') - await runSyncCommand({ ...options, cwd: root }, { review: 'fail' }) - return - } - 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 (!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 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, - existingLockReview: hasExistingLock ? 'fail' : undefined, - prompts, - root, - selection, }) } export interface InstallCommandOptions extends GlobalScanFlags { dryRun?: boolean - input?: boolean map?: boolean } @@ -213,18 +137,13 @@ 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, use `intent install --map`, or configure explicit package.json intent.skills and intent.install values before using `intent install --no-input`.', + 'Interactive installation requires a terminal. Run `intent install` in a TTY or use `intent install --map`.', ) } 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 163bc06c..008e5bee 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -161,11 +161,6 @@ 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 c6206806..eb143989 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -36,7 +36,7 @@ import type { IntentInstallPreferences, } from './config.js' import type { SkillSelection } from './plan.js' -import type { HookAgent } from '../../hooks/types.js' +import type { HookAgent, HookInstallScope } from '../../hooks/types.js' import type { ReadFs } from '../../shared/utils.js' import type { IntentPackage } from '../../shared/types.js' @@ -68,11 +68,9 @@ export interface InstallerPrompter { export interface RunConsumerInstallOptions { discovered: ReadonlyArray dryRun?: boolean - existingLockReview?: 'fail' prompts: InstallerPrompter readFs?: ReadFs root: string - selection?: SkillSelection } function hookAgentForTarget(target: InstallTarget): HookAgent { @@ -93,14 +91,47 @@ function countSkills(entries: ReadonlyArray<{ skillCount: number }>): number { return entries.reduce((count, entry) => count + entry.skillCount, 0) } +async function confirmUserScopeHooks( + targets: ReadonlyArray, + prompts: InstallerPrompter, +): Promise { + if (!targets.includes('github')) return false + return prompts.confirmUserScopeHooks() +} + +function installHookAgents( + root: string, + agents: string, + scope: HookInstallScope, +): Array { + return runInstallHooks({ agents, root, scope }) + .filter((result) => result.status !== 'skipped') + .map((result) => result.agent) +} + +function installConfiguredHooks( + root: string, + targets: ReadonlyArray, + userScopeHooksAccepted: boolean, +): Array { + const hookAgents = targets.map(hookAgentForTarget) + const projectAgents = hookAgents.filter((agent) => agent !== 'copilot') + const installedAgents = + projectAgents.length > 0 + ? installHookAgents(root, projectAgents.join(','), 'project') + : [] + if (userScopeHooksAccepted) { + installedAgents.push(...installHookAgents(root, 'copilot', 'user')) + } + return installedAgents +} + export async function runConsumerInstall({ discovered, dryRun = false, - existingLockReview, prompts, readFs = nodeReadFs, root, - selection: fixedSelection, }: RunConsumerInstallOptions): Promise { const packageJsonPath = join(root, 'package.json') const packageJson = readFileSync(packageJsonPath, 'utf8') @@ -121,22 +152,31 @@ export async function runConsumerInstall({ const changed = countSkills(summary.changed) const hasChanges = newDependencies > 0 || newSkills > 0 || changed > 0 || summary.removed > 0 - if ( - !hasChanges && - existingLock.status === 'found' && - existingLockReview !== 'fail' - ) { - prompts.complete('Project is up to date.') - return - } - if ( - hasChanges && - existingLock.status === 'found' && - existingLockReview === 'fail' - ) { - throw new Error( - 'Intent install requires review before automation can continue.', + if (!hasChanges && existingLock.status === 'found') { + if (install.method === 'symlink') { + prompts.complete('Project is up to date.') + return + } + const userScopeHooksAccepted = await confirmUserScopeHooks( + install.targets, + prompts, + ) + if (userScopeHooksAccepted === null) return + const installedAgents = installConfiguredHooks( + root, + install.targets, + userScopeHooksAccepted, + ) + const repairedHooks = + installedAgents.length > 0 ? ' Repaired configured hooks.' : '' + const skippedCopilot = + install.targets.includes('github') && !userScopeHooksAccepted + ? ' Copilot was skipped because home-directory access was declined.' + : '' + prompts.complete( + `Project is up to date.${repairedHooks}${skippedCopilot}`, ) + return } console.log( `Install changes: ${newDependencies} new ${newDependencies === 1 ? 'dependency' : 'dependencies'}, ${newSkills} new ${newSkills === 1 ? 'skill' : 'skills'}, ${changed} changed, ${summary.removed} removed.`, @@ -153,14 +193,11 @@ export async function runConsumerInstall({ const symlinkAccepted = await prompts.confirmSymlink() if (!symlinkAccepted) return } - if ( - fixedSelection === undefined && - discovered.every((pkg) => pkg.skills.length === 0) - ) { + if (discovered.every((pkg) => pkg.skills.length === 0)) { prompts.complete('No intent-enabled skills found.') return } - const selection = fixedSelection ?? (await prompts.selectSkills(discovered)) + const selection = await prompts.selectSkills(discovered) if (!selection) return const plan = buildSkillSelectionPlan(discovered, selection) const installation = { @@ -246,12 +283,9 @@ 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 - } + const userScopeHooksAccepted = + method === 'hooks' ? await confirmUserScopeHooks(targets, prompts) : false + if (userScopeHooksAccepted === null) return writeTextFileAtomic(packageJsonPath, updatedPackageJson) writeIntentLockfile(join(root, 'intent.lock'), lockfile) @@ -268,25 +302,11 @@ export async function runConsumerInstall({ 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 installedAgents = installConfiguredHooks( + root, + targets, + userScopeHooksAccepted, + ) const skippedCopilot = targets.includes('github') && !userScopeHooksAccepted ? ' Copilot was skipped because home-directory access was declined.' diff --git a/packages/intent/src/commands/install/plan.ts b/packages/intent/src/commands/install/plan.ts index 3999c013..75dc7444 100644 --- a/packages/intent/src/commands/install/plan.ts +++ b/packages/intent/src/commands/install/plan.ts @@ -21,11 +21,6 @@ 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 @@ -211,39 +206,6 @@ 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) => { - const packageSkills = sortedSkills(pkg) - return { - name: pkg.name, - kind: pkg.kind, - skills: packageSkills.map((skill) => { - const id = skillSelectionId(pkg, skill) - const status = classifySkillPolicy( - pkg, - skill.name, - packageSkills, - 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') { diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts index cd528d14..4812d3bc 100644 --- a/packages/intent/src/core/intent-core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -135,21 +135,19 @@ export function listIntentSkills( 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, - pkg.skills, - ) - return decision.source - ? `Allowed by intent.skills[${JSON.stringify(decision.source.raw)}]` - : undefined - })() + ? config.mode === 'allow-all' + ? 'Allowed because intent.skills allows all sources' + : (() => { + const decision = sourcePolicy.explainPermitsSkill( + pkg.name, + skill.name, + pkg.kind, + pkg.skills, + ) + return decision.source + ? `Allowed by intent.skills[${JSON.stringify(decision.source.raw)}]` + : undefined + })() : undefined, } }), diff --git a/packages/intent/src/core/skill-sources.ts b/packages/intent/src/core/skill-sources.ts index 4c7a02cf..9604871d 100644 --- a/packages/intent/src/core/skill-sources.ts +++ b/packages/intent/src/core/skill-sources.ts @@ -16,12 +16,7 @@ type SkillSource = )) | { raw: string; id: string; kind: 'git'; ref: string } -/** - * `absent` (key missing, v0 upgrade path) and `empty` (`[]`) are deliberately - * distinct: absent is show-all, empty is deny-all. - */ export type SkillSourcesConfig = - | { mode: 'absent' } | { mode: 'empty' } | { mode: 'allow-all' } | { mode: 'explicit'; sources: Array } @@ -54,7 +49,7 @@ export function isSkillSourcesParseError( */ export function parseSkillSources(value: unknown): SkillSourcesConfig { if (value === undefined || value === null) { - return { mode: 'absent' } + return { mode: 'empty' } } if (!Array.isArray(value)) { diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index c39f6c2f..890355dc 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -30,9 +30,6 @@ import type { export { ALLOW_ALL_NOTICE } -export const MIGRATION_NOTICE = - 'intent.skills is not set — all discovered skill sources are surfaced. A future version will require an explicit intent.skills allowlist; add one to opt in to specific sources.' - export const EMPTY_NOTE = 'intent.skills is empty — no skill sources are permitted.' @@ -141,7 +138,6 @@ export function compileSkillSourcePolicy( config: SkillSourcesConfig, ): CompiledSkillSourcePolicy { switch (config.mode) { - case 'absent': case 'allow-all': return { matchers: [], @@ -436,8 +432,7 @@ export function applySourcePolicy( } } - if (config.mode === 'absent') emit(MIGRATION_NOTICE) - else if (config.mode === 'allow-all') emit(ALLOW_ALL_NOTICE) + if (config.mode === 'allow-all') emit(ALLOW_ALL_NOTICE) else if (config.mode === 'empty') emit(EMPTY_NOTE) return { @@ -450,8 +445,6 @@ export function applySourcePolicy( } } -// A null/undefined intent.skills is treated as not-declared so it cannot -// shadow a stricter parent allowlist. export function readSkillSourcesConfig( cwd: string, context: ProjectContext = resolveProjectContext({ cwd }), @@ -462,12 +455,11 @@ export function readSkillSourcesConfig( if ('skills' in intent) { const skills = (intent as Record).skills - if (skills === null || skills === undefined) continue return parseSkillSources(skills) } } - return { mode: 'absent' } + return parseSkillSources(undefined) } export interface PolicedScan { diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 919f9810..3c585be5 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -49,24 +49,6 @@ export function runInstallHooks({ ) } -export function formatHookInstallResult(result: HookInstallResult): string { - if (result.status === 'skipped') { - return `Skipped Intent hooks for ${result.agent}: ${result.reason}` - } - - const target = result.configPath - ? formatPath(result.configPath) - : result.agent - switch (result.status) { - case 'created': - return `Installed Intent hooks for ${result.agent} (${result.scope}) in ${target}.` - case 'updated': - return `Updated Intent hooks for ${result.agent} (${result.scope}) in ${target}.` - case 'unchanged': - return `No changes to Intent hooks for ${result.agent} (${result.scope}); already current.` - } -} - function installAgentHook({ agent, copilotHome, diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index ad3217e7..02215e7e 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -33,6 +33,14 @@ function writeJson(filePath: string, data: unknown): void { writeFileSync(filePath, JSON.stringify(data, null, 2)) } +function writeAllowAllConsumer(root: string): void { + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['*'] }, + }) +} + function writeIntentLock( root: string, packages: Parameters[0] = [], @@ -72,6 +80,34 @@ function writeInstalledIntentPackage( version: string }, ): void { + const consumerPackageJsonPath = join(root, 'package.json') + const consumerPackageJson = existsSync(consumerPackageJsonPath) + ? (JSON.parse(readFileSync(consumerPackageJsonPath, 'utf8')) as Record< + string, + unknown + >) + : { name: 'app', private: true } + const consumerIntent = + consumerPackageJson.intent && + typeof consumerPackageJson.intent === 'object' && + !Array.isArray(consumerPackageJson.intent) + ? (consumerPackageJson.intent as Record) + : {} + const consumerDependencies = + consumerPackageJson.dependencies && + typeof consumerPackageJson.dependencies === 'object' && + !Array.isArray(consumerPackageJson.dependencies) + ? (consumerPackageJson.dependencies as Record) + : {} + writeJson(consumerPackageJsonPath, { + ...consumerPackageJson, + dependencies: { ...consumerDependencies, [name]: version }, + intent: { + ...consumerIntent, + ...(!Object.hasOwn(consumerIntent, 'skills') ? { skills: ['*'] } : {}), + }, + }) + const pkgDir = join(root, 'node_modules', ...name.split('/')) writeJson(join(pkgDir, 'package.json'), { name, @@ -91,6 +127,7 @@ function writeConflictingQueryPackages(root: string): { writeJson(join(root, 'package.json'), { name: 'app', private: true, + intent: { skills: ['*'] }, dependencies: { 'consumer-a': '1.0.0', 'consumer-b': '1.0.0', @@ -291,6 +328,18 @@ describe('cli commands', () => { expect(output).toContain('--show-hidden') }) + it('omits the removed non-interactive install and standalone hook options from help', async () => { + expect(await main(['install', '--help'])).toBe(0) + expect(getHelpOutput()).not.toContain('--no-input') + + infoSpy.mockClear() + logSpy.mockClear() + + expect(await main(['hooks', '--help'])).toBe(0) + expect(getHelpOutput()).not.toContain('--scope') + expect(getHelpOutput()).not.toContain('--agents') + }) + 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) @@ -450,7 +499,9 @@ describe('cli commands', () => { }) process.chdir(root) - expect(await main(['install', '--no-input'])).toBe(0) + const discovered = scanForIntents(root, { scope: 'local' }).packages + writeIntentLock(root, discovered) + expect(await main(['sync'])).toBe(0) const statePath = join(root, '.intent', 'install-state.json') const conflictPath = '.agents/skills/npm-verified-core' const linkPath = join(root, conflictPath) @@ -487,704 +538,16 @@ describe('cli commands', () => { expect(errorSpy).toHaveBeenCalledWith('Unknown option `--printPrompt`') }) - it('rejects install --map --no-input before writing files', async () => { + it('rejects the removed install --no-input option 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 - writeIntentLock(root, 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 - writeIntentLock(root, 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 - writeIntentLock(root, 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( - 'Pending skills by source:', - ) - 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) - const claudeHooks = join(root, '.claude', 'settings.json') - const codexHooks = join(root, '.codex', 'hooks.json') - expect(existsSync(join(root, 'intent.lock'))).toBe(true) - expect(existsSync(claudeHooks)).toBe(true) - expect(existsSync(codexHooks)).toBe(true) - - rmSync(claudeHooks) - expect(await main(['install', '--no-input'])).toBe(0) - expect(existsSync(claudeHooks)).toBe(true) - expect(existsSync(codexHooks)).toBe(true) - }) - - it('rejects configured hook content drift before writing package, lock, or hooks', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-install-hooks-drift-'), - ) - 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) - const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') - const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') - const claudeHooks = join(root, '.claude', 'settings.json') - writeFileSync( - join(root, 'node_modules', 'verified', 'skills', 'core', 'SKILL.md'), - '---\nname: core\ndescription: Changed skill\n---\n', - ) - rmSync(claudeHooks) - errorSpy.mockClear() - - expect(await main(['install', '--no-input'])).toBe(1) - - expect(errorSpy).toHaveBeenCalledWith( - 'Intent install requires review before automation can continue.', - ) - expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( - packageJsonBefore, - ) - expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) - expect(existsSync(claudeHooks)).toBe(false) - }) - - 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('rejects configured Copilot hooks before writes when a lock exists', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-install-copilot-locked-'), - ) - 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 discovered = scanForIntents(root, { scope: 'local' }).packages - writeIntentLock(root, discovered) - 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(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, - ) - expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) - for (const path of ['.intent', '.claude', '.codex']) { - expect(existsSync(join(root, path))).toBe(false) - } - }) - - it('uses the workspace root Copilot guard for install --no-input from a leaf', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-install-leaf-copilot-'), - ) - const leaf = join(root, 'packages', 'leaf') - const copilotHome = join(root, 'copilot-home') - const previousCopilotHome = process.env.COPILOT_HOME - tempDirs.push(root) - writeJson(join(root, 'package.json'), { - name: 'workspace', - private: true, - workspaces: ['packages/*'], - intent: { - skills: ['verified'], - install: { method: 'hooks', targets: ['claude', 'github'] }, - }, - }) - writeJson(join(leaf, 'package.json'), { - name: 'leaf', - private: true, - intent: { - skills: ['verified'], - install: { method: 'hooks', targets: ['claude'] }, - }, - }) - writeInstalledIntentPackage(root, { - name: 'verified', - version: '1.0.0', - skillName: 'core', - description: 'Verified skill', - }) - const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') - process.env.COPILOT_HOME = copilotHome - process.chdir(leaf) - - try { - expect(await main(['install', '--no-input'])).toBe(1) - } finally { - if (previousCopilotHome === undefined) { - delete process.env.COPILOT_HOME - } else { - process.env.COPILOT_HOME = previousCopilotHome - } - } - - expect(errorSpy).toHaveBeenCalledWith( - 'Non-interactive install cannot bootstrap GitHub Copilot hooks because they require interactive approval for user-home access. Remove "github" from intent.install.targets or run `intent install` in a terminal.', - ) - expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( - packageJsonBefore, - ) - for (const path of [ - 'intent.lock', - '.intent', - '.claude', - '.codex', - join('copilot-home', 'hooks', 'hooks.json'), - ]) { - expect(existsSync(join(root, path))).toBe(false) - } - }) - - it('runs install --no-input through sync without prompting', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-no-input-')) - tempDirs.push(root) - writeJson(join(root, 'package.json'), { - name: 'app', - private: true, - intent: { - skills: ['verified'], - install: { method: 'symlink', targets: ['github'] }, - }, - }) - writeInstalledIntentPackage(root, { - name: 'verified', - version: '1.0.0', - skillName: 'core', - description: 'Verified skill', - }) - const discovered = scanForIntents(root, { scope: 'local' }).packages - writeIntentLock(root, discovered) - process.chdir(root) - - const exitCode = await main(['install', '--no-input']) - - expect(errorSpy).not.toHaveBeenCalled() - expect(exitCode).toBe(0) - expect( - lstatSync( - join(root, '.github', 'skills', 'npm-verified-core'), - ).isSymbolicLink(), - ).toBe(true) - }) - - it('syncs an existing lock before requiring bootstrap-only intent.skills', async () => { - const root = mkdtempSync( - join(realTmpdir, 'intent-cli-install-lock-no-skills-'), - ) - tempDirs.push(root) - writeJson(join(root, 'package.json'), { - name: 'app', - private: true, - intent: { - install: { method: 'symlink', targets: ['agents'] }, - }, - }) - writeIntentLock(root) - 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) + expect(errorSpy).toHaveBeenCalledWith('Unknown option `--input`') + expect(readdirSync(root)).toEqual(entriesBefore) }) it('lists excludes when none are configured', async () => { @@ -1336,39 +699,24 @@ describe('cli commands', () => { expect(exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith( - 'Interactive installation requires a terminal. Run `intent install` in a TTY, use `intent install --map`, or configure explicit package.json intent.skills and intent.install values before using `intent install --no-input`.', + '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 () => { + it('rejects the removed hooks install action', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-install-')) tempDirs.push(root) process.chdir(root) - const exitCode = await main(['hooks', 'install', '--agents', 'claude']) - const output = logSpy.mock.calls.flat().join('\n') - - expect(exitCode).toBe(0) - expect(output).toContain('Installed Intent hooks for claude (project)') - expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) - expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) - }) - - it('fails cleanly for invalid hooks install options', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-bad-options-')) - tempDirs.push(root) - process.chdir(root) - - const exitCode = await main(['hooks', 'install', '--scope', 'repo']) + const exitCode = await main(['hooks', 'install']) expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Unknown hook scope: repo. Expected project or user.', - ) + expect(errorSpy).toHaveBeenCalledWith('Unknown hooks action: expected run.') expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) it('runs the session catalogue hook for a valid agent', async () => { @@ -1516,6 +864,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-install-local-only-global-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -1545,6 +894,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-install-global-node-modules-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -1799,6 +1149,7 @@ describe('cli commands', () => { it('prints full load commands for every skill in human list output', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-load-commands-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') writeFileSync(join(root, 'pnpm-lock.yaml'), '') @@ -1985,19 +1336,14 @@ describe('cli commands', () => { 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) => { + it('explains explicit allow-all configuration', async () => { 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 }, - }) - } + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['*'] }, + }) writeInstalledIntentPackage(root, { name: '@tanstack/query', version: '5.0.0', @@ -2010,7 +1356,7 @@ describe('cli commands', () => { expect(await main(['list', '--why'])).toBe(0) const output = logSpy.mock.calls.flat().join('\n') - expect(output).toContain(explanation) + expect(output).toContain('Allowed because intent.skills allows all sources') }) it('adds no output for --why in agent sessions', async () => { @@ -2095,6 +1441,7 @@ describe('cli commands', () => { it('prints compact list guidance for agents', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-agent-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') writeFileSync(join(root, 'pnpm-lock.yaml'), '') @@ -2356,7 +1703,7 @@ describe('cli commands', () => { expect(output).not.toContain('Could not read') }) - it('prints the intent.skills migration notice to stderr, not stdout', async () => { + it('treats missing intent.skills as empty without a migration notice', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-list-migration-')) const isolatedGlobalRoot = mkdtempSync( join(realTmpdir, 'intent-cli-list-migration-empty-global-'), @@ -2368,6 +1715,7 @@ describe('cli commands', () => { skillName: 'fetching', description: 'Query data fetching patterns', }) + writeJson(join(root, 'package.json'), { name: 'app', private: true }) process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot process.env.INTENT_AUDIENCE = 'human' @@ -2378,8 +1726,9 @@ describe('cli commands', () => { const stderr = errorSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) - expect(stdout).toContain('@tanstack/query') - expect(stderr).toContain('intent.skills is not set') + expect(stdout).toContain('No intent-enabled packages found.') + expect(stderr).toContain('intent.skills is empty') + expect(stderr).not.toContain('intent.skills is not set') expect(stdout).not.toContain('intent.skills is not set') expect(stdout).not.toContain('Notices:') }) @@ -2509,6 +1858,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-list-local-only-global-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -2540,6 +1890,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-list-global-node-modules-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -2596,6 +1947,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-list-global-human-node-modules-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -2629,6 +1981,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-list-mixed-global-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const localPkgDir = join(root, 'node_modules', '@tanstack', 'query') writeJson(join(localPkgDir, 'package.json'), { @@ -2799,6 +2152,7 @@ describe('cli commands', () => { it('rewrites relative markdown destinations when loading a skill', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-links-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') const skillDir = join(pkgDir, 'skills', 'fetching') writeJson(join(pkgDir, 'package.json'), { @@ -2877,6 +2231,7 @@ describe('cli commands', () => { it('prints a skill path without reading skill content', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-path-only-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') writeJson(join(pkgDir, 'package.json'), { name: '@tanstack/query', @@ -2971,6 +2326,7 @@ describe('cli commands', () => { it('rewrites relative markdown destinations in json load content', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-load-json-links-')) tempDirs.push(root) + writeAllowAllConsumer(root) const pkgDir = join(root, 'node_modules', '@tanstack', 'query') const skillDir = join(pkgDir, 'skills', 'fetching') writeJson(join(pkgDir, 'package.json'), { @@ -3010,6 +2366,7 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-load-global-node-modules-'), ) tempDirs.push(root, globalRoot) + writeAllowAllConsumer(root) const globalPkgDir = join(globalRoot, '@tanstack', 'query') writeJson(join(globalPkgDir, 'package.json'), { @@ -3104,6 +2461,11 @@ describe('cli commands', () => { join(realTmpdir, 'intent-cli-load-missing-package-'), ) tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: 'app', + private: true, + intent: { skills: ['@tanstack/query'] }, + }) process.chdir(root) const exitCode = await main(['load', '@tanstack/query#fetching']) diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 213b3535..85bda153 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -617,9 +617,49 @@ describe('consumer install', () => { expect(readInstallState(root)).toEqual({ status: 'missing' }) }) - it('installs selected GitHub hooks at user scope after confirmation', async () => { + it('repairs missing project hooks without re-interviewing or rewriting install state', async () => { + const root = createProject() + const discovered = scanForIntents(root, { scope: 'local' }).packages + await runConsumerInstall({ + discovered, + prompts: createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['claude', 'codex']), + }), + root, + }) + const claudeConfig = join(root, '.claude', 'settings.json') + const codexConfig = join(root, '.codex', 'hooks.json') + const packageBefore = readFileSync(join(root, 'package.json')) + const lockBefore = readFileSync(join(root, 'intent.lock')) + const codexBefore = readFileSync(codexConfig) + unlinkSync(claudeConfig) + const complete = vi.fn() + + await runConsumerInstall({ + discovered, + prompts: createPrompts({ + complete, + selectMethod: () => Promise.reject(new Error('method must not run')), + selectTargets: () => Promise.reject(new Error('targets must not run')), + selectSkills: () => Promise.reject(new Error('skills must not run')), + }), + root, + }) + + expect(existsSync(claudeConfig)).toBe(true) + expect(readFileSync(codexConfig)).toEqual(codexBefore) + expect(readFileSync(join(root, 'package.json'))).toEqual(packageBefore) + expect(readFileSync(join(root, 'intent.lock'))).toEqual(lockBefore) + expect(complete).toHaveBeenCalledWith( + 'Project is up to date. Repaired configured hooks.', + ) + }) + + it('installs and repairs GitHub hooks at user scope after confirmation', async () => { const root = createProject() const copilotHome = join(root, 'copilot-home') + const hookConfig = join(copilotHome, 'hooks', 'hooks.json') const previousCopilotHome = process.env.COPILOT_HOME const confirmUserScopeHooks = vi.fn(() => Promise.resolve(true)) let output = '' @@ -639,6 +679,25 @@ describe('consumer install', () => { prompts, root, }) + expect(confirmUserScopeHooks).toHaveBeenCalledOnce() + expect(existsSync(hookConfig)).toBe(true) + expect(output).toContain('Installed hook agents: copilot.') + unlinkSync(hookConfig) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete(message) { + output = message + }, + confirmUserScopeHooks, + selectMethod: () => Promise.reject(new Error('method must not run')), + selectTargets: () => + Promise.reject(new Error('targets must not run')), + selectSkills: () => Promise.reject(new Error('skills must not run')), + }), + root, + }) } finally { if (previousCopilotHome === undefined) { delete process.env.COPILOT_HOME @@ -647,9 +706,52 @@ describe('consumer install', () => { } } - expect(confirmUserScopeHooks).toHaveBeenCalledOnce() - expect(existsSync(join(copilotHome, 'hooks', 'hooks.json'))).toBe(true) - expect(output).toContain('Installed hook agents: copilot.') + expect(confirmUserScopeHooks).toHaveBeenCalledTimes(2) + expect(existsSync(hookConfig)).toBe(true) + expect(output).toBe('Project is up to date. Repaired configured hooks.') + }) + + it('reports declined GitHub hook repair without claiming a repair', async () => { + const root = createProject() + const copilotHome = join(root, 'copilot-home') + const hookConfig = join(copilotHome, 'hooks', 'hooks.json') + const previousCopilotHome = process.env.COPILOT_HOME + let output = '' + process.env.COPILOT_HOME = copilotHome + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github']), + }), + root, + }) + unlinkSync(hookConfig) + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete(message) { + output = message + }, + confirmUserScopeHooks: () => Promise.resolve(false), + }), + root, + }) + } finally { + if (previousCopilotHome === undefined) { + delete process.env.COPILOT_HOME + } else { + process.env.COPILOT_HOME = previousCopilotHome + } + } + + expect(existsSync(hookConfig)).toBe(false) + expect(output).toBe( + 'Project is up to date. Copilot was skipped because home-directory access was declined.', + ) }) it('skips declined Copilot hooks while installing project hooks', async () => { diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index be902933..0ad39810 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -17,6 +17,7 @@ import { } from '../src/core/index.js' import { computeSkillContentHash } from '../src/core/lockfile/hash.js' import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { EMPTY_NOTE } from '../src/core/source-policy.js' const realTmpdir = realpathSync(tmpdir()) @@ -85,6 +86,11 @@ let originalCwd: string beforeEach(() => { root = realpathSync(mkdtempSync(join(realTmpdir, 'intent-core-test-'))) + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + intent: { skills: ['*'] }, + }) originalCwd = process.cwd() }) @@ -182,7 +188,10 @@ describe('listIntentSkills', () => { writeJson(join(root, 'package.json'), { name: 'test-app', private: true, - intent: { exclude: ['@tanstack/*devtools*'] }, + intent: { + skills: ['*'], + exclude: ['@tanstack/*devtools*'], + }, }) writeInstalledIntentPackage(root, { name: '@tanstack/query', @@ -336,7 +345,11 @@ describe('listIntentSkills', () => { expect(result.packages[0]?.skillCount).toBe(1) }) - it('warns about migration when intent.skills is absent', () => { + it('denies all discovered skills when intent.skills is missing', () => { + writeJson(join(root, 'package.json'), { + name: 'test-app', + private: true, + }) writeInstalledIntentPackage(root, { name: '@tanstack/query', version: '5.0.0', @@ -346,10 +359,9 @@ describe('listIntentSkills', () => { const result = listIntentSkills({ cwd: root }) - expect(result.packages.map((pkg) => pkg.name)).toEqual(['@tanstack/query']) - expect(result.notices).toEqual([ - 'intent.skills is not set — all discovered skill sources are surfaced. A future version will require an explicit intent.skills allowlist; add one to opt in to specific sources.', - ]) + expect(result.packages).toEqual([]) + expect(result.skills).toEqual([]) + expect(result.notices).toEqual([EMPTY_NOTE]) }) it('permits nothing and notes an empty allowlist', () => { @@ -683,6 +695,7 @@ describe('loadIntentSkill', () => { name: 'test-monorepo', private: true, workspaces: ['packages/*'], + intent: { skills: ['*'] }, }) writeJson(join(appDir, 'package.json'), { name: '@test/app', @@ -719,6 +732,7 @@ describe('loadIntentSkill', () => { name: 'test-monorepo', private: true, workspaces: ['packages/*'], + intent: { skills: ['*'] }, }) writeJson(join(appDir, 'package.json'), { name: '@test/app' }) writeJson(join(routerDir, 'package.json'), { @@ -765,6 +779,7 @@ describe('loadIntentSkill', () => { name: 'test-monorepo', private: true, workspaces: ['packages/*'], + intent: { skills: ['*'] }, }) writeJson(join(appDir, 'package.json'), { name: '@test/app', @@ -904,6 +919,7 @@ describe('loadIntentSkill', () => { name: 'test-monorepo', private: true, workspaces: ['packages/*'], + intent: { skills: ['*'] }, }) writeJson(join(appDir, 'package.json'), { name: '@test/app', diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index fc1d3a30..08e1ef8c 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -11,10 +11,7 @@ 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 { runInstallHooks } from '../src/hooks/install.js' import { packageVersionToPin } from '../src/shared/command-runner.js' const tempDirs: Array = [] @@ -312,18 +309,4 @@ describe('hook installer', () => { note: 'mentions intent-copilot-gate.mjs in documentation', }) }) - - it('formats skipped install results', () => { - expect( - formatHookInstallResult({ - agent: 'copilot', - configPath: null, - reason: 'project scope is not supported; use --scope user', - scope: 'project', - status: 'skipped', - }), - ).toBe( - 'Skipped Intent hooks for copilot: project scope is not supported; use --scope user', - ) - }) }) diff --git a/packages/intent/tests/install-plan.test.ts b/packages/intent/tests/install-plan.test.ts index bbab81d5..f6c7e5ec 100644 --- a/packages/intent/tests/install-plan.test.ts +++ b/packages/intent/tests/install-plan.test.ts @@ -38,57 +38,6 @@ 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('leaves a prefixed skill pending when an exact sibling owns its short alias', () => { - expect(() => - buildSkillSelectionPlan([pkg('@scope/ui', ['theme', 'ui/theme'])], { - mode: 'configured-policy', - skills: ['@scope/ui#theme'], - exclude: [], - }), - ).toThrow( - 'Configured policy leaves "@scope/ui#ui/theme" 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' }), diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index 5451aada..cf2da629 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -1,9 +1,8 @@ -import { execFileSync, spawnSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, - readFileSync, readdirSync, realpathSync, rmSync, @@ -13,6 +12,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' +import { runInteractiveInstall } from '../../src/commands/install/command.js' +import type { InstallerPrompter } from '../../src/commands/install/consumer.js' /** * Regression guard for discussion #119: skill discovery in a real Yarn Berry @@ -108,6 +109,7 @@ function scaffoldBerryProject(): string { name: 'berry-corepack-repro', packageManager: `yarn@${YARN_VERSION}`, dependencies: { '@repro/skills-leaf': `file:./${tarball}` }, + intent: { skills: ['@repro/skills-leaf'] }, }) // CI makes Berry installs immutable by default; this fixture creates lockfile fresh. @@ -121,19 +123,18 @@ function scaffoldBerryProject(): string { return dir } -function configureInstall(cwd: string, method: 'hooks' | 'symlink'): void { - const packageJsonPath = join(cwd, 'package.json') - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) - writeJson(packageJsonPath, { - ...packageJson, - intent: { - skills: ['@repro/skills-leaf'], - install: { - method, - targets: method === 'hooks' ? ['claude'] : ['agents'], - }, - }, - }) +function createInstallPrompts(method: 'hooks' | 'symlink'): InstallerPrompter { + return { + advisory: () => {}, + complete: () => {}, + selectMethod: () => Promise.resolve(method), + selectTargets: () => + Promise.resolve(method === 'hooks' ? ['claude'] : ['agents']), + confirmSymlink: () => Promise.resolve(true), + confirmUserScopeHooks: () => Promise.resolve(false), + selectSkills: () => Promise.resolve({ mode: 'all-found' }), + confirmInstall: () => Promise.resolve('install'), + } } describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { @@ -167,34 +168,31 @@ describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { expect(load).toContain('# Core') }, 120_000) - it('installs hooks and locks a zip-backed dependency', () => { + it('installs hooks and locks a zip-backed dependency', async () => { const cwd = scaffoldBerryProject() - configureInstall(cwd, 'hooks') - execFileSync('node', [cliPath, 'install', '--no-input'], { + await runInteractiveInstall({ cwd, - encoding: 'utf8', - timeout: CMD_TIMEOUT_MS, - maxBuffer: 5 * 1024 * 1024, + prompts: createInstallPrompts('hooks'), }) expect(existsSync(join(cwd, 'intent.lock'))).toBe(true) expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(true) }, 120_000) - it('rejects symlink delivery for a zip-backed dependency before writing', () => { + it('rejects symlink delivery for a zip-backed dependency before writing', async () => { const cwd = scaffoldBerryProject() - configureInstall(cwd, 'symlink') - const result = spawnSync('node', [cliPath, 'install', '--no-input'], { + const error = await runInteractiveInstall({ cwd, - encoding: 'utf8', - timeout: CMD_TIMEOUT_MS, - maxBuffer: 5 * 1024 * 1024, - }) - const output = `${result.stdout}\n${result.stderr}` + prompts: createInstallPrompts('symlink'), + }).then( + () => null, + (reason: unknown) => reason, + ) + const output = error instanceof Error ? error.message : String(error) - expect(result.status).not.toBe(0) + expect(error).toBeInstanceOf(Error) expect(output).toMatch(/archive-backed|PnP/i) expect(output).toMatch(/cannot use symlink delivery.*use hooks/i) expect(output).not.toContain('ENOTDIR') diff --git a/packages/intent/tests/integration/scaffold.ts b/packages/intent/tests/integration/scaffold.ts index e58232b2..4639446e 100644 --- a/packages/intent/tests/integration/scaffold.ts +++ b/packages/intent/tests/integration/scaffold.ts @@ -203,6 +203,7 @@ export function scaffoldProject(opts: { private: true, ...(opts.pnp ? { installConfig: { pnp: true } } : {}), dependencies: { [opts.dependency]: '1.0.0' }, + intent: { skills: ['@test-intent/skills-leaf'] }, }) install(root, opts.pm, opts.registryUrl, opts) return { root, cwd: root } @@ -214,6 +215,7 @@ export function scaffoldProject(opts: { private: true, ...(opts.pm !== 'pnpm' ? { workspaces: ['packages/*'] } : {}), ...(opts.pnp ? { installConfig: { pnp: true } } : {}), + intent: { skills: ['@test-intent/skills-leaf'] }, }) if (opts.pm === 'pnpm') { writeFileSync( diff --git a/packages/intent/tests/skill-sources.test.ts b/packages/intent/tests/skill-sources.test.ts index da8dffca..3d643ecd 100644 --- a/packages/intent/tests/skill-sources.test.ts +++ b/packages/intent/tests/skill-sources.test.ts @@ -16,15 +16,15 @@ function expectParseError(value: unknown): SkillSourcesParseError { } describe('parseSkillSources — list-level modes', () => { - it('treats an absent key (undefined) as the migration show-all mode', () => { - expect(parseSkillSources(undefined)).toEqual({ mode: 'absent' }) + it('treats an undefined value as deny-all', () => { + expect(parseSkillSources(undefined)).toEqual({ mode: 'empty' }) }) - it('treats null as absent', () => { - expect(parseSkillSources(null)).toEqual({ mode: 'absent' }) + it('treats null as deny-all', () => { + expect(parseSkillSources(null)).toEqual({ mode: 'empty' }) }) - it('treats an empty array as deny-all (distinct from absent)', () => { + it('treats an empty array as deny-all', () => { expect(parseSkillSources([])).toEqual({ mode: 'empty' }) }) diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 7370ed59..13eba9ca 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -17,7 +17,6 @@ import { import { ALLOW_ALL_NOTICE, EMPTY_NOTE, - MIGRATION_NOTICE, applySourcePolicy, checkLoadAllowed, compileSkillSourcePolicy, @@ -289,7 +288,7 @@ describe('compiled policy explanations', () => { }) it.each([ - [undefined, true], + [undefined, false], [['*'], true], [[], false], ])( @@ -484,13 +483,13 @@ describe('applySourcePolicy — permit-all and empty modes', () => { expect(result.notices).toEqual([ALLOW_ALL_NOTICE]) }) - it('permits every discovered source under absent config with a migration warning', () => { + it('denies every discovered source under undefined config with the empty note', () => { const result = applySourcePolicy( { packages: [pkg('@scope/a', ['x'])] }, { config: config(undefined), excludeMatchers: [] }, ) - expect(names(result.packages)).toEqual(['@scope/a']) - expect(result.notices).toEqual([MIGRATION_NOTICE]) + expect(names(result.packages)).toEqual([]) + expect(result.notices).toEqual([EMPTY_NOTE]) }) it('permits nothing under empty config with a quiet info note', () => { @@ -523,7 +522,7 @@ describe('applySourcePolicy — exclude interaction', () => { expect(names(result.packages)).toEqual(['@scope/a']) }) - it('subtracts an excluded package on top of absent (migration) mode', () => { + it('denies all packages under undefined config even when excludes are present', () => { const result = applySourcePolicy( { packages: [pkg('@scope/a', ['x']), pkg('@scope/bad', ['y'])] }, { @@ -531,8 +530,8 @@ describe('applySourcePolicy — exclude interaction', () => { excludeMatchers: compileExcludePatterns(['@scope/bad']), }, ) - expect(names(result.packages)).toEqual(['@scope/a']) - expect(result.notices).toEqual([MIGRATION_NOTICE]) + expect(names(result.packages)).toEqual([]) + expect(result.notices).toEqual([EMPTY_NOTE]) }) it('treats an unlisted+excluded package as excluded with no unlisted warning', () => { @@ -611,9 +610,9 @@ describe('readSkillSourcesConfig', () => { writeFileSync(filePath, JSON.stringify(data, null, 2)) } - it('returns absent when no package.json declares intent.skills', () => { + it('returns empty when no package.json declares intent.skills', () => { writeJson(join(root, 'package.json'), { name: 'app', private: true }) - expect(readSkillSourcesConfig(root)).toEqual({ mode: 'absent' }) + expect(readSkillSourcesConfig(root)).toEqual({ mode: 'empty' }) }) it('returns empty when intent.skills is an empty array', () => { @@ -664,7 +663,7 @@ describe('readSkillSourcesConfig', () => { }) }) - it('ignores a null intent.skills so it cannot shadow a stricter parent', () => { + it('inherits from the parent when the nearer package omits intent.skills', () => { const appDir = join(root, 'packages', 'app') writeFileSync( join(root, 'pnpm-workspace.yaml'), @@ -677,7 +676,7 @@ describe('readSkillSourcesConfig', () => { }) writeJson(join(appDir, 'package.json'), { name: '@scope/app', - intent: { skills: null }, + intent: { exclude: [] }, }) expect(readSkillSourcesConfig(appDir)).toEqual({ @@ -685,4 +684,23 @@ describe('readSkillSourcesConfig', () => { sources: [{ raw: '@scope/root', id: '@scope/root', kind: 'npm' }], }) }) + + it('treats null intent.skills as deny-all without inheriting from the parent', () => { + const appDir = join(root, 'packages', 'app') + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ) + writeJson(join(root, 'package.json'), { + name: 'monorepo', + private: true, + intent: { skills: ['@scope/root'] }, + }) + writeJson(join(appDir, 'package.json'), { + name: '@scope/app', + intent: { skills: null }, + }) + + expect(readSkillSourcesConfig(appDir)).toEqual({ mode: 'empty' }) + }) }) From 541761af37505bd1994e3135ed1d07cbf4db04aa Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 29 Jul 2026 16:27:05 -0700 Subject: [PATCH 54/56] feat(intent): make skill delivery contributor-local --- .../intent/src/commands/install/config.ts | 147 +--------------- .../intent/src/commands/install/consumer.ts | 88 ++++++---- .../intent/src/commands/install/delivery.ts | 163 ++++++++++++++++++ .../intent/src/commands/install/prompts.ts | 14 +- packages/intent/src/commands/sync/command.ts | 44 +++-- .../intent/src/commands/sync/gitignore.ts | 31 +++- packages/intent/src/commands/sync/plan.ts | 10 +- packages/intent/src/commands/sync/targets.ts | 2 +- packages/intent/tests/cli.test.ts | 42 ++++- .../intent/tests/consumer-install.test.ts | 112 +++++++++--- packages/intent/tests/install-config.test.ts | 99 +++-------- .../intent/tests/install-delivery.test.ts | 79 +++++++++ .../integration/pnp-berry-corepack.test.ts | 1 - packages/intent/tests/sync.test.ts | 21 +-- 14 files changed, 517 insertions(+), 336 deletions(-) create mode 100644 packages/intent/src/commands/install/delivery.ts create mode 100644 packages/intent/tests/install-delivery.test.ts diff --git a/packages/intent/src/commands/install/config.ts b/packages/intent/src/commands/install/config.ts index 008e5bee..a6e52e3d 100644 --- a/packages/intent/src/commands/install/config.ts +++ b/packages/intent/src/commands/install/config.ts @@ -1,89 +1,10 @@ -import { existsSync, statSync } from 'node:fs' -import { join } from 'node:path' import { applyEdits, modify, parse } from 'jsonc-parser' import { compileExcludePatterns } from '../../core/excludes.js' import { parseSkillSources } from '../../core/skill-sources.js' -export type InstallMethod = 'symlink' | 'hooks' -export type InstallTarget = - | 'agents' - | 'github' - | 'vscode' - | 'cursor' - | 'codex' - | 'claude' - -export interface IntentInstallPreferences { - targets: Array - method: InstallMethod -} - export interface IntentConsumerConfig { skills: Array exclude: Array - install?: IntentInstallPreferences -} - -export const INSTALL_TARGETS: ReadonlyArray<{ - id: InstallTarget - label: string -}> = [ - { id: 'agents', label: 'Shared .agents directory' }, - { id: 'github', label: 'GitHub Copilot' }, - { id: 'vscode', label: 'VS Code' }, - { id: 'cursor', label: 'Cursor' }, - { id: 'codex', label: 'Codex' }, - { id: 'claude', label: 'Claude Code' }, -] - -const INSTALL_METHODS: Readonly< - Record> -> = { - symlink: new Set(INSTALL_TARGETS.map((target) => target.id)), - hooks: new Set(['github', 'codex', 'claude']), -} - -export function installTargetsForMethod( - method: InstallMethod, -): typeof INSTALL_TARGETS { - return INSTALL_TARGETS.filter((target) => - INSTALL_METHODS[method].has(target.id), - ) -} - -function isDirectory(root: string, path: string): boolean { - const target = join(root, path) - return existsSync(target) && statSync(target).isDirectory() -} - -export function detectInstallTargets(root: string): Array { - return INSTALL_TARGETS.flatMap((target) => { - switch (target.id) { - case 'agents': - return isDirectory(root, '.agents') || - existsSync(join(root, 'AGENTS.md')) - ? [target.id] - : [] - case 'github': - return existsSync(join(root, '.github/copilot-instructions.md')) - ? [target.id] - : [] - case 'vscode': - return isDirectory(root, '.vscode') ? [target.id] : [] - case 'cursor': - return isDirectory(root, '.cursor') || - existsSync(join(root, '.cursorrules')) - ? [target.id] - : [] - case 'codex': - return isDirectory(root, '.codex') ? [target.id] : [] - case 'claude': - return isDirectory(root, '.claude') || - existsSync(join(root, 'CLAUDE.md')) - ? [target.id] - : [] - } - }) } function isRecord(value: unknown): value is Record { @@ -107,40 +28,6 @@ function validateExcludes(excludes: Array): void { 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, { @@ -176,13 +63,7 @@ export function readIntentConsumerConfig(text: string): IntentConsumerConfig { : requireStringArray(intent.exclude, 'intent.exclude') parseSkillSources(skills) validateExcludes(exclude) - return { - skills, - exclude, - ...(intent.install === undefined - ? {} - : { install: validateInstall(intent.install) }), - } + return { skills, exclude } } function equalsConfig( @@ -191,8 +72,7 @@ function equalsConfig( ): boolean { return ( equalsArray(left.skills, right.skills) && - equalsArray(left.exclude, right.exclude) && - equalsInstall(left.install, right.install) + equalsArray(left.exclude, right.exclude) ) } @@ -206,16 +86,6 @@ function equalsArray( ) } -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 @@ -246,16 +116,17 @@ export function updateIntentConsumerConfigText( requested: IntentConsumerConfig, ): string { const existing = readIntentConsumerConfig(text) + const intent = parsePackageJson(text).intent + const hasLegacyInstall = isRecord(intent) && intent.install !== undefined 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 + if (equalsConfig(existing, normalized) && !hasLegacyInstall) { + return text + } const bom = text.startsWith('\ufeff') ? '\ufeff' : '' const options = formattingOptions(text) @@ -276,11 +147,11 @@ export function updateIntentConsumerConfigText( options, ) } - if (!equalsInstall(existing.install, normalized.install)) { + if (hasLegacyInstall) { updated = applyModification( updated, ['intent', 'install'], - normalized.install, + undefined, options, ) } diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index eb143989..0f2a6aa5 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -18,36 +18,35 @@ import { wireIntentSyncPrepare } from '../sync/prepare.js' import { readInstallStateForLinks } from '../sync/state.js' import { toProjectRelativePath } from '../sync/targets.js' import { - INSTALL_TARGETS, - detectInstallTargets, hasIntentDevDependency, readIntentConsumerConfig, updateIntentConsumerConfigText, } from './config.js' +import { + INSTALL_TARGETS, + detectInstallTargets, + readIntentDeliveryConfig, + writeIntentDeliveryConfig, +} from './delivery.js' import { buildInstallDeltaInventory, buildSkillSelectionPlan, summarizeInstallDeltaInventory, } from './plan.js' +import type { IntentConsumerConfig } from './config.js' import type { InstallMethod, InstallTarget, - IntentConsumerConfig, - IntentInstallPreferences, -} from './config.js' + IntentDeliveryConfig, +} from './delivery.js' import type { SkillSelection } from './plan.js' import type { HookAgent, HookInstallScope } from '../../hooks/types.js' import type { ReadFs } from '../../shared/utils.js' import type { IntentPackage } from '../../shared/types.js' -interface ConsumerInstallConfig extends IntentConsumerConfig { - install: IntentInstallPreferences -} - export type InstallConfirmation = 'install' | 'back' | null export interface InstallerPrompter { - advisory: (message: string) => void complete: (message: string) => void selectMethod: () => Promise selectTargets: ( @@ -60,7 +59,8 @@ export interface InstallerPrompter { discovered: ReadonlyArray, ) => Promise confirmInstall: (confirmation: { - config: ConsumerInstallConfig + config: IntentConsumerConfig + delivery: IntentDeliveryConfig skillCount: number }) => Promise } @@ -137,9 +137,9 @@ export async function runConsumerInstall({ const packageJson = readFileSync(packageJsonPath, 'utf8') const intentDevDependency = hasIntentDevDependency(packageJson) const existingConfig = readIntentConsumerConfig(packageJson) - const install = dryRun ? undefined : existingConfig.install - if (install) { - const existingLock = readIntentLockfile(join(root, 'intent.lock')) + const existingLock = readIntentLockfile(join(root, 'intent.lock')) + const delivery = dryRun ? null : readIntentDeliveryConfig(root) + if (delivery) { const inventory = buildInstallDeltaInventory( discovered, buildCurrentLockfileSources(discovered, readFs), @@ -153,24 +153,24 @@ export async function runConsumerInstall({ const hasChanges = newDependencies > 0 || newSkills > 0 || changed > 0 || summary.removed > 0 if (!hasChanges && existingLock.status === 'found') { - if (install.method === 'symlink') { + if (delivery.method === 'symlink') { prompts.complete('Project is up to date.') return } const userScopeHooksAccepted = await confirmUserScopeHooks( - install.targets, + delivery.targets, prompts, ) if (userScopeHooksAccepted === null) return const installedAgents = installConfiguredHooks( root, - install.targets, + delivery.targets, userScopeHooksAccepted, ) const repairedHooks = installedAgents.length > 0 ? ' Repaired configured hooks.' : '' const skippedCopilot = - install.targets.includes('github') && !userScopeHooksAccepted + delivery.targets.includes('github') && !userScopeHooksAccepted ? ' Copilot was skipped because home-directory access was declined.' : '' prompts.complete( @@ -183,13 +183,13 @@ export async function runConsumerInstall({ ) } for (;;) { - const method = install ? install.method : await prompts.selectMethod() + const method = delivery ? delivery.method : await prompts.selectMethod() if (!method) return - const targets = install - ? install.targets + const targets = delivery + ? delivery.targets : await prompts.selectTargets(method, detectInstallTargets(root)) if (!targets || targets.length === 0) return - if (!install && method === 'symlink') { + if (!delivery && method === 'symlink') { const symlinkAccepted = await prompts.confirmSymlink() if (!symlinkAccepted) return } @@ -197,19 +197,30 @@ export async function runConsumerInstall({ prompts.complete('No intent-enabled skills found.') return } - const selection = await prompts.selectSkills(discovered) - if (!selection) return - const plan = buildSkillSelectionPlan(discovered, selection) + const hasCommittedTrust = + existingLock.status === 'found' && + (existingConfig.skills.length > 0 || existingConfig.exclude.length > 0) + const reusingCommittedTrust = !delivery && hasCommittedTrust + const selection = reusingCommittedTrust + ? null + : await prompts.selectSkills(discovered) + if (!reusingCommittedTrust && !selection) return + const plan = selection + ? buildSkillSelectionPlan(discovered, selection) + : null + const config = plan + ? { skills: plan.skills, exclude: plan.exclude } + : existingConfig + const deliveryConfig = { method, targets } const installation = { - config: { - skills: plan.skills, - exclude: plan.exclude, - install: { method, targets }, - } satisfies ConsumerInstallConfig, - skillCount: plan.packages.reduce( + config, + delivery: deliveryConfig, + skillCount: (plan?.packages ?? discovered).reduce( (count, pkg) => count + - pkg.skills.filter((skill) => skill.status === 'enabled').length, + pkg.skills.filter( + (skill) => !('status' in skill) || skill.status === 'enabled', + ).length, 0, ), } @@ -244,6 +255,7 @@ export async function runConsumerInstall({ lock: { status: 'found', lockfile }, packages: policy.packages, root, + targets, }) if (hasNonNativeLinkSource(linkPlan.expected, readFs)) { throw new Error( @@ -287,13 +299,13 @@ export async function runConsumerInstall({ method === 'hooks' ? await confirmUserScopeHooks(targets, prompts) : false if (userScopeHooksAccepted === null) return - 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 (updatedPackageJson !== packageJson) { + writeTextFileAtomic(packageJsonPath, updatedPackageJson) + } + if (!hasCommittedTrust) { + writeIntentLockfile(join(root, 'intent.lock'), lockfile) } + writeIntentDeliveryConfig(root, deliveryConfig) if (method === 'symlink') { await runSyncCommand({ cwd: root }, { review: 'reminder' }) prompts.complete( diff --git a/packages/intent/src/commands/install/delivery.ts b/packages/intent/src/commands/install/delivery.ts new file mode 100644 index 00000000..069b2e41 --- /dev/null +++ b/packages/intent/src/commands/install/delivery.ts @@ -0,0 +1,163 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' +import { writeIntentGitExclude } from '../sync/gitignore.js' + +export const DELIVERY_CONFIG_PATH = '.intent/delivery.json' + +export type InstallMethod = 'symlink' | 'hooks' +export type InstallTarget = + | 'agents' + | 'github' + | 'vscode' + | 'cursor' + | 'codex' + | 'claude' + +export interface IntentDeliveryConfig { + method: InstallMethod + targets: Array +} + +export const INSTALL_TARGETS: ReadonlyArray<{ + id: InstallTarget + label: string +}> = [ + { id: 'agents', label: 'Shared .agents directory' }, + { id: 'github', label: 'GitHub Copilot' }, + { id: 'vscode', label: 'VS Code' }, + { id: 'cursor', label: 'Cursor' }, + { id: 'codex', label: 'Codex' }, + { id: 'claude', label: 'Claude Code' }, +] + +const INSTALL_METHODS: Readonly< + Record> +> = { + symlink: new Set(INSTALL_TARGETS.map((target) => target.id)), + hooks: new Set(['github', 'codex', 'claude']), +} + +export function installTargetsForMethod( + method: InstallMethod, +): typeof INSTALL_TARGETS { + return INSTALL_TARGETS.filter((target) => + INSTALL_METHODS[method].has(target.id), + ) +} + +function isDirectory(root: string, path: string): boolean { + const target = join(root, path) + return existsSync(target) && statSync(target).isDirectory() +} + +export function detectInstallTargets(root: string): Array { + return INSTALL_TARGETS.flatMap((target) => { + switch (target.id) { + case 'agents': + return isDirectory(root, '.agents') || + existsSync(join(root, 'AGENTS.md')) + ? [target.id] + : [] + case 'github': + return existsSync(join(root, '.github/copilot-instructions.md')) + ? [target.id] + : [] + case 'vscode': + return isDirectory(root, '.vscode') ? [target.id] : [] + case 'cursor': + return isDirectory(root, '.cursor') || + existsSync(join(root, '.cursorrules')) + ? [target.id] + : [] + case 'codex': + return isDirectory(root, '.codex') ? [target.id] : [] + case 'claude': + return isDirectory(root, '.claude') || + existsSync(join(root, 'CLAUDE.md')) + ? [target.id] + : [] + } + }) +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function validateIntentDeliveryConfig(value: unknown): IntentDeliveryConfig { + if (!isRecord(value)) throw new Error('Local delivery must be an object.') + if (Object.keys(value).sort().join(',') !== 'method,targets') { + throw new Error('Local delivery must contain exactly method and targets.') + } + if (typeof value.method !== 'string' || !(value.method in INSTALL_METHODS)) { + throw new Error(`Unknown install method "${String(value.method)}".`) + } + if (!Array.isArray(value.targets) || value.targets.length === 0) { + throw new Error('Local delivery targets must be a non-empty array.') + } + const method = value.method as InstallMethod + const targets: Array = [] + const seen = new Set() + for (const target of value.targets) { + if ( + typeof target !== 'string' || + !INSTALL_TARGETS.some((candidate) => candidate.id === target) + ) { + throw new Error(`Unknown install target "${String(target)}".`) + } + if (seen.has(target)) { + throw new Error(`Duplicate install target "${target}".`) + } + if (!INSTALL_METHODS[method].has(target as InstallTarget)) { + throw new Error( + `Install method "${method}" is not supported for "${target}".`, + ) + } + seen.add(target) + targets.push(target as InstallTarget) + } + return { method, targets } +} + +function parseIntentDeliveryConfig(text: string): IntentDeliveryConfig { + try { + return validateIntentDeliveryConfig(JSON.parse(text)) + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid local delivery JSON: ${error.message}`) + } + throw error + } +} + +function serializeIntentDeliveryConfig(config: IntentDeliveryConfig): string { + return `${JSON.stringify(validateIntentDeliveryConfig(config), null, 2)}\n` +} + +export function readIntentDeliveryConfig( + root: string, +): IntentDeliveryConfig | null { + const path = join(root, DELIVERY_CONFIG_PATH) + if (!existsSync(path)) return null + try { + return parseIntentDeliveryConfig(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `Invalid local delivery at ${DELIVERY_CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) + } +} + +export function writeIntentDeliveryConfig( + root: string, + config: IntentDeliveryConfig, +): boolean { + const path = join(root, DELIVERY_CONFIG_PATH) + const content = serializeIntentDeliveryConfig(config) + if (existsSync(path) && readFileSync(path, 'utf8') === content) return false + writeIntentGitExclude(root, [DELIVERY_CONFIG_PATH]) + writeTextFileAtomic(path, content) + return true +} diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index d0c15cf1..d7d3d938 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -9,10 +9,10 @@ import { outro, select, } from '@clack/prompts' -import { installTargetsForMethod } from './config.js' +import { installTargetsForMethod } from './delivery.js' import { skillSelectionId } from './plan.js' import type { InstallConfirmation, InstallerPrompter } from './consumer.js' -import type { InstallMethod, InstallTarget } from './config.js' +import type { InstallMethod, InstallTarget } from './delivery.js' import type { SkillSelection } from './plan.js' import type { IntentPackage } from '../../shared/types.js' @@ -82,9 +82,6 @@ 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) }, @@ -149,10 +146,13 @@ export function createClackInstallerPrompter(): InstallerPrompter { ): Promise { return selectClackSkills(discovered) }, - async confirmInstall({ config, skillCount }): Promise { + async confirmInstall({ + delivery, + skillCount, + }): Promise { return cancelled( await select>({ - message: `Install ${skillCount} ${skillCount === 1 ? 'skill' : 'skills'} using ${config.install.method}?`, + message: `Install ${skillCount} ${skillCount === 1 ? 'skill' : 'skills'} using ${delivery.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 aa09d811..f0190c52 100644 --- a/packages/intent/src/commands/sync/command.ts +++ b/packages/intent/src/commands/sync/command.ts @@ -1,4 +1,4 @@ -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' @@ -21,12 +21,16 @@ import { readIntentConsumerConfig, updateIntentConsumerConfigText, } from '../install/config.js' +import { + DELIVERY_CONFIG_PATH, + readIntentDeliveryConfig, +} from '../install/delivery.js' import { buildSkillSelectionPlan, skillSelectionId, summarizeInstallDeltaInventory, } from '../install/plan.js' -import { updateIntentGitignore } from './gitignore.js' +import { writeIntentGitExclude } from './gitignore.js' import { hasNonNativeLinkSource, reconcileManagedLinks } from './links.js' import { buildSyncLinkPlan } from './plan.js' import { @@ -99,23 +103,15 @@ function printReminder( console.log(`${title}:\n\n${packages}\n\n${action}`) } -function writeGitignore(root: string, paths: Array): boolean { - const path = join(root, '.gitignore') - const before = existsSync(path) ? readFileSync(path, 'utf8') : null - const after = updateIntentGitignore(before, paths) - if (before === after) return false - writeFileSync(path, after, 'utf8') - return true -} - function writeManagedLinkState(root: string, links: LinkReconciliation): void { const entries = links.entries.map((entry) => ({ ...entry, path: toProjectRelativePath(root, entry.path), })) writeInstallState(root, { version: 1, entries }) - writeGitignore(root, [ + writeIntentGitExclude(root, [ ...entries.map((entry) => entry.path), + DELIVERY_CONFIG_PATH, INSTALL_STATE_PATH, ]) } @@ -196,6 +192,7 @@ function shouldReviewInteractively( async function reviewNewDependencies({ config, + deliveryTargets, discovered, lock, packages, @@ -205,6 +202,9 @@ async function reviewNewDependencies({ root, }: { config: ReturnType + deliveryTargets: NonNullable< + ReturnType + >['targets'] discovered: Array lock: Extract, { status: 'found' }> packages: Array @@ -386,6 +386,7 @@ async function reviewNewDependencies({ lock: { status: 'found', lockfile: prospectiveLock }, packages: policy.packages, root, + targets: deliveryTargets, }).expected if (hasNonNativeLinkSource(expected, readFs)) { fail( @@ -436,23 +437,30 @@ export async function runSyncCommand( ): Promise { const context = resolveProjectContext({ cwd: options.cwd ?? process.cwd() }) const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd + const delivery = readIntentDeliveryConfig(root) + if (!delivery) { + console.error( + 'Intent skill delivery is not configured for this checkout. Run `intent install` to configure it.', + ) + return + } const packageJsonPath = join(root, 'package.json') if (!existsSync(packageJsonPath)) { fail( - 'Intent sync requires intent.install configuration and intent.lock. Run `intent install` first.', + 'Intent sync requires package policy and intent.lock. Run `intent install` first.', ) } const packageJson = readFileSync(packageJsonPath, 'utf8') const config = readIntentConsumerConfig(packageJson) const lock = readIntentLockfile(join(root, 'intent.lock')) - if (!config.install || lock.status !== 'found') { + if (lock.status !== 'found') { fail( - 'Intent sync requires intent.install configuration and intent.lock. Run `intent install` first.', + 'Intent sync requires package policy and intent.lock. Run `intent install` first.', ) } - if (config.install.method !== 'symlink') { + if (delivery.method !== 'symlink') { fail( - `Intent sync adapter for method "${config.install.method}" is not implemented yet.`, + 'Hook delivery is repaired by `intent install`; run it to repair configured hooks.', ) } const stateResult = readInstallStateForLinks(root) @@ -482,6 +490,7 @@ export async function runSyncCommand( lock, packages: discovery.policy.packages, root, + targets: delivery.targets, }) } catch (error) { if (stateResult.status === 'missing') throw error @@ -622,6 +631,7 @@ export async function runSyncCommand( (await import('./prompts.js')).createClackSyncReviewPrompter() await reviewNewDependencies({ config, + deliveryTargets: delivery.targets, discovered, lock, packages, diff --git a/packages/intent/src/commands/sync/gitignore.ts b/packages/intent/src/commands/sync/gitignore.ts index 1dab932c..3e8f2ff2 100644 --- a/packages/intent/src/commands/sync/gitignore.ts +++ b/packages/intent/src/commands/sync/gitignore.ts @@ -1,7 +1,12 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' + const START = '# intent skill links:start' const END = '# intent skill links:end' -export function updateIntentGitignore( +export function updateIntentGitExclude( text: string | null, paths: ReadonlyArray, ): string { @@ -17,3 +22,27 @@ export function updateIntentGitignore( const separator = prefix.endsWith('\n') ? '' : eol return `${prefix}${separator}${block}${eol}` } + +export function writeIntentGitExclude( + root: string, + paths: ReadonlyArray, +): boolean { + let gitPath: string + try { + gitPath = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return false + } + if (gitPath === '') return false + + const path = resolve(root, gitPath) + const before = existsSync(path) ? readFileSync(path, 'utf8') : null + const after = updateIntentGitExclude(before, paths) + if (before === after) return false + writeTextFileAtomic(path, after) + return true +} diff --git a/packages/intent/src/commands/sync/plan.ts b/packages/intent/src/commands/sync/plan.ts index 3bbc7d6e..059007e6 100644 --- a/packages/intent/src/commands/sync/plan.ts +++ b/packages/intent/src/commands/sync/plan.ts @@ -7,6 +7,7 @@ import { toProjectRelativePath, } from './targets.js' import type { IntentConsumerConfig } from '../install/config.js' +import type { InstallTarget } from '../install/delivery.js' import type { InstallDeltaInventory } from '../install/plan.js' import type { ExpectedLink } from './links.js' import type { @@ -26,6 +27,7 @@ export function buildSyncLinkPlan({ lock, packages, root, + targets, }: { config: IntentConsumerConfig currentSources: ReadonlyArray @@ -33,12 +35,11 @@ export function buildSyncLinkPlan({ lock: ReadIntentLockfileResult packages: ReadonlyArray root: string + targets: ReadonlyArray }): { expected: Array inventory: InstallDeltaInventory } { - if (!config.install) - throw new Error('Intent install configuration is missing.') const inventory = buildInstallDeltaInventory( discovered, currentSources, @@ -79,10 +80,7 @@ export function buildSyncLinkPlan({ return sourceSkill ? [{ pkg, skill: sourceSkill, source }] : [] }) }) - const targetDirectories = resolveSyncTargetDirectories( - root, - config.install.targets, - ) + const targetDirectories = resolveSyncTargetDirectories(root, targets) const expected = accepted.flatMap(({ pkg, skill, source }) => { const identity = `${sourceIdentityKey({ kind: pkg.kind, id: pkg.name })}\0${skill.name}` const alias = aliases.get(identity) diff --git a/packages/intent/src/commands/sync/targets.ts b/packages/intent/src/commands/sync/targets.ts index 12290abc..7f223e5c 100644 --- a/packages/intent/src/commands/sync/targets.ts +++ b/packages/intent/src/commands/sync/targets.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { join, relative, resolve, sep } from 'node:path' -import type { InstallTarget } from '../install/config.js' +import type { InstallTarget } from '../install/delivery.js' export interface SyncTargetDirectory { id: InstallTarget diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 02215e7e..35b2533d 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { existsSync, lstatSync, @@ -10,9 +11,10 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { writeIntentDeliveryConfig } from '../src/commands/install/delivery.js' import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' import { writeIntentLockfile } from '../src/core/lockfile/lockfile.js' import { scanForIntents } from '../src/discovery/scanner.js' @@ -340,7 +342,7 @@ describe('cli commands', () => { expect(getHelpOutput()).not.toContain('--agents') }) - it('tells consumers to install before syncing without configuration or a lockfile', async () => { + it('does nothing without local delivery configuration', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-sync-unconfigured-')) tempDirs.push(root) writeJson(join(root, 'package.json'), { name: 'app', private: true }) @@ -348,10 +350,12 @@ describe('cli commands', () => { const exitCode = await main(['sync']) - expect(exitCode).toBe(1) + expect(exitCode).toBe(0) + expect(errorSpy).toHaveBeenCalledOnce() expect(errorSpy).toHaveBeenCalledWith( - 'Intent sync requires intent.install configuration and intent.lock. Run `intent install` first.', + 'Intent skill delivery is not configured for this checkout. Run `intent install` to configure it.', ) + expect(readdirSync(root)).toEqual(['package.json']) }) it('syncs verified links and reports changed, pending, removed, and dry-run work', async () => { @@ -362,7 +366,6 @@ describe('cli commands', () => { private: true, intent: { skills: ['verified'], - install: { method: 'symlink', targets: ['github', 'vscode'] }, }, }) writeInstalledIntentPackage(root, { @@ -371,6 +374,11 @@ describe('cli commands', () => { skillName: 'core', description: 'Verified skill', }) + execFileSync('git', ['init', '--quiet'], { cwd: root }) + writeIntentDeliveryConfig(root, { + method: 'symlink', + targets: ['github', 'vscode'], + }) process.chdir(root) const discovered = scanForIntents(root, { scope: 'local' }).packages writeIntentLock(root, discovered) @@ -382,8 +390,18 @@ describe('cli commands', () => { join(root, '.intent', 'install-state.json'), 'utf8', ) - const gitignore = readFileSync(join(root, '.gitignore'), 'utf8') - expect(gitignore).toContain('.github/skills/npm-verified-core') + const excludePath = resolve( + root, + execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { + cwd: root, + encoding: 'utf8', + }).trim(), + ) + const localExclude = readFileSync(excludePath, 'utf8') + expect(localExclude).toContain('.intent/delivery.json') + expect(localExclude).toContain('.intent/install-state.json') + expect(localExclude).toContain('.github/skills/npm-verified-core') + expect(existsSync(join(root, '.gitignore'))).toBe(false) expect(await main(['sync'])).toBe(0) expect( readFileSync(join(root, '.intent', 'install-state.json'), 'utf8'), @@ -459,7 +477,6 @@ describe('cli commands', () => { private: true, intent: { skills: ['dry-package'], - install: { method: 'symlink', targets: ['agents'] }, }, }) writeInstalledIntentPackage(dryRoot, { @@ -468,6 +485,10 @@ describe('cli commands', () => { skillName: 'core', description: 'Dry skill', }) + writeIntentDeliveryConfig(dryRoot, { + method: 'symlink', + targets: ['agents'], + }) const dryDiscovered = scanForIntents(dryRoot, { scope: 'local' }).packages writeIntentLock(dryRoot, dryDiscovered) process.chdir(dryRoot) @@ -488,7 +509,6 @@ describe('cli commands', () => { private: true, intent: { skills: ['verified#core'], - install: { method: 'symlink', targets: ['agents'] }, }, }) writeInstalledIntentPackage(root, { @@ -497,6 +517,10 @@ describe('cli commands', () => { skillName: 'core', description: 'Verified skill', }) + writeIntentDeliveryConfig(root, { + method: 'symlink', + targets: ['agents'], + }) process.chdir(root) const discovered = scanForIntents(root, { scope: 'local' }).packages diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 85bda153..c8a8470a 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -14,18 +14,23 @@ 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 { readIntentConsumerConfig } from '../src/commands/install/config.js' +import { runConsumerInstall } from '../src/commands/install/consumer.js' import { detectInstallTargets, - readIntentConsumerConfig, -} from '../src/commands/install/config.js' -import { runConsumerInstall } from '../src/commands/install/consumer.js' + readIntentDeliveryConfig, +} from '../src/commands/install/delivery.js' import { createClackInstallerPrompter, groupSkillOptions, } from '../src/commands/install/prompts.js' import { runSyncCommand } from '../src/commands/sync/command.js' import { readInstallState } from '../src/commands/sync/state.js' -import { readIntentLockfile } from '../src/core/lockfile/lockfile.js' +import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' +import { + readIntentLockfile, + writeIntentLockfile, +} 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' @@ -112,7 +117,6 @@ function createPrompts( overrides: Partial = {}, ): InstallerPrompter { return { - advisory: () => {}, complete: () => {}, selectMethod: () => Promise.resolve('symlink'), selectTargets: () => Promise.resolve(['agents']), @@ -245,7 +249,61 @@ describe('consumer install', () => { expect(selectSkills).toHaveBeenCalledOnce() }) - it('reports an already-configured project as up to date without interviewing', async () => { + it('moves legacy delivery local without changing committed trust or lock', async () => { + const root = createProject() + const discovered = scanForIntents(root, { scope: 'local' }).packages + const packageJsonPath = join(root, 'package.json') + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + packageJson.description = 'preserve me' + packageJson.intent = { + skills: ['@tanstack/query'], + exclude: [], + install: { method: 'symlink', targets: ['agents'] }, + } + writeJson(packageJsonPath, packageJson) + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: buildCurrentLockfileSources(discovered), + }) + const lockBefore = readFileSync(join(root, 'intent.lock')) + const selectMethod = vi.fn(() => Promise.resolve('symlink' as const)) + const selectTargets = vi.fn(() => + Promise.resolve>(['agents']), + ) + const selectSkills = vi.fn(() => + Promise.reject(new Error('current trust must not reselect skills')), + ) + + await runConsumerInstall({ + discovered, + prompts: createPrompts({ selectMethod, selectTargets, selectSkills }), + root, + }) + + expect(selectMethod).toHaveBeenCalledOnce() + expect(selectTargets).toHaveBeenCalledOnce() + expect(selectSkills).not.toHaveBeenCalled() + expect(JSON.parse(readFileSync(packageJsonPath, 'utf8'))).toEqual({ + name: 'app', + private: true, + devDependencies: { '@tanstack/intent': '0.4.0' }, + description: 'preserve me', + intent: { skills: ['@tanstack/query'], exclude: [] }, + scripts: { prepare: 'intent sync' }, + }) + expect(readFileSync(join(root, 'intent.lock'))).toEqual(lockBefore) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'symlink', + targets: ['agents'], + }) + expect( + lstatSync( + join(root, '.agents', 'skills', 'npm-tanstack-query-fetching'), + ).isSymbolicLink(), + ).toBe(true) + }) + + it('wires prepare once and 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 }) @@ -272,6 +330,9 @@ describe('consumer install', () => { expect(selectMethod).not.toHaveBeenCalled() expect(selectTargets).not.toHaveBeenCalled() expect(selectSkills).not.toHaveBeenCalled() + expect( + JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, + ).toEqual({ prepare: 'intent sync' }) }) it('reports a new skill and enters review without re-interviewing delivery', async () => { @@ -326,8 +387,7 @@ describe('consumer install', () => { it('installs confirmed skills with policy, lock state, and managed links', async () => { const root = createProject() - const advisory = vi.fn() - const prompts = createPrompts({ advisory }) + const prompts = createPrompts() await runInteractiveInstall({ cwd: root, @@ -341,11 +401,11 @@ describe('consumer install', () => { ).toEqual({ skills: ['@tanstack/query'], exclude: [], - install: { method: 'symlink', targets: ['agents'] }, }) - expect( - JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, - ).toEqual({ prepare: 'intent sync' }) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'symlink', + targets: ['agents'], + }) expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ status: 'found', lockfile: { @@ -372,7 +432,6 @@ describe('consumer install', () => { }, }) expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) - expect(advisory).not.toHaveBeenCalled() }) it('preserves malformed install state and managed links when sync fails', async () => { @@ -390,6 +449,7 @@ describe('consumer install', () => { 'skills', 'npm-tanstack-query-fetching', ) + writeFileSync(gitignorePath, 'shared\n', 'utf8') writeFileSync(statePath, '{malformed ownership state\n', 'utf8') const stateBefore = readFileSync(statePath) const gitignoreBefore = readFileSync(gitignorePath) @@ -420,6 +480,7 @@ describe('consumer install', () => { 'skills', 'npm-tanstack-query-fetching', ) + writeFileSync(gitignorePath, 'shared\n', 'utf8') unlinkSync(statePath) const gitignoreBefore = readFileSync(gitignorePath) @@ -547,6 +608,7 @@ describe('consumer install', () => { 'skills', 'npm-tanstack-query-fetching', ) + writeFileSync(gitignorePath, 'shared\n', 'utf8') unlinkSync(statePath) const gitignoreBefore = readFileSync(gitignorePath) const outside = join(root, 'outside-content') @@ -595,7 +657,10 @@ describe('consumer install', () => { ).toEqual({ skills: ['@tanstack/query'], exclude: [], - install: { method: 'hooks', targets: ['claude', 'codex'] }, + }) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'hooks', + targets: ['claude', 'codex'], }) expect( JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, @@ -923,8 +988,7 @@ describe('consumer install', () => { it('installs without Intent as a project development dependency', async () => { const root = createProject() writeJson(join(root, 'package.json'), { name: 'app', private: true }) - const advisory = vi.fn() - const prompts = createPrompts({ advisory }) + const prompts = createPrompts() await runConsumerInstall({ discovered: scanForIntents(root, { scope: 'local' }).packages, @@ -939,7 +1003,10 @@ describe('consumer install', () => { ).toEqual({ skills: ['@tanstack/query'], exclude: [], - install: { method: 'symlink', targets: ['agents'] }, + }) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'symlink', + targets: ['agents'], }) expect( JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts, @@ -955,9 +1022,6 @@ describe('consumer install', () => { ], }, }) - expect(advisory).toHaveBeenCalledWith( - 'Skills will not re-sync automatically because the prepare script was not wired. intent.lock records the accepted skill baseline, but nothing will check it automatically. Add @tanstack/intent as a devDependency to enable both.', - ) }) it('stops without skill selection when discovery is empty', async () => { @@ -1031,10 +1095,10 @@ describe('consumer install', () => { root, }) - expect( - readIntentConsumerConfig(readFileSync(join(root, 'package.json'), 'utf8')) - .install, - ).toEqual({ method: 'symlink', targets: ['cursor'] }) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'symlink', + targets: ['cursor'], + }) expect( existsSync( join(root, '.cursor', 'skills', 'npm-tanstack-query-fetching'), diff --git a/packages/intent/tests/install-config.test.ts b/packages/intent/tests/install-config.test.ts index eb07425b..39a69577 100644 --- a/packages/intent/tests/install-config.test.ts +++ b/packages/intent/tests/install-config.test.ts @@ -1,92 +1,17 @@ import { describe, expect, it } from 'vitest' import { - INSTALL_TARGETS, - installTargetsForMethod, readIntentConsumerConfig, updateIntentConsumerConfigText, } from '../src/commands/install/config.js' -import type { - InstallMethod, - IntentConsumerConfig, - IntentInstallPreferences, -} from '../src/commands/install/config.js' +import type { IntentConsumerConfig } from '../src/commands/install/config.js' describe('installer configuration', () => { - it('provides neutral install targets without detected or selected state', () => { - expect(INSTALL_TARGETS).toEqual([ - { id: 'agents', label: 'Shared .agents directory' }, - { id: 'github', label: 'GitHub Copilot' }, - { id: 'vscode', label: 'VS Code' }, - { id: 'cursor', label: 'Cursor' }, - { id: 'codex', label: 'Codex' }, - { id: 'claude', label: 'Claude Code' }, - ]) - }) - - it('filters targets by the selected delivery method', () => { - expect( - installTargetsForMethod('symlink').map((target) => target.id), - ).toEqual(['agents', 'github', 'vscode', 'cursor', 'codex', 'claude']) - expect(installTargetsForMethod('hooks').map((target) => target.id)).toEqual( - ['github', 'codex', 'claude'], - ) - }) - - it('rejects an install method unsupported by a selected target', () => { - const preferences: IntentInstallPreferences = { - method: 'hooks', - targets: ['github'], - } - const method: InstallMethod = preferences.method - expect(method).toBe('hooks') - expect(() => - readIntentConsumerConfig( - '{ "intent": { "install": { "method": "hooks", "targets": ["vscode"] } } }', - ), - ).toThrow('not supported') - }) - - it('rejects duplicate install targets', () => { - expect(() => - readIntentConsumerConfig( - '{ "intent": { "install": { "method": "symlink", "targets": ["agents", "agents"] } } }', - ), - ).toThrow('Duplicate') - }) - - it('rejects unknown install fields', () => { - expect(() => - readIntentConsumerConfig( - '{ "intent": { "install": { "method": "symlink", "targets": [], "extra": true } } }', - ), - ).toThrow('Unknown') - }) - - it('rejects unknown targets, methods, and wrong target types', () => { - expect(() => - readIntentConsumerConfig( - '{ "intent": { "install": { "method": "symlink", "targets": ["unknown"] } } }', - ), - ).toThrow('Unknown install target') - expect(() => - readIntentConsumerConfig( - '{ "intent": { "install": { "method": "unknown", "targets": ["github"] } } }', - ), - ).toThrow('Unknown install method') - expect(() => - readIntentConsumerConfig( - '{ "intent": { "install": { "method": "symlink", "targets": "github" } } }', - ), - ).toThrow('array of strings') - }) - it('updates JSONC fields without changing unrelated formatting', () => { const source = '\ufeff{\r\n\t// keep this comment\r\n\t"name": "app",\r\n\t"intent": {\r\n\t\t"skills": ["old"],\r\n\t},\r\n}\r\n' const updated = updateIntentConsumerConfigText(source, { skills: ['@tanstack/query'], exclude: ['@other/pkg'], - install: { method: 'hooks', targets: ['github'] }, }) expect(updated.startsWith('\ufeff')).toBe(true) @@ -97,7 +22,6 @@ describe('installer configuration', () => { expect(readIntentConsumerConfig(updated)).toEqual({ skills: ['@tanstack/query'], exclude: ['@other/pkg'], - install: { method: 'hooks', targets: ['github'] }, }) }) @@ -108,6 +32,27 @@ describe('installer configuration', () => { expect(updateIntentConsumerConfigText(source, requested)).toBe(source) }) + it('ignores legacy intent.install when reading policy', () => { + expect( + readIntentConsumerConfig( + '{"intent":{"skills":["pkg"],"exclude":[],"install":"invalid"}}', + ), + ).toEqual({ skills: ['pkg'], exclude: [] }) + }) + + it('removes legacy intent.install when updating policy', () => { + const updated = updateIntentConsumerConfigText( + '{"intent":{"skills":["old"],"exclude":[],"install":{"method":"hooks"}}}\n', + { skills: ['pkg'], exclude: [] }, + ) + + expect(updated).not.toContain('"install"') + expect(readIntentConsumerConfig(updated)).toEqual({ + skills: ['pkg'], + exclude: [], + }) + }) + it('preserves unchanged array formatting when another field changes', () => { const source = `{ "intent": { diff --git a/packages/intent/tests/install-delivery.test.ts b/packages/intent/tests/install-delivery.test.ts new file mode 100644 index 00000000..d8ca1a0e --- /dev/null +++ b/packages/intent/tests/install-delivery.test.ts @@ -0,0 +1,79 @@ +import { execFileSync } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + DELIVERY_CONFIG_PATH, + readIntentDeliveryConfig, + writeIntentDeliveryConfig, +} from '../src/commands/install/delivery.js' +import type { IntentDeliveryConfig } from '../src/commands/install/delivery.js' + +const roots: Array = [] + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('local delivery configuration', () => { + it('round-trips the exact schema', () => { + const root = mkdtempSync(join(tmpdir(), 'intent-delivery-')) + roots.push(root) + const config: IntentDeliveryConfig = { + method: 'symlink', + targets: ['agents', 'github'], + } + + expect(DELIVERY_CONFIG_PATH).toBe('.intent/delivery.json') + expect(writeIntentDeliveryConfig(root, config)).toBe(true) + expect(readIntentDeliveryConfig(root)).toEqual(config) + expect( + JSON.parse(readFileSync(join(root, DELIVERY_CONFIG_PATH), 'utf8')), + ).toEqual(config) + }) + + it.each([ + ['invalid JSON', '{'], + ['unknown field', '{"method":"symlink","targets":["agents"],"extra":true}'], + ['invalid method', '{"method":"copy","targets":["agents"]}'], + ['empty targets', '{"method":"symlink","targets":[]}'], + ['invalid target', '{"method":"symlink","targets":["unknown"]}'], + ['duplicate target', '{"method":"symlink","targets":["agents","agents"]}'], + ['unsupported target', '{"method":"hooks","targets":["vscode"]}'], + ])('rejects %s', (_label, source) => { + const root = mkdtempSync(join(tmpdir(), 'intent-delivery-')) + roots.push(root) + mkdirSync(join(root, '.intent')) + writeFileSync(join(root, DELIVERY_CONFIG_PATH), source, 'utf8') + + expect(() => readIntentDeliveryConfig(root)).toThrow() + }) + + it('writes idempotently and excludes delivery from the local Git repository', () => { + const root = mkdtempSync(join(tmpdir(), 'intent-delivery-')) + roots.push(root) + execFileSync('git', ['init', '--quiet'], { cwd: root }) + writeFileSync(join(root, '.gitignore'), 'shared\r\n', 'utf8') + const config: IntentDeliveryConfig = { + method: 'hooks', + targets: ['github'], + } + + expect(writeIntentDeliveryConfig(root, config)).toBe(true) + expect(writeIntentDeliveryConfig(root, config)).toBe(false) + expect(readIntentDeliveryConfig(root)).toEqual(config) + expect(readFileSync(join(root, '.gitignore'), 'utf8')).toBe('shared\r\n') + expect( + readFileSync(join(root, '.git', 'info', 'exclude'), 'utf8'), + ).toContain(DELIVERY_CONFIG_PATH) + }) +}) diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index cf2da629..f8498c4f 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -125,7 +125,6 @@ function scaffoldBerryProject(): string { function createInstallPrompts(method: 'hooks' | 'symlink'): InstallerPrompter { return { - advisory: () => {}, complete: () => {}, selectMethod: () => Promise.resolve(method), selectTargets: () => diff --git a/packages/intent/tests/sync.test.ts b/packages/intent/tests/sync.test.ts index 721a6ab6..7a197049 100644 --- a/packages/intent/tests/sync.test.ts +++ b/packages/intent/tests/sync.test.ts @@ -11,9 +11,8 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { updateIntentGitignore } from '../src/commands/sync/gitignore.js' +import { updateIntentGitExclude } 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, @@ -513,30 +512,18 @@ describe('managed sync links', () => { }) describe('sync managed text', () => { - it('updates only the exact gitignore block while preserving CRLF', () => { - const updated = updateIntentGitignore('node_modules/\r\n', [ + it('updates only the exact local exclude block while preserving CRLF', () => { + const updated = updateIntentGitExclude('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, [ + updateIntentGitExclude(updated, [ '.github/skills/a', '.intent/install-state.json', ]), ).toBe(updated) }) - - it('adds and preserves an idempotent prepare sync command', () => { - expect(wireIntentSyncPrepare('{"name":"app"}\n')).toContain( - '"prepare": "intent sync"', - ) - expect(wireIntentSyncPrepare('{"scripts":{"prepare":"build"}}')).toContain( - 'build && intent sync', - ) - const existing = - '{\r\n "scripts": { "prepare": "build && intent sync" }\r\n}\r\n' - expect(wireIntentSyncPrepare(existing)).toBe(existing) - }) }) From 4a5e162929bbb627a97fa246bb866412d30e0e67 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 29 Jul 2026 16:51:11 -0700 Subject: [PATCH 55/56] feat(intent): enhance map target selection and validation in install command --- .../intent/src/commands/install/command.ts | 49 ++-- .../intent/src/commands/install/guidance.ts | 124 ++++++++-- .../intent/src/commands/install/prompts.ts | 38 ++++ .../intent/tests/consumer-install.test.ts | 68 ++++++ packages/intent/tests/install-writer.test.ts | 211 +++++++++++++++++- 5 files changed, 434 insertions(+), 56 deletions(-) diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index b0ceacf8..954db68e 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -8,8 +8,10 @@ import { printWarnings, } from '../support.js' import { + SUPPORTED_MAP_TARGETS, buildIntentSkillsBlock, - resolveIntentSkillsBlockTargetPath, + findExistingIntentSkillsBlockTargetPath, + resolveMapTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from './guidance.js' @@ -157,22 +159,33 @@ export async function runInstallCommand( const scanResult = await scanIntentsOrFail(coreOptions) const generated = buildIntentSkillsBlock(scanResult) + const root = process.cwd() - if (options.dryRun) { - const targetPath = resolveIntentSkillsBlockTargetPath( - process.cwd(), - generated.mappingCount, + if (generated.mappingCount === 0) { + printNoActionableSkills( + scanResult.warnings, + scanResult.notices, + noticeOptions, ) + return + } - if (!targetPath) { - printNoActionableSkills( - scanResult.warnings, - scanResult.notices, - noticeOptions, - ) - return + const existingTargetPath = findExistingIntentSkillsBlockTargetPath(root) + let targetPath: string + if (existingTargetPath) { + targetPath = resolveMapTargetPath(root, relative(root, existingTargetPath)) + } else { + let selectedTarget: string = SUPPORTED_MAP_TARGETS[0] + if (process.stdin.isTTY && process.stdout.isTTY) { + const { selectClackMapTarget } = await import('./prompts.js') + const selection = await selectClackMapTarget(root) + if (!selection) return + selectedTarget = selection } + targetPath = resolveMapTargetPath(root, selectedTarget) + } + if (options.dryRun) { console.log( `Generated ${formatMappingCount(generated.mappingCount)} for ${formatTargetPath(targetPath)}.`, ) @@ -184,17 +197,11 @@ export async function runInstallCommand( const result = writeIntentSkillsBlock({ ...generated, - root: process.cwd(), + root, + targetPath, }) - if (!result.targetPath) { - printNoActionableSkills( - scanResult.warnings, - scanResult.notices, - noticeOptions, - ) - return - } + if (!result.targetPath) return const target = formatTargetPath(result.targetPath) const verification = verifyIntentSkillsBlockFile({ diff --git a/packages/intent/src/commands/install/guidance.ts b/packages/intent/src/commands/install/guidance.ts index 6eaad81f..bb21f04f 100644 --- a/packages/intent/src/commands/install/guidance.ts +++ b/packages/intent/src/commands/install/guidance.ts @@ -1,7 +1,16 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path' import { parse as parseYaml } from 'yaml' import { formatIntentCommand } from '../../shared/command-runner.js' +import { isPathWithin } from '../../shared/utils.js' import { isGeneratedMappingSkill } from '../../skills/categories.js' import { formatSkillUse, parseSkillUse } from '../../skills/use.js' import type { ScanResult, SkillEntry } from '../../shared/types.js' @@ -11,12 +20,12 @@ const INTENT_SKILLS_END = '' const LOCAL_PATH_VALUE_PATTERN = /(?:^|[\s"'])(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|\/(?:Users|home|private|tmp|var\/folders)[\\/]|[^\s"']*(?:node_modules|\.pnpm|\.bun|\.yarn|\.intent)[\\/])/i -const SUPPORTED_AGENT_CONFIG_FILES = [ +export const SUPPORTED_MAP_TARGETS = [ 'AGENTS.md', 'CLAUDE.md', '.cursorrules', '.github/copilot-instructions.md', -] +] as const export interface IntentSkillsBlockResult { block: string @@ -26,6 +35,7 @@ export interface IntentSkillsBlockResult { export interface WriteIntentSkillsBlockOptions extends IntentSkillsBlockResult { root: string skipWhenEmpty?: boolean + targetPath?: string } interface WriteIntentSkillsBlockFileResult { @@ -261,15 +271,65 @@ export function verifyIntentSkillsBlockFile({ } } -export function resolveIntentSkillsBlockTargetPath( +export function findExistingIntentSkillsBlockTargetPath( root: string, - mappingCount: number, ): string | null { - if (mappingCount === 0) return null - return ( - findExistingConfigWithManagedBlock(root)?.filePath ?? - join(root, 'AGENTS.md') - ) + return findExistingConfigWithManagedBlock(root)?.filePath ?? null +} + +export function resolveMapTargetPath( + root: string, + projectRelativePath: string, +): string { + const value = projectRelativePath.trim() + if (value === '') throw new Error('Map target path is required.') + if (/[\\/]$/.test(value)) { + throw new Error('Map target must be a file, not a directory.') + } + if (isAbsolute(value) || win32.isAbsolute(value)) { + throw new Error('Map target must be relative to the project.') + } + + const segments = value.replace(/\\/g, '/').split('/') + if ( + segments.some( + (segment) => segment === '..' || segment.toLowerCase() === '.git', + ) + ) { + throw new Error('Map target cannot use `..` or `.git` path segments.') + } + + const normalizedSegments = posix.normalize(segments.join('/')).split('/') + const resolvedRoot = resolve(root) + const targetPath = resolve(resolvedRoot, ...normalizedSegments) + if (!isPathWithin(resolvedRoot, targetPath)) { + throw new Error('Map target must stay within the project.') + } + + const realRoot = realpathSync(resolvedRoot) + let existingPath = targetPath + for (;;) { + try { + lstatSync(existingPath) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + const parent = dirname(existingPath) + if (parent === existingPath) { + throw new Error('Map target must have an existing project ancestor.') + } + existingPath = parent + } + + if (!isPathWithin(realRoot, realpathSync(existingPath))) { + throw new Error('Map target cannot resolve outside the project.') + } + if (existsSync(targetPath) && statSync(targetPath).isDirectory()) { + throw new Error('Map target must be a file, not a directory.') + } + + return targetPath } function compareNames(a: { name: string }, b: { name: string }): number { @@ -365,23 +425,33 @@ function findExistingConfigWithManagedBlock(root: string): { filePath: string managedBlock: ManagedBlock } | null { - for (const file of SUPPORTED_AGENT_CONFIG_FILES) { + for (const file of SUPPORTED_MAP_TARGETS) { const filePath = join(root, file) - if (!existsSync(filePath)) continue - - const content = readFileSync(filePath, 'utf8') - const { managedBlock, errors, hasMarker } = readManagedBlock(content) - if (managedBlock) return { content, filePath, managedBlock } - if (hasMarker) { - throw new Error( - `Invalid intent-skills block in ${filePath}: ${errors.join(' ')}`, - ) - } + const existing = readConfigWithManagedBlock(filePath) + if (existing) return existing } return null } +function readConfigWithManagedBlock(filePath: string): { + content: string + filePath: string + managedBlock: ManagedBlock +} | null { + if (!existsSync(filePath)) return null + + const content = readFileSync(filePath, 'utf8') + const { managedBlock, errors, hasMarker } = readManagedBlock(content) + if (managedBlock) return { content, filePath, managedBlock } + if (hasMarker) { + throw new Error( + `Invalid intent-skills block in ${filePath}: ${errors.join(' ')}`, + ) + } + return null +} + function replaceManagedBlock( content: string, managedBlock: ManagedBlock, @@ -397,6 +467,7 @@ export function writeIntentSkillsBlock({ mappingCount, root, skipWhenEmpty = true, + targetPath: explicitTargetPath, }: WriteIntentSkillsBlockOptions): WriteIntentSkillsBlockResult { if (mappingCount === 0 && skipWhenEmpty) { return { @@ -406,8 +477,13 @@ export function writeIntentSkillsBlock({ } } - const existingTarget = findExistingConfigWithManagedBlock(root) - const targetPath = existingTarget?.filePath ?? join(root, 'AGENTS.md') + const existingTarget = explicitTargetPath + ? readConfigWithManagedBlock(explicitTargetPath) + : findExistingConfigWithManagedBlock(root) + const targetPath = + explicitTargetPath ?? + existingTarget?.filePath ?? + join(root, SUPPORTED_MAP_TARGETS[0]) if (existingTarget) { const nextContent = replaceManagedBlock( diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index d7d3d938..d2fb31a0 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -8,8 +8,10 @@ import { note, outro, select, + text, } from '@clack/prompts' import { installTargetsForMethod } from './delivery.js' +import { SUPPORTED_MAP_TARGETS, resolveMapTargetPath } from './guidance.js' import { skillSelectionId } from './plan.js' import type { InstallConfirmation, InstallerPrompter } from './consumer.js' import type { InstallMethod, InstallTarget } from './delivery.js' @@ -26,6 +28,42 @@ function sourceLabel(pkg: IntentPackage): string { return pkg.kind === 'workspace' ? `workspace:${pkg.name}` : pkg.name } +const OTHER_MAP_TARGET = 'other' + +export async function selectClackMapTarget( + root: string, +): Promise { + const selection = cancelled( + await select({ + message: 'Where should Intent write skill mappings?', + options: [ + ...SUPPORTED_MAP_TARGETS.map((target) => ({ + value: target, + label: target, + })), + { value: OTHER_MAP_TARGET, label: 'Other project file' }, + ], + }), + ) + if (!selection) return null + if (selection !== OTHER_MAP_TARGET) return selection + + const customTarget = cancelled( + await text({ + message: 'Project-relative file path', + validate(value) { + try { + resolveMapTargetPath(root, value ?? '') + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + return undefined + }, + }), + ) + return customTarget?.trim() || null +} + export function groupSkillOptions( discovered: ReadonlyArray, ): Record< diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index c8a8470a..a0e5a6ac 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -23,7 +23,9 @@ import { import { createClackInstallerPrompter, groupSkillOptions, + selectClackMapTarget, } from '../src/commands/install/prompts.js' +import { SUPPORTED_MAP_TARGETS } from '../src/commands/install/guidance.js' import { runSyncCommand } from '../src/commands/sync/command.js' import { readInstallState } from '../src/commands/sync/state.js' import { buildCurrentLockfileSources } from '../src/core/lockfile/lockfile-state.js' @@ -36,21 +38,36 @@ import type * as ClackPrompts from '@clack/prompts' import type { InstallerPrompter } from '../src/commands/install/consumer.js' const clackPromptMocks = vi.hoisted(() => ({ + cancel: vi.fn(), + cancelValue: Symbol('cancel'), intro: vi.fn(), + isCancel: vi.fn<(value: unknown) => boolean>(), multiselect: vi.fn<() => Promise>(), select: vi.fn< (options: { options: Array<{ value: string }> }) => Promise >(), + text: vi.fn< + (options: { + validate?: (value: string) => string | Error | undefined + }) => Promise + >(), })) +clackPromptMocks.isCancel.mockImplementation( + (value) => value === clackPromptMocks.cancelValue, +) + vi.mock('@clack/prompts', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + cancel: clackPromptMocks.cancel, intro: clackPromptMocks.intro, + isCancel: clackPromptMocks.isCancel, multiselect: clackPromptMocks.multiselect, select: clackPromptMocks.select, + text: clackPromptMocks.text, } }) @@ -132,6 +149,7 @@ afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } + vi.clearAllMocks() }) describe('consumer install', () => { @@ -205,6 +223,56 @@ describe('consumer install', () => { expect(options.map((option) => option.value)).toEqual(['symlink', 'hooks']) }) + it('selects a standard map target from the canonical target list', async () => { + const root = createProject() + clackPromptMocks.select.mockResolvedValueOnce( + '.github/copilot-instructions.md', + ) + + await expect(selectClackMapTarget(root)).resolves.toBe( + '.github/copilot-instructions.md', + ) + + const [{ options }] = clackPromptMocks.select.mock.calls[0]! + expect(options.map((option) => option.value)).toEqual([ + ...SUPPORTED_MAP_TARGETS, + 'other', + ]) + expect(clackPromptMocks.text).not.toHaveBeenCalled() + }) + + it('validates Other project file map targets with the safe resolver', async () => { + const root = createProject() + clackPromptMocks.select.mockResolvedValueOnce('other') + clackPromptMocks.text.mockImplementationOnce(({ validate }) => { + expect(validate?.('../outside.md')).toBe( + 'Map target cannot use `..` or `.git` path segments.', + ) + return Promise.resolve('notes/assistant.md') + }) + + await expect(selectClackMapTarget(root)).resolves.toBe('notes/assistant.md') + }) + + it.each(['selection', 'Other path'])( + 'returns null when map target %s is cancelled', + async (cancelledPrompt) => { + const root = createProject() + if (cancelledPrompt === 'selection') { + clackPromptMocks.select.mockResolvedValueOnce( + clackPromptMocks.cancelValue, + ) + } else { + clackPromptMocks.select.mockResolvedValueOnce('other') + clackPromptMocks.text.mockResolvedValueOnce( + clackPromptMocks.cancelValue, + ) + } + + await expect(selectClackMapTarget(root)).resolves.toBeNull() + }, + ) + it('selects the method before requesting applicable targets', async () => { const root = createProject() const calls: Array = [] diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 52b8c6ed..ef41d762 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -1,18 +1,21 @@ import { existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runInstallCommand } from '../src/commands/install/command.js' import { buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, - resolveIntentSkillsBlockTargetPath, + resolveMapTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from '../src/commands/install/guidance.js' @@ -23,7 +26,22 @@ import type { SkillEntry, } from '../src/shared/types.js' +const mapPromptMocks = vi.hoisted(() => ({ + selectClackMapTarget: vi.fn<(root: string) => Promise>(), +})) + +vi.mock('../src/commands/install/prompts.js', async (importOriginal) => ({ + ...(await importOriginal()), + selectClackMapTarget: mapPromptMocks.selectClackMapTarget, +})) + const tempDirs: Array = [] +const originalCwd = process.cwd() +const originalStdinTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') +const originalStdoutTTY = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY', +) const packageJson = JSON.parse( readFileSync( join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), @@ -33,9 +51,22 @@ const packageJson = JSON.parse( const intentPackagePin = packageVersionToPin(packageJson.version) afterEach(() => { + process.chdir(originalCwd) + if (originalStdinTTY) { + Object.defineProperty(process.stdin, 'isTTY', originalStdinTTY) + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY + } + if (originalStdoutTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalStdoutTTY) + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } + vi.restoreAllMocks() + vi.clearAllMocks() }) function tempRoot(): string { @@ -94,6 +125,25 @@ function scanResult(packages: Array): ScanResult { } } +function mappedScanResult(): ScanResult { + return scanResult([ + pkg({ + skills: [skill({ description: 'Core guidance' })], + }), + ]) +} + +function setTTY(value: boolean): void { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value, + }) + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value, + }) +} + const exampleBlock = ` # TanStack Intent - before editing files, run the matching guidance command. tanstackIntent: @@ -315,6 +365,57 @@ tanstackIntent: }) describe('install writer file updates', () => { + it('resolves nested project map targets', () => { + const root = tempRoot() + + expect(resolveMapTargetPath(root, '.github/copilot-instructions.md')).toBe( + join(root, '.github', 'copilot-instructions.md'), + ) + }) + + it.each([ + ['', 'empty'], + ['/tmp/outside.md', 'native absolute'], + ['C:\\outside.md', 'Windows absolute'], + ['../outside.md', 'parent segment'], + ['notes/../outside.md', 'nested parent segment'], + ['.git/instructions.md', 'git metadata'], + ['notes/.git/instructions.md', 'nested git metadata'], + ['.GIT/instructions.md', 'case-variant git metadata'], + ])('rejects %s as a map target (%s)', (targetPath) => { + const root = tempRoot() + + expect(() => resolveMapTargetPath(root, targetPath)).toThrow() + }) + + it('rejects directory and trailing-separator map targets', () => { + const root = tempRoot() + mkdirSync(join(root, 'notes')) + + expect(() => resolveMapTargetPath(root, 'notes')).toThrow() + expect(() => resolveMapTargetPath(root, 'notes/')).toThrow() + }) + + it('rejects map targets through a symlinked parent outside the project', () => { + const root = tempRoot() + const outside = tempRoot() + symlinkSync(outside, join(root, 'linked-notes'), 'dir') + + expect(() => + resolveMapTargetPath(root, 'linked-notes/instructions.md'), + ).toThrow() + }) + + it('rejects an existing map target symlinked outside the project', () => { + const root = tempRoot() + const outside = tempRoot() + const outsideFile = join(outside, 'instructions.md') + writeFileSync(outsideFile, 'outside\n') + symlinkSync(outsideFile, join(root, 'instructions.md'), 'file') + + expect(() => resolveMapTargetPath(root, 'instructions.md')).toThrow() + }) + it('creates AGENTS.md when no managed block exists', () => { const root = tempRoot() @@ -358,6 +459,35 @@ After `) }) + it('replaces an explicit custom target block exactly once on rerun', () => { + const root = tempRoot() + const targetPath = resolveMapTargetPath(root, 'notes/assistant.md') + const surrounding = 'Project introduction\nProject details\n' + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, surrounding) + + writeIntentSkillsBlock({ + block: exampleBlock, + mappingCount: 1, + root, + targetPath, + }) + const updatedBlock = exampleBlock.replace( + 'Query data fetching', + 'Query cache management', + ) + writeIntentSkillsBlock({ + block: updatedBlock, + mappingCount: 1, + root, + targetPath, + }) + + const content = readFileSync(targetPath, 'utf8') + expect(content.match(//g)).toHaveLength(1) + expect(content).toBe(`${updatedBlock}\n${surrounding}`) + }) + it('prepends to an existing AGENTS.md without a managed block', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') @@ -401,15 +531,6 @@ old expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) - it('resolves the existing managed config as the write target', () => { - const root = tempRoot() - const claudePath = join(root, 'CLAUDE.md') - writeFileSync(claudePath, exampleBlock) - - expect(resolveIntentSkillsBlockTargetPath(root, 1)).toBe(claudePath) - expect(resolveIntentSkillsBlockTargetPath(root, 0)).toBe(null) - }) - it('rejects malformed managed blocks before writing', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') @@ -731,3 +852,71 @@ tanstackIntent: ) }) }) + +describe('install map destination command', () => { + it('does not prompt or write when there are no mappings', async () => { + const root = tempRoot() + process.chdir(root) + setTTY(true) + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('updates an existing managed target without prompting', async () => { + const root = tempRoot() + const targetPath = join(root, 'CLAUDE.md') + process.chdir(root) + setTTY(true) + writeFileSync(targetPath, exampleBlock) + + await runInstallCommand({ map: true }, () => + Promise.resolve(mappedScanResult()), + ) + + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expect(readFileSync(targetPath, 'utf8')).toContain('id: "pkg#core"') + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('writes nothing when map destination selection is cancelled', async () => { + const root = tempRoot() + process.chdir(root) + setTTY(true) + mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce(null) + + await runInstallCommand({ map: true }, () => + Promise.resolve(mappedScanResult()), + ) + + expect(mapPromptMocks.selectClackMapTarget).toHaveBeenCalledWith( + process.cwd(), + ) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('reports the selected map target during dry run without writing', async () => { + const root = tempRoot() + process.chdir(root) + setTTY(true) + mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce( + '.github/copilot-instructions.md', + ) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ dryRun: true, map: true }, () => + Promise.resolve(mappedScanResult()), + ) + + expect(log.mock.calls.flat().join('\n')).toContain( + 'Generated 1 mapping for .github/copilot-instructions.md.', + ) + expect(existsSync(join(root, '.github', 'copilot-instructions.md'))).toBe( + false, + ) + }) +}) From 502af20d888c02b27823c05b55b5313629444227 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Wed, 29 Jul 2026 18:31:08 -0700 Subject: [PATCH 56/56] feat(intent): bootstrap map trust and localize hooks --- .../intent/src/commands/install/command.ts | 108 +++++- .../intent/src/commands/install/consumer.ts | 86 ++--- .../intent/src/commands/install/prompts.ts | 7 +- .../intent/tests/consumer-install.test.ts | 232 +++++++----- packages/intent/tests/install-writer.test.ts | 354 ++++++++++++++++-- .../integration/pnp-berry-corepack.test.ts | 22 +- 6 files changed, 615 insertions(+), 194 deletions(-) diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 954db68e..8ad96ad6 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,4 +1,12 @@ -import { relative } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { join, relative } 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 { resolveProjectContext } from '../../core/project-context.js' +import { applySourcePolicy } from '../../core/source-policy.js' +import { parseSkillSources } from '../../core/skill-sources.js' +import { writeTextFileAtomic } from '../../shared/atomic-write.js' import { fail } from '../../shared/cli-error.js' import { detectIntentAudience } from '../../shared/environment.js' import { @@ -15,8 +23,14 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from './guidance.js' +import { + readIntentConsumerConfig, + updateIntentConsumerConfigText, +} from './config.js' +import { buildSkillSelectionPlan } from './plan.js' import type { GlobalScanFlags } from '../support.js' import type { InstallerPrompter } from './consumer.js' +import type { IntentLockfile } from '../../core/lockfile/lockfile.js' import type { IntentCoreOptions } from '../../core/index.js' import type { ScanResult } from '../../shared/types.js' @@ -61,8 +75,6 @@ export async function runInteractiveInstall({ dryRun?: boolean prompts: InstallerPrompter }): Promise { - const { resolveProjectContext } = - await import('../../core/project-context.js') const context = resolveProjectContext({ cwd }) const root = context.workspaceRoot ?? context.packageRoot ?? context.cwd await runInstallWithPrompts({ dryRun, prompts, root }) @@ -157,9 +169,77 @@ export async function runInstallCommand( return } - const scanResult = await scanIntentsOrFail(coreOptions) + let root = process.cwd() + let projectRoot = root + let scanResult: ScanResult | null = null + let bootstrapWrites: { + lockfile: IntentLockfile + packageJsonPath: string + updatedPackageJson: string + } | null = null + + if (process.stdin.isTTY && process.stdout.isTTY) { + const context = resolveProjectContext({ cwd: process.cwd() }) + projectRoot = context.workspaceRoot ?? context.packageRoot ?? context.cwd + const lockfilePath = join(projectRoot, 'intent.lock') + const packageJsonPath = join(projectRoot, 'package.json') + if (!existsSync(lockfilePath) && existsSync(packageJsonPath)) { + const packageJson = readFileSync(packageJsonPath, 'utf8') + const config = readIntentConsumerConfig(packageJson) + if ( + config.skills.length === 0 && + config.exclude.length === 0 && + !options.global && + !options.globalOnly + ) { + root = projectRoot + const [{ createIntentFsCache }, { scanForIntents }] = await Promise.all( + [ + import('../../discovery/fs-cache.js'), + import('../../discovery/scanner.js'), + ], + ) + const fsCache = createIntentFsCache() + const scanOptions = { scope: 'local' as const, fsCache } + const scan = scanForIntents(root, scanOptions) + if (buildIntentSkillsBlock(scan).mappingCount === 0) { + printNoActionableSkills(scan.warnings, scan.notices, noticeOptions) + return + } + + const { selectClackSkills } = await import('./prompts.js') + const selection = await selectClackSkills(scan.packages) + if (!selection) return + + const plan = buildSkillSelectionPlan(scan.packages, selection) + const policy = applySourcePolicy( + { packages: scan.packages }, + { + config: parseSkillSources(plan.skills), + excludeMatchers: compileExcludePatterns(plan.exclude), + }, + ) + scanResult = { ...scan, packages: policy.packages } + bootstrapWrites = { + lockfile: { + lockfileVersion: 1, + sources: buildCurrentLockfileSources( + policy.packages, + fsCache.getReadFs(), + ), + }, + packageJsonPath, + updatedPackageJson: updateIntentConsumerConfigText(packageJson, { + skills: plan.skills, + exclude: plan.exclude, + }), + } + } + } + } + + scanResult ??= await scanIntentsOrFail(coreOptions) const generated = buildIntentSkillsBlock(scanResult) - const root = process.cwd() if (generated.mappingCount === 0) { printNoActionableSkills( @@ -170,7 +250,15 @@ export async function runInstallCommand( return } - const existingTargetPath = findExistingIntentSkillsBlockTargetPath(root) + let existingTargetPath = + root !== projectRoot + ? findExistingIntentSkillsBlockTargetPath(projectRoot) + : null + if (existingTargetPath) { + root = projectRoot + } else { + existingTargetPath = findExistingIntentSkillsBlockTargetPath(root) + } let targetPath: string if (existingTargetPath) { targetPath = resolveMapTargetPath(root, relative(root, existingTargetPath)) @@ -219,6 +307,14 @@ export async function runInstallCommand( ) } + if (bootstrapWrites) { + writeIntentLockfile(join(root, 'intent.lock'), bootstrapWrites.lockfile) + writeTextFileAtomic( + bootstrapWrites.packageJsonPath, + bootstrapWrites.updatedPackageJson, + ) + } + printWriteResult(result) printPlacementTip(result.targetPath) diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index 0f2a6aa5..81f321a5 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -40,7 +40,7 @@ import type { IntentDeliveryConfig, } from './delivery.js' import type { SkillSelection } from './plan.js' -import type { HookAgent, HookInstallScope } from '../../hooks/types.js' +import type { HookAgent } from '../../hooks/types.js' import type { ReadFs } from '../../shared/utils.js' import type { IntentPackage } from '../../shared/types.js' @@ -66,8 +66,10 @@ export interface InstallerPrompter { } export interface RunConsumerInstallOptions { + copilotHome?: string discovered: ReadonlyArray dryRun?: boolean + homeDir?: string prompts: InstallerPrompter readFs?: ReadFs root: string @@ -91,44 +93,23 @@ function countSkills(entries: ReadonlyArray<{ skillCount: number }>): number { return entries.reduce((count, entry) => count + entry.skillCount, 0) } -async function confirmUserScopeHooks( - targets: ReadonlyArray, - prompts: InstallerPrompter, -): Promise { - if (!targets.includes('github')) return false - return prompts.confirmUserScopeHooks() -} - -function installHookAgents( - root: string, - agents: string, - scope: HookInstallScope, -): Array { - return runInstallHooks({ agents, root, scope }) - .filter((result) => result.status !== 'skipped') - .map((result) => result.agent) -} - function installConfiguredHooks( root: string, targets: ReadonlyArray, - userScopeHooksAccepted: boolean, + homeDir: string | undefined, + copilotHome: string | undefined, ): Array { - const hookAgents = targets.map(hookAgentForTarget) - const projectAgents = hookAgents.filter((agent) => agent !== 'copilot') - const installedAgents = - projectAgents.length > 0 - ? installHookAgents(root, projectAgents.join(','), 'project') - : [] - if (userScopeHooksAccepted) { - installedAgents.push(...installHookAgents(root, 'copilot', 'user')) - } - return installedAgents + const agents = targets.map(hookAgentForTarget).join(',') + return runInstallHooks({ agents, copilotHome, homeDir, root, scope: 'user' }) + .filter((result) => result.status !== 'skipped') + .map((result) => result.agent) } export async function runConsumerInstall({ + copilotHome, discovered, dryRun = false, + homeDir, prompts, readFs = nodeReadFs, root, @@ -157,25 +138,17 @@ export async function runConsumerInstall({ prompts.complete('Project is up to date.') return } - const userScopeHooksAccepted = await confirmUserScopeHooks( - delivery.targets, - prompts, - ) + const userScopeHooksAccepted = await prompts.confirmUserScopeHooks() if (userScopeHooksAccepted === null) return - const installedAgents = installConfiguredHooks( - root, - delivery.targets, - userScopeHooksAccepted, - ) + const installedAgents = userScopeHooksAccepted + ? installConfiguredHooks(root, delivery.targets, homeDir, copilotHome) + : [] const repairedHooks = installedAgents.length > 0 ? ' Repaired configured hooks.' : '' - const skippedCopilot = - delivery.targets.includes('github') && !userScopeHooksAccepted - ? ' Copilot was skipped because home-directory access was declined.' - : '' - prompts.complete( - `Project is up to date.${repairedHooks}${skippedCopilot}`, - ) + const skippedHooks = userScopeHooksAccepted + ? '' + : ' Hooks were skipped because home-directory access was declined.' + prompts.complete(`Project is up to date.${repairedHooks}${skippedHooks}`) return } console.log( @@ -296,13 +269,13 @@ export async function runConsumerInstall({ } const userScopeHooksAccepted = - method === 'hooks' ? await confirmUserScopeHooks(targets, prompts) : false + method === 'hooks' ? await prompts.confirmUserScopeHooks() : false if (userScopeHooksAccepted === null) return if (updatedPackageJson !== packageJson) { writeTextFileAtomic(packageJsonPath, updatedPackageJson) } - if (!hasCommittedTrust) { + if (plan) { writeIntentLockfile(join(root, 'intent.lock'), lockfile) } writeIntentDeliveryConfig(root, deliveryConfig) @@ -314,17 +287,14 @@ export async function runConsumerInstall({ return } - const installedAgents = installConfiguredHooks( - root, - targets, - userScopeHooksAccepted, - ) - const skippedCopilot = - targets.includes('github') && !userScopeHooksAccepted - ? ' Copilot was skipped because home-directory access was declined.' - : '' + const installedAgents = userScopeHooksAccepted + ? installConfiguredHooks(root, targets, homeDir, copilotHome) + : [] + const skippedHooks = userScopeHooksAccepted + ? '' + : ' Hooks were skipped because home-directory access was declined.' prompts.complete( - `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using hooks. Installed hook agents: ${installedAgents.length > 0 ? installedAgents.join(', ') : 'none'}.${skippedCopilot}`, + `Installed ${installation.skillCount} ${installation.skillCount === 1 ? 'skill' : 'skills'} using hooks. Installed hook agents: ${installedAgents.length > 0 ? installedAgents.join(', ') : 'none'}.${skippedHooks}`, ) return } diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index d2fb31a0..f1f01eee 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -167,13 +167,12 @@ 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', + 'Hooks are written to your home directory and affect sessions in this and other repositories.', + 'User-level hooks apply across repositories', ) return cancelled( await confirm({ - message: - 'Allow Intent to write GitHub Copilot hooks in your home directory?', + message: 'Allow Intent to install user-level hooks?', initialValue: false, vertical: true, }), diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index a0e5a6ac..8db005d6 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -40,9 +40,11 @@ import type { InstallerPrompter } from '../src/commands/install/consumer.js' const clackPromptMocks = vi.hoisted(() => ({ cancel: vi.fn(), cancelValue: Symbol('cancel'), + confirm: vi.fn<() => Promise>(), intro: vi.fn(), isCancel: vi.fn<(value: unknown) => boolean>(), multiselect: vi.fn<() => Promise>(), + note: vi.fn(), select: vi.fn< (options: { options: Array<{ value: string }> }) => Promise @@ -63,9 +65,11 @@ vi.mock('@clack/prompts', async (importOriginal) => { return { ...actual, cancel: clackPromptMocks.cancel, + confirm: clackPromptMocks.confirm, intro: clackPromptMocks.intro, isCancel: clackPromptMocks.isCancel, multiselect: clackPromptMocks.multiselect, + note: clackPromptMocks.note, select: clackPromptMocks.select, text: clackPromptMocks.text, } @@ -223,6 +227,24 @@ describe('consumer install', () => { expect(options.map((option) => option.value)).toEqual(['symlink', 'hooks']) }) + it('requests permission to install user-level hooks across repositories', async () => { + clackPromptMocks.confirm.mockResolvedValueOnce(true) + + await expect( + createClackInstallerPrompter().confirmUserScopeHooks(), + ).resolves.toBe(true) + + expect(clackPromptMocks.note).toHaveBeenCalledWith( + 'Hooks are written to your home directory and affect sessions in this and other repositories.', + 'User-level hooks apply across repositories', + ) + expect(clackPromptMocks.confirm).toHaveBeenCalledWith({ + message: 'Allow Intent to install user-level hooks?', + initialValue: false, + vertical: true, + }) + }) + it('selects a standard map target from the canonical target list', async () => { const root = createProject() clackPromptMocks.select.mockResolvedValueOnce( @@ -451,6 +473,22 @@ describe('consumer install', () => { } expect(selectSkills).toHaveBeenCalledOnce() expect(confirmInstall).toHaveBeenCalledOnce() + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect(readIntentLockfile(join(root, 'intent.lock'))).toMatchObject({ + status: 'found', + lockfile: { + sources: [ + { + id: '@tanstack/query', + skills: [{ path: 'skills/fetching' }, { path: 'skills/mutations' }], + }, + ], + }, + }) }) it('installs confirmed skills with policy, lock state, and managed links', async () => { @@ -705,15 +743,19 @@ describe('consumer install', () => { it('installs hooks with policy and lock state without links or prepare', async () => { const root = createProject() + const homeDir = join(root, 'home') + const confirmUserScopeHooks = vi.fn(() => Promise.resolve(true)) const prompts = createPrompts({ selectMethod: () => Promise.resolve('hooks'), selectTargets: () => Promise.resolve(['claude', 'codex']), confirmSymlink: () => Promise.reject(new Error('hooks must not request symlink consent')), + confirmUserScopeHooks, }) await runConsumerInstall({ discovered: scanForIntents(root, { scope: 'local' }).packages, + homeDir, prompts, root, }) @@ -744,25 +786,32 @@ describe('consumer install', () => { ], }, }) - expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) - expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(true) + expect(confirmUserScopeHooks).toHaveBeenCalledOnce() + expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(homeDir, '.codex', 'hooks.json'))).toBe(true) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(false) expect(existsSync(join(root, '.agents'))).toBe(false) expect(readInstallState(root)).toEqual({ status: 'missing' }) }) - it('repairs missing project hooks without re-interviewing or rewriting install state', async () => { + it('repairs missing user hooks without re-interviewing or rewriting install state', async () => { const root = createProject() + const homeDir = join(root, 'home') const discovered = scanForIntents(root, { scope: 'local' }).packages + const confirmUserScopeHooks = vi.fn(() => Promise.resolve(true)) await runConsumerInstall({ discovered, + homeDir, prompts: createPrompts({ selectMethod: () => Promise.resolve('hooks'), selectTargets: () => Promise.resolve(['claude', 'codex']), + confirmUserScopeHooks, }), root, }) - const claudeConfig = join(root, '.claude', 'settings.json') - const codexConfig = join(root, '.codex', 'hooks.json') + const claudeConfig = join(homeDir, '.claude', 'settings.json') + const codexConfig = join(homeDir, '.codex', 'hooks.json') const packageBefore = readFileSync(join(root, 'package.json')) const lockBefore = readFileSync(join(root, 'intent.lock')) const codexBefore = readFileSync(codexConfig) @@ -771,8 +820,10 @@ describe('consumer install', () => { await runConsumerInstall({ discovered, + homeDir, prompts: createPrompts({ complete, + confirmUserScopeHooks, selectMethod: () => Promise.reject(new Error('method must not run')), selectTargets: () => Promise.reject(new Error('targets must not run')), selectSkills: () => Promise.reject(new Error('skills must not run')), @@ -784,6 +835,7 @@ describe('consumer install', () => { expect(readFileSync(codexConfig)).toEqual(codexBefore) expect(readFileSync(join(root, 'package.json'))).toEqual(packageBefore) expect(readFileSync(join(root, 'intent.lock'))).toEqual(lockBefore) + expect(confirmUserScopeHooks).toHaveBeenCalledTimes(2) expect(complete).toHaveBeenCalledWith( 'Project is up to date. Repaired configured hooks.', ) @@ -793,7 +845,6 @@ describe('consumer install', () => { const root = createProject() const copilotHome = join(root, 'copilot-home') const hookConfig = join(copilotHome, 'hooks', 'hooks.json') - const previousCopilotHome = process.env.COPILOT_HOME const confirmUserScopeHooks = vi.fn(() => Promise.resolve(true)) let output = '' const prompts = createPrompts({ @@ -804,40 +855,31 @@ describe('consumer install', () => { selectTargets: () => Promise.resolve(['github']), confirmUserScopeHooks, }) - process.env.COPILOT_HOME = copilotHome - - try { - await runConsumerInstall({ - discovered: scanForIntents(root, { scope: 'local' }).packages, - prompts, - root, - }) - expect(confirmUserScopeHooks).toHaveBeenCalledOnce() - expect(existsSync(hookConfig)).toBe(true) - expect(output).toContain('Installed hook agents: copilot.') - unlinkSync(hookConfig) + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts, + root, + }) + expect(confirmUserScopeHooks).toHaveBeenCalledOnce() + expect(existsSync(hookConfig)).toBe(true) + expect(output).toContain('Installed hook agents: copilot.') + unlinkSync(hookConfig) - await runConsumerInstall({ - discovered: scanForIntents(root, { scope: 'local' }).packages, - prompts: createPrompts({ - complete(message) { - output = message - }, - confirmUserScopeHooks, - selectMethod: () => Promise.reject(new Error('method must not run')), - selectTargets: () => - Promise.reject(new Error('targets must not run')), - selectSkills: () => Promise.reject(new Error('skills must not run')), - }), - root, - }) - } finally { - if (previousCopilotHome === undefined) { - delete process.env.COPILOT_HOME - } else { - process.env.COPILOT_HOME = previousCopilotHome - } - } + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete(message) { + output = message + }, + confirmUserScopeHooks, + selectMethod: () => Promise.reject(new Error('method must not run')), + selectTargets: () => Promise.reject(new Error('targets must not run')), + selectSkills: () => Promise.reject(new Error('skills must not run')), + }), + root, + }) expect(confirmUserScopeHooks).toHaveBeenCalledTimes(2) expect(existsSync(hookConfig)).toBe(true) @@ -848,49 +890,41 @@ describe('consumer install', () => { const root = createProject() const copilotHome = join(root, 'copilot-home') const hookConfig = join(copilotHome, 'hooks', 'hooks.json') - const previousCopilotHome = process.env.COPILOT_HOME let output = '' - process.env.COPILOT_HOME = copilotHome - try { - await runConsumerInstall({ - discovered: scanForIntents(root, { scope: 'local' }).packages, - prompts: createPrompts({ - selectMethod: () => Promise.resolve('hooks'), - selectTargets: () => Promise.resolve(['github']), - }), - root, - }) - unlinkSync(hookConfig) + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github']), + }), + root, + }) + unlinkSync(hookConfig) - await runConsumerInstall({ - discovered: scanForIntents(root, { scope: 'local' }).packages, - prompts: createPrompts({ - complete(message) { - output = message - }, - confirmUserScopeHooks: () => Promise.resolve(false), - }), - root, - }) - } finally { - if (previousCopilotHome === undefined) { - delete process.env.COPILOT_HOME - } else { - process.env.COPILOT_HOME = previousCopilotHome - } - } + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete(message) { + output = message + }, + confirmUserScopeHooks: () => Promise.resolve(false), + }), + root, + }) expect(existsSync(hookConfig)).toBe(false) expect(output).toBe( - 'Project is up to date. Copilot was skipped because home-directory access was declined.', + 'Project is up to date. Hooks were skipped because home-directory access was declined.', ) }) - it('skips declined Copilot hooks while installing project hooks', async () => { + it('skips all user hooks when home-directory access is declined', async () => { const root = createProject() + const homeDir = join(root, 'home') const copilotHome = join(root, 'copilot-home') - const previousCopilotHome = process.env.COPILOT_HOME let output = '' const prompts = createPrompts({ complete(message) { @@ -900,40 +934,50 @@ describe('consumer install', () => { 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 - } - } + await runConsumerInstall({ + copilotHome, + discovered: scanForIntents(root, { scope: 'local' }).packages, + homeDir, + prompts, + root, + }) 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.', + expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(homeDir, '.codex', 'hooks.json'))).toBe(false) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(false) + expect(readIntentDeliveryConfig(root)).toEqual({ + method: 'hooks', + targets: ['github', 'claude', 'codex'], + }) + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect(readIntentLockfile(join(root, 'intent.lock')).status).toBe('found') + expect(output).toBe( + 'Installed 1 skill using hooks. Installed hook agents: none. Hooks were skipped because home-directory access was declined.', ) }) - it('writes nothing when installation is cancelled', async () => { + it('writes nothing when user-scope hook confirmation is cancelled', async () => { const root = createProject() + const homeDir = join(root, 'home') + const copilotHome = join(root, 'copilot-home') const originalPackageJson = readFileSync(join(root, 'package.json'), 'utf8') const prompts = createPrompts({ - confirmInstall: () => Promise.resolve(null), + selectMethod: () => Promise.resolve('hooks'), + selectTargets: () => Promise.resolve(['github', 'claude', 'codex']), + confirmUserScopeHooks: () => Promise.resolve(null), }) await runConsumerInstall({ + copilotHome, discovered: scanForIntents(root, { scope: 'local' }).packages, + homeDir, prompts, root, }) @@ -942,6 +986,10 @@ describe('consumer install', () => { originalPackageJson, ) expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(readIntentDeliveryConfig(root)).toBeNull() + expect(existsSync(join(copilotHome, 'hooks', 'hooks.json'))).toBe(false) + expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(false) + expect(existsSync(join(homeDir, '.codex', 'hooks.json'))).toBe(false) expect(existsSync(join(root, '.agents'))).toBe(false) }) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index ef41d762..79d712f4 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -10,8 +10,9 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runInstallCommand } from '../src/commands/install/command.js' +import { readIntentConsumerConfig } from '../src/commands/install/config.js' import { buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, @@ -19,6 +20,7 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from '../src/commands/install/guidance.js' +import { readIntentLockfile } from '../src/core/lockfile/lockfile.js' import { packageVersionToPin } from '../src/shared/command-runner.js' import type { IntentPackage, @@ -28,11 +30,13 @@ import type { const mapPromptMocks = vi.hoisted(() => ({ selectClackMapTarget: vi.fn<(root: string) => Promise>(), + selectClackSkills: vi.fn(), })) vi.mock('../src/commands/install/prompts.js', async (importOriginal) => ({ ...(await importOriginal()), selectClackMapTarget: mapPromptMocks.selectClackMapTarget, + selectClackSkills: mapPromptMocks.selectClackSkills, })) const tempDirs: Array = [] @@ -75,6 +79,89 @@ function tempRoot(): string { return root } +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2), 'utf8') +} + +function bootstrapProject(): string { + const root = tempRoot() + writeJson(join(root, 'package.json'), { name: 'app', private: true }) + 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 writeFetchingSkill( + root: string, + frontmatterLines: Array, +): void { + writeFileSync( + join( + root, + 'node_modules', + '@tanstack', + 'query', + 'skills', + 'fetching', + 'SKILL.md', + ), + `---\n${frontmatterLines.join('\n')}\n---\n`, + 'utf8', + ) +} + +function bootstrapChdir(): { + root: string + packageJsonPath: string + originalPackageJson: string +} { + const root = bootstrapProject() + const packageJsonPath = join(root, 'package.json') + const originalPackageJson = readFileSync(packageJsonPath, 'utf8') + process.chdir(root) + return { root, packageJsonPath, originalPackageJson } +} + +function mockBootstrapSelection(target: string | null): void { + mapPromptMocks.selectClackSkills.mockResolvedValueOnce({ + mode: 'all-found', + }) + mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce(target) +} + +function configuredMapProject(): string { + const root = tempRoot() + writeJson(join(root, 'package.json'), { + name: 'app', + intent: { skills: ['pkg'], exclude: [] }, + }) + return root +} + +function expectNoBootstrapWrites( + root: string, + originalPackageJson: string, +): void { + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) +} + function skill(overrides: Partial): SkillEntry { return { name: 'core', @@ -854,40 +941,219 @@ tanstackIntent: }) describe('install map destination command', () => { - it('does not prompt or write when there are no mappings', async () => { - const root = tempRoot() - process.chdir(root) + beforeEach(() => { setTTY(true) + }) + + it('bootstraps trust and writes the selected map without delivery state', async () => { + const root = bootstrapProject() + const targetPath = join(root, '.github', 'copilot-instructions.md') + process.chdir(root) + mockBootstrapSelection('.github/copilot-instructions.md') await runInstallCommand({ map: true }, () => Promise.resolve(scanResult([])), ) - expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() - expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect(readIntentLockfile(join(root, 'intent.lock')).status).toBe('found') + expect(readFileSync(targetPath, 'utf8')).toContain( + 'id: "@tanstack/query#fetching"', + ) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) }) - it('updates an existing managed target without prompting', async () => { + it('writes trust only after the bootstrap map verifies', async () => { + const { root, packageJsonPath, originalPackageJson } = bootstrapChdir() + const targetPath = join(root, 'AGENTS.md') + writeFetchingSkill(root, [ + 'name: fetching', + 'description: Edit /Users/example/project files', + ]) + mockBootstrapSelection('AGENTS.md') + + await expect( + runInstallCommand({ map: true }, () => Promise.resolve(scanResult([]))), + ).rejects.toThrow('Install verification failed for AGENTS.md:') + + expect(readFileSync(targetPath, 'utf8')).toContain( + 'Edit /Users/example/project files', + ) + expect(readFileSync(packageJsonPath, 'utf8')).toBe(originalPackageJson) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) + }) + + it('falls through to policed map behavior when the TTY root has no package', async () => { const root = tempRoot() - const targetPath = join(root, 'CLAUDE.md') process.chdir(root) - setTTY(true) - writeFileSync(targetPath, exampleBlock) + const scan = vi.fn(() => Promise.resolve(scanResult([]))) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ map: true }, scan) + + expect(scan).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith('No intent-enabled skills found.') + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('does not bootstrap without a lock outside a TTY', async () => { + const { root, originalPackageJson } = bootstrapChdir() + setTTY(false) + const scan = vi.fn(() => Promise.resolve(scanResult([]))) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ map: true }, scan) + + expect(scan).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledWith('No intent-enabled skills found.') + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('preserves existing policy without a lock and does not bootstrap', async () => { + const root = bootstrapProject() + const packageJsonPath = join(root, 'package.json') + const originalPackageJson = `{ + "name": "app", + "intent": { + "skills": ["@tanstack/query"], + "exclude": [] + } +} +` + writeFileSync(packageJsonPath, originalPackageJson) + process.chdir(root) + const scan = vi.fn(() => Promise.resolve(scanResult([]))) + + await runInstallCommand({ map: true }, scan) + + expect(scan).toHaveBeenCalledOnce() + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('writes nothing when bootstrap skill selection is cancelled', async () => { + const { root, originalPackageJson } = bootstrapChdir() + mapPromptMocks.selectClackSkills.mockResolvedValueOnce(null) + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('writes nothing when bootstrap map destination selection is cancelled', async () => { + const { root, originalPackageJson } = bootstrapChdir() + mockBootstrapSelection(null) + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect(mapPromptMocks.selectClackMapTarget).toHaveBeenCalledWith( + process.cwd(), + ) + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('does not prompt or write when bootstrap finds no actionable skills', async () => { + const { root, originalPackageJson } = bootstrapChdir() + writeFetchingSkill(root, [ + 'name: fetching', + 'description: Query reference', + 'type: reference', + ]) + const scan = vi.fn(() => Promise.resolve(scanResult([]))) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ map: true }, scan) + + expect(log).toHaveBeenCalledWith('No intent-enabled skills found.') + expect(scan).not.toHaveBeenCalled() + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('dry-runs bootstrap map output without writing trust or map files', async () => { + const { root, originalPackageJson } = bootstrapChdir() + mockBootstrapSelection('.github/copilot-instructions.md') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runInstallCommand({ dryRun: true, map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect(log.mock.calls.flat().join('\n')).toContain( + 'Generated 1 mapping for .github/copilot-instructions.md.', + ) + expect(log.mock.calls.flat().join('\n')).toContain( + 'id: "@tanstack/query#fetching"', + ) + expectNoBootstrapWrites(root, originalPackageJson) + }) + + it('writes bootstrap artifacts at the workspace root from a package leaf', async () => { + const root = bootstrapProject() + const leaf = join(root, 'packages', 'app') + writeJson(join(root, 'package.json'), { + name: 'workspace', + private: true, + workspaces: ['packages/*'], + }) + writeJson(join(leaf, 'package.json'), { name: 'app', private: true }) + process.chdir(leaf) + mockBootstrapSelection('AGENTS.md') + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), + ) + + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + expect(readIntentLockfile(join(root, 'intent.lock')).status).toBe('found') + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + 'id: "@tanstack/query#fetching"', + ) + expect(existsSync(join(leaf, 'intent.lock'))).toBe(false) + expect(existsSync(join(leaf, 'AGENTS.md'))).toBe(false) + + mapPromptMocks.selectClackMapTarget.mockClear() await runInstallCommand({ map: true }, () => Promise.resolve(mappedScanResult()), ) + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + 'id: "pkg#core"', + ) + expect(existsSync(join(leaf, 'AGENTS.md'))).toBe(false) expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() - expect(readFileSync(targetPath, 'utf8')).toContain('id: "pkg#core"') - expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) - it('writes nothing when map destination selection is cancelled', async () => { + it('targets the current package leaf during fallback', async () => { const root = tempRoot() - process.chdir(root) - setTTY(true) - mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce(null) + const leaf = join(root, 'packages', 'app') + writeJson(join(root, 'package.json'), { + name: 'workspace', + private: true, + workspaces: ['packages/*'], + intent: { skills: ['pkg'], exclude: [] }, + }) + writeJson(join(leaf, 'package.json'), { name: 'app', private: true }) + process.chdir(leaf) + mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce('AGENTS.md') await runInstallCommand({ map: true }, () => Promise.resolve(mappedScanResult()), @@ -896,27 +1162,57 @@ describe('install map destination command', () => { expect(mapPromptMocks.selectClackMapTarget).toHaveBeenCalledWith( process.cwd(), ) + expect(readFileSync(join(leaf, 'AGENTS.md'), 'utf8')).toContain( + 'id: "pkg#core"', + ) expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) - it('reports the selected map target during dry run without writing', async () => { - const root = tempRoot() + it.each([{ global: true }, { globalOnly: true }])( + 'skips bootstrap and maps the scanned result with global flags (%o)', + async (flag) => { + const { root, originalPackageJson } = bootstrapChdir() + const scan = vi.fn(() => Promise.resolve(mappedScanResult())) + mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce('AGENTS.md') + + await runInstallCommand({ map: true, ...flag }, scan) + + expect(mapPromptMocks.selectClackSkills).not.toHaveBeenCalled() + expect(scan).toHaveBeenCalledOnce() + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + 'id: "pkg#core"', + ) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + originalPackageJson, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + }, + ) + + it('does not prompt or write when there are no mappings', async () => { + const root = configuredMapProject() process.chdir(root) - setTTY(true) - mapPromptMocks.selectClackMapTarget.mockResolvedValueOnce( - '.github/copilot-instructions.md', + + await runInstallCommand({ map: true }, () => + Promise.resolve(scanResult([])), ) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await runInstallCommand({ dryRun: true, map: true }, () => + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('updates an existing managed target without prompting', async () => { + const root = configuredMapProject() + const targetPath = join(root, 'CLAUDE.md') + process.chdir(root) + writeFileSync(targetPath, exampleBlock) + + await runInstallCommand({ map: true }, () => Promise.resolve(mappedScanResult()), ) - expect(log.mock.calls.flat().join('\n')).toContain( - 'Generated 1 mapping for .github/copilot-instructions.md.', - ) - expect(existsSync(join(root, '.github', 'copilot-instructions.md'))).toBe( - false, - ) + expect(mapPromptMocks.selectClackMapTarget).not.toHaveBeenCalled() + expect(readFileSync(targetPath, 'utf8')).toContain('id: "pkg#core"') + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) }) diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index f8498c4f..89f91f8f 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -13,6 +13,9 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { runInteractiveInstall } from '../../src/commands/install/command.js' +import { runConsumerInstall } from '../../src/commands/install/consumer.js' +import { createIntentFsCache } from '../../src/discovery/fs-cache.js' +import { scanForIntents } from '../../src/discovery/scanner.js' import type { InstallerPrompter } from '../../src/commands/install/consumer.js' /** @@ -130,7 +133,7 @@ function createInstallPrompts(method: 'hooks' | 'symlink'): InstallerPrompter { selectTargets: () => Promise.resolve(method === 'hooks' ? ['claude'] : ['agents']), confirmSymlink: () => Promise.resolve(true), - confirmUserScopeHooks: () => Promise.resolve(false), + confirmUserScopeHooks: () => Promise.resolve(true), selectSkills: () => Promise.resolve({ mode: 'all-found' }), confirmInstall: () => Promise.resolve('install'), } @@ -169,14 +172,23 @@ describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { it('installs hooks and locks a zip-backed dependency', async () => { const cwd = scaffoldBerryProject() - - await runInteractiveInstall({ - cwd, + const homeDir = mkdtempSync(join(realTmpdir, 'intent-berry-home-')) + tempDirs.push(homeDir) + const fsCache = createIntentFsCache() + const scanOptions = { scope: 'local' as const, fsCache } + const scan = scanForIntents(cwd, scanOptions) + + await runConsumerInstall({ + discovered: scan.packages, + homeDir, prompts: createInstallPrompts('hooks'), + readFs: fsCache.getReadFs(), + root: cwd, }) expect(existsSync(join(cwd, 'intent.lock'))).toBe(true) - expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(homeDir, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(cwd, '.claude', 'settings.json'))).toBe(false) }, 120_000) it('rejects symlink delivery for a zip-backed dependency before writing', async () => {