From 9a04a8b3e5436b9b929e8c147c19a370662b39a3 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Sun, 19 Jul 2026 20:39:35 -0700 Subject: [PATCH 01/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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/60] 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 () => { From 59ee0b3a554ed5e5287e7bdd2e9ba19b73856b14 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 30 Jul 2026 18:58:15 -0700 Subject: [PATCH 57/60] feat(intent): implement static guidance block installation method --- .../intent/src/commands/install/command.ts | 25 +-- .../intent/src/commands/install/consumer.ts | 143 +++++++++++++++--- .../intent/src/commands/install/delivery.ts | 11 +- .../intent/src/commands/install/guidance.ts | 54 ++++++- .../intent/src/commands/install/prompts.ts | 12 +- .../intent/tests/consumer-install.test.ts | 136 ++++++++++++++++- packages/intent/tests/install-writer.test.ts | 7 + .../integration/pnp-berry-corepack.test.ts | 2 + 8 files changed, 330 insertions(+), 60 deletions(-) diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts index 8ad96ad6..285d9402 100644 --- a/packages/intent/src/commands/install/command.ts +++ b/packages/intent/src/commands/install/command.ts @@ -20,8 +20,7 @@ import { buildIntentSkillsBlock, findExistingIntentSkillsBlockTargetPath, resolveMapTargetPath, - verifyIntentSkillsBlockFile, - writeIntentSkillsBlock, + writeVerifiedIntentSkillsBlock, } from './guidance.js' import { readIntentConsumerConfig, @@ -55,6 +54,7 @@ async function runInstallWithPrompts({ await runConsumerInstall({ discovered: scan.packages, dryRun, + packageManager: scan.packageManager, prompts, readFs: fsCache.getReadFs(), root, @@ -283,30 +283,15 @@ export async function runInstallCommand( return } - const result = writeIntentSkillsBlock({ - ...generated, + const result = writeVerifiedIntentSkillsBlock({ + generated, root, targetPath, + formatTargetLabel: formatTargetPath, }) if (!result.targetPath) return - const target = formatTargetPath(result.targetPath) - const verification = verifyIntentSkillsBlockFile({ - expectedBlock: generated.block, - expectedMappingCount: generated.mappingCount, - targetPath: result.targetPath, - }) - - if (!verification.ok) { - fail( - [ - `Install verification failed for ${target}:`, - ...verification.errors.map((error) => `- ${error}`), - ].join('\n'), - ) - } - if (bootstrapWrites) { writeIntentLockfile(join(root, 'intent.lock'), bootstrapWrites.lockfile) writeTextFileAtomic( diff --git a/packages/intent/src/commands/install/consumer.ts b/packages/intent/src/commands/install/consumer.ts index 81f321a5..07fed862 100644 --- a/packages/intent/src/commands/install/consumer.ts +++ b/packages/intent/src/commands/install/consumer.ts @@ -10,6 +10,7 @@ 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 { detectIntentAudience } from '../../shared/environment.js' import { nodeReadFs } from '../../shared/utils.js' import { runSyncCommand } from '../sync/command.js' import { hasNonNativeLinkSource, reconcileManagedLinks } from '../sync/links.js' @@ -33,8 +34,15 @@ import { buildSkillSelectionPlan, summarizeInstallDeltaInventory, } from './plan.js' +import { + buildIntentSkillsBlockFromPackages, + findExistingIntentSkillsBlockTargetPath, + resolveMapTargetPath, + writeVerifiedIntentSkillsBlock, +} from './guidance.js' import type { IntentConsumerConfig } from './config.js' import type { + DeliveryMethod, InstallMethod, InstallTarget, IntentDeliveryConfig, @@ -42,15 +50,16 @@ import type { 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' +import type { IntentPackage, ScanResult } from '../../shared/types.js' export type InstallConfirmation = 'install' | 'back' | null export interface InstallerPrompter { complete: (message: string) => void selectMethod: () => Promise + selectMapTarget: (root: string) => Promise selectTargets: ( - method: InstallMethod, + method: DeliveryMethod, detected: ReadonlyArray, ) => Promise | null> confirmSymlink: () => Promise @@ -70,6 +79,7 @@ export interface RunConsumerInstallOptions { discovered: ReadonlyArray dryRun?: boolean homeDir?: string + packageManager: ScanResult['packageManager'] prompts: InstallerPrompter readFs?: ReadFs root: string @@ -110,6 +120,7 @@ export async function runConsumerInstall({ discovered, dryRun = false, homeDir, + packageManager, prompts, readFs = nodeReadFs, root, @@ -120,6 +131,35 @@ export async function runConsumerInstall({ const existingConfig = readIntentConsumerConfig(packageJson) const existingLock = readIntentLockfile(join(root, 'intent.lock')) const delivery = dryRun ? null : readIntentDeliveryConfig(root) + async function resolveSkillSelection(): Promise<{ + plan: ReturnType | null + config: IntentConsumerConfig + skillCount: number + } | null> { + 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 null + const plan = selection + ? buildSkillSelectionPlan(discovered, selection) + : null + const config = plan + ? { skills: plan.skills, exclude: plan.exclude } + : existingConfig + const skillCount = (plan?.packages ?? discovered).reduce( + (count, pkg) => + count + + pkg.skills.filter( + (skill) => !('status' in skill) || skill.status === 'enabled', + ).length, + 0, + ) + return { plan, config, skillCount } + } if (delivery) { const inventory = buildInstallDeltaInventory( discovered, @@ -158,6 +198,79 @@ export async function runConsumerInstall({ for (;;) { const method = delivery ? delivery.method : await prompts.selectMethod() if (!method) return + if (method === 'map') { + if (discovered.every((pkg) => pkg.skills.length === 0)) { + prompts.complete('No intent-enabled skills found.') + return + } + const resolved = await resolveSkillSelection() + if (!resolved) return + const { config, skillCount } = resolved + const policy = applySourcePolicy( + { packages: [...discovered] }, + { + config: parseSkillSources(config.skills), + excludeMatchers: compileExcludePatterns(config.exclude), + }, + ) + const generated = buildIntentSkillsBlockFromPackages( + policy.packages, + packageManager, + ) + if (generated.mappingCount === 0) { + prompts.complete('No intent-enabled skills found.') + return + } + + const existingTargetPath = findExistingIntentSkillsBlockTargetPath(root) + let targetPath: string + if (existingTargetPath) { + targetPath = existingTargetPath + } else { + const selectedTarget = await prompts.selectMapTarget(root) + if (!selectedTarget) return + targetPath = resolveMapTargetPath(root, selectedTarget) + } + const relativeTarget = toProjectRelativePath(root, targetPath) + + if (dryRun) { + console.log( + `Generated ${generated.mappingCount} ${generated.mappingCount === 1 ? 'mapping' : 'mappings'} for ${relativeTarget}.`, + ) + console.log(generated.block) + prompts.complete('Dry run complete.') + return + } + + const result = writeVerifiedIntentSkillsBlock({ + generated, + root, + targetPath, + formatTargetLabel: () => relativeTarget, + }) + if (!result.targetPath) return + + const updatedPackageJson = updateIntentConsumerConfigText( + packageJson, + config, + ) + if (updatedPackageJson !== packageJson) { + writeTextFileAtomic(packageJsonPath, updatedPackageJson) + } + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: buildCurrentLockfileSources(policy.packages, readFs), + }) + if (result.status !== 'unchanged' && detectIntentAudience() === 'human') { + console.log( + 'The intent-skills block is a snapshot and does not update when dependencies change. Re-run `intent install --map` to regenerate it.', + ) + } + prompts.complete( + `Installed ${skillCount} ${skillCount === 1 ? 'skill' : 'skills'} to ${relativeTarget} as a static guidance block.`, + ) + return + } const targets = delivery ? delivery.targets : await prompts.selectTargets(method, detectInstallTargets(root)) @@ -170,32 +283,14 @@ export async function runConsumerInstall({ prompts.complete('No intent-enabled skills found.') return } - 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 resolved = await resolveSkillSelection() + if (!resolved) return + const { plan, config, skillCount } = resolved const deliveryConfig = { method, targets } const installation = { config, delivery: deliveryConfig, - skillCount: (plan?.packages ?? discovered).reduce( - (count, pkg) => - count + - pkg.skills.filter( - (skill) => !('status' in skill) || skill.status === 'enabled', - ).length, - 0, - ), + skillCount, } const confirmation = await prompts.confirmInstall(installation) if (confirmation === null) return diff --git a/packages/intent/src/commands/install/delivery.ts b/packages/intent/src/commands/install/delivery.ts index 069b2e41..7dc491f1 100644 --- a/packages/intent/src/commands/install/delivery.ts +++ b/packages/intent/src/commands/install/delivery.ts @@ -5,7 +5,8 @@ import { writeIntentGitExclude } from '../sync/gitignore.js' export const DELIVERY_CONFIG_PATH = '.intent/delivery.json' -export type InstallMethod = 'symlink' | 'hooks' +export type DeliveryMethod = 'symlink' | 'hooks' +export type InstallMethod = DeliveryMethod | 'map' export type InstallTarget = | 'agents' | 'github' @@ -15,7 +16,7 @@ export type InstallTarget = | 'claude' export interface IntentDeliveryConfig { - method: InstallMethod + method: DeliveryMethod targets: Array } @@ -32,14 +33,14 @@ export const INSTALL_TARGETS: ReadonlyArray<{ ] const INSTALL_METHODS: Readonly< - Record> + Record> > = { symlink: new Set(INSTALL_TARGETS.map((target) => target.id)), hooks: new Set(['github', 'codex', 'claude']), } export function installTargetsForMethod( - method: InstallMethod, + method: DeliveryMethod, ): typeof INSTALL_TARGETS { return INSTALL_TARGETS.filter((target) => INSTALL_METHODS[method].has(target.id), @@ -96,7 +97,7 @@ function validateIntentDeliveryConfig(value: unknown): IntentDeliveryConfig { 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 method = value.method as DeliveryMethod const targets: Array = [] const seen = new Set() for (const target of value.targets) { diff --git a/packages/intent/src/commands/install/guidance.ts b/packages/intent/src/commands/install/guidance.ts index bb21f04f..a3d3500c 100644 --- a/packages/intent/src/commands/install/guidance.ts +++ b/packages/intent/src/commands/install/guidance.ts @@ -9,11 +9,16 @@ import { } from 'node:fs' import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path' import { parse as parseYaml } from 'yaml' +import { fail } from '../../shared/cli-error.js' 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' +import type { + IntentPackage, + ScanResult, + SkillEntry, +} from '../../shared/types.js' const INTENT_SKILLS_START = '' const INTENT_SKILLS_END = '' @@ -345,8 +350,9 @@ function formatWhen(packageName: string, skill: SkillEntry): string { return description || `Use ${packageName} ${skill.name}` } -export function buildIntentSkillsBlock( - scanResult: ScanResult, +export function buildIntentSkillsBlockFromPackages( + packages: ReadonlyArray, + packageManager: ScanResult['packageManager'], ): IntentSkillsBlockResult { const lines = [ INTENT_SKILLS_START, @@ -355,7 +361,7 @@ export function buildIntentSkillsBlock( ] let mappingCount = 0 - for (const pkg of [...scanResult.packages].sort(compareNames)) { + for (const pkg of [...packages].sort(compareNames)) { for (const skill of [...pkg.skills].sort(compareNames)) { if (!isGeneratedMappingSkill(skill)) continue @@ -366,7 +372,7 @@ export function buildIntentSkillsBlock( lines.push( ` run: ${quoteYamlString( formatIntentCommand( - scanResult.packageManager, + packageManager, `load ${formatSkillUse(pkg.name, skill.name)}`, ), )}`, @@ -386,6 +392,15 @@ export function buildIntentSkillsBlock( } } +export function buildIntentSkillsBlock( + scanResult: ScanResult, +): IntentSkillsBlockResult { + return buildIntentSkillsBlockFromPackages( + scanResult.packages, + scanResult.packageManager, + ) +} + export function buildIntentSkillGuidanceBlock( packageManager: ScanResult['packageManager'] = 'unknown', ): IntentSkillsBlockResult { @@ -529,3 +544,32 @@ export function writeIntentSkillsBlock({ targetPath, } } + +export function writeVerifiedIntentSkillsBlock({ + generated, + root, + targetPath, + formatTargetLabel, +}: { + generated: IntentSkillsBlockResult + root: string + targetPath: string + formatTargetLabel: (resolvedTargetPath: string) => string +}): WriteIntentSkillsBlockResult { + const result = writeIntentSkillsBlock({ ...generated, root, targetPath }) + if (!result.targetPath) return result + const verification = verifyIntentSkillsBlockFile({ + expectedBlock: generated.block, + expectedMappingCount: generated.mappingCount, + targetPath: result.targetPath, + }) + if (!verification.ok) { + fail( + [ + `Install verification failed for ${formatTargetLabel(result.targetPath)}:`, + ...verification.errors.map((error) => `- ${error}`), + ].join('\n'), + ) + } + return result +} diff --git a/packages/intent/src/commands/install/prompts.ts b/packages/intent/src/commands/install/prompts.ts index f1f01eee..d80858a0 100644 --- a/packages/intent/src/commands/install/prompts.ts +++ b/packages/intent/src/commands/install/prompts.ts @@ -14,7 +14,11 @@ 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' +import type { + DeliveryMethod, + InstallMethod, + InstallTarget, +} from './delivery.js' import type { SkillSelection } from './plan.js' import type { IntentPackage } from '../../shared/types.js' @@ -130,12 +134,16 @@ export function createClackInstallerPrompter(): InstallerPrompter { options: [ { value: 'symlink', label: 'Symlink skill folders' }, { value: 'hooks', label: 'Install lifecycle hooks' }, + { value: 'map', label: 'Static file (guidance block)' }, ], }), ) }, + async selectMapTarget(root: string): Promise { + return selectClackMapTarget(root) + }, async selectTargets( - method: InstallMethod, + method: DeliveryMethod, detected: ReadonlyArray, ): Promise | null> { const targets = installTargetsForMethod(method) diff --git a/packages/intent/tests/consumer-install.test.ts b/packages/intent/tests/consumer-install.test.ts index 8db005d6..72798257 100644 --- a/packages/intent/tests/consumer-install.test.ts +++ b/packages/intent/tests/consumer-install.test.ts @@ -15,7 +15,7 @@ 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 { runConsumerInstall as runConsumerInstallImpl } from '../src/commands/install/consumer.js' import { detectInstallTargets, readIntentDeliveryConfig, @@ -35,7 +35,10 @@ import { } 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' +import type { + InstallerPrompter, + RunConsumerInstallOptions, +} from '../src/commands/install/consumer.js' const clackPromptMocks = vi.hoisted(() => ({ cancel: vi.fn(), @@ -140,6 +143,7 @@ function createPrompts( return { complete: () => {}, selectMethod: () => Promise.resolve('symlink'), + selectMapTarget: () => Promise.resolve('AGENTS.md'), selectTargets: () => Promise.resolve(['agents']), confirmSymlink: () => Promise.resolve(true), confirmUserScopeHooks: () => Promise.resolve(true), @@ -149,6 +153,12 @@ function createPrompts( } } +function runConsumerInstall( + options: Omit, +): Promise { + return runConsumerInstallImpl({ ...options, packageManager: 'pnpm' }) +} + afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) @@ -217,14 +227,18 @@ describe('consumer install', () => { }) }) - it('offers only implemented interactive install methods', async () => { + it('offers implemented interactive install choices', 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']) + expect(options.map((option) => option.value)).toEqual([ + 'symlink', + 'hooks', + 'map', + ]) }) it('requests permission to install user-level hooks across repositories', async () => { @@ -540,6 +554,120 @@ describe('consumer install', () => { expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) + it('installs selected skills as a static guidance block without delivery state', async () => { + const root = createProject() + const complete = vi.fn() + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete, + selectMethod: () => Promise.resolve('map'), + selectMapTarget: () => Promise.resolve('notes/assistant.md'), + selectTargets: () => + Promise.reject(new Error('map must not select delivery targets')), + confirmInstall: () => + Promise.reject(new Error('map must not confirm delivery')), + }), + root, + }) + + const target = readFileSync(join(root, 'notes', 'assistant.md'), 'utf8') + expect(target).toContain('id: "@tanstack/query#fetching"') + expect(target).toContain('load @tanstack/query#fetching') + expect( + readIntentConsumerConfig( + readFileSync(join(root, 'package.json'), 'utf8'), + ), + ).toEqual({ skills: ['@tanstack/query'], exclude: [] }) + 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, '.intent', 'delivery.json'))).toBe(false) + expect(complete).toHaveBeenCalledWith( + 'Installed 1 skill to notes/assistant.md as a static guidance block.', + ) + }) + + it('writes nothing when the static selection produces no mappings', async () => { + const root = createProject() + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + writeIntentLockfile(join(root, 'intent.lock'), { + lockfileVersion: 1, + sources: [], + }) + const lockBefore = readFileSync(join(root, 'intent.lock'), 'utf8') + const complete = vi.fn() + + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + prompts: createPrompts({ + complete, + selectMethod: () => Promise.resolve('map'), + selectMapTarget: () => + Promise.reject(new Error('empty map must not select a target')), + selectSkills: () => + Promise.resolve({ mode: 'individual', enabled: [] }), + }), + root, + }) + + expect(complete).toHaveBeenCalledWith('No intent-enabled skills found.') + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(readFileSync(join(root, 'intent.lock'), 'utf8')).toBe(lockBefore) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) + }) + + it('prints a static guidance block without writing during dry run', async () => { + const root = createProject() + const packageJsonBefore = readFileSync(join(root, 'package.json'), 'utf8') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const complete = vi.fn() + + try { + await runConsumerInstall({ + discovered: scanForIntents(root, { scope: 'local' }).packages, + dryRun: true, + prompts: createPrompts({ + complete, + selectMethod: () => Promise.resolve('map'), + selectMapTarget: () => Promise.resolve('notes/assistant.md'), + selectTargets: () => + Promise.reject(new Error('map must not select delivery targets')), + }), + root, + }) + + const output = log.mock.calls.flat().join('\n') + expect(output).toContain('Generated 1 mapping for notes/assistant.md.') + expect(output).toContain('id: "@tanstack/query#fetching"') + } finally { + log.mockRestore() + } + + expect(complete).toHaveBeenCalledWith('Dry run complete.') + expect(existsSync(join(root, 'notes', 'assistant.md'))).toBe(false) + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe( + packageJsonBefore, + ) + expect(existsSync(join(root, 'intent.lock'))).toBe(false) + expect(existsSync(join(root, '.intent', 'delivery.json'))).toBe(false) + }) + it('preserves malformed install state and managed links when sync fails', async () => { const root = createProject() await runConsumerInstall({ diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 79d712f4..4868a72f 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -16,6 +16,7 @@ import { readIntentConsumerConfig } from '../src/commands/install/config.js' import { buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, + buildIntentSkillsBlockFromPackages, resolveMapTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, @@ -299,6 +300,12 @@ describe('install writer block builder', () => { const generated = buildIntentSkillsBlock(result) + expect( + buildIntentSkillsBlockFromPackages( + result.packages, + result.packageManager, + ), + ).toEqual(generated) expect(generated.mappingCount).toBe(3) expect(generated.block).toBe(` # TanStack Intent - before editing files, run the matching guidance command. diff --git a/packages/intent/tests/integration/pnp-berry-corepack.test.ts b/packages/intent/tests/integration/pnp-berry-corepack.test.ts index 89f91f8f..e213385a 100644 --- a/packages/intent/tests/integration/pnp-berry-corepack.test.ts +++ b/packages/intent/tests/integration/pnp-berry-corepack.test.ts @@ -130,6 +130,7 @@ function createInstallPrompts(method: 'hooks' | 'symlink'): InstallerPrompter { return { complete: () => {}, selectMethod: () => Promise.resolve(method), + selectMapTarget: () => Promise.resolve('AGENTS.md'), selectTargets: () => Promise.resolve(method === 'hooks' ? ['claude'] : ['agents']), confirmSymlink: () => Promise.resolve(true), @@ -181,6 +182,7 @@ describe.skipIf(!shouldRun)('Yarn Berry PnP (zip-backed dependencies)', () => { await runConsumerInstall({ discovered: scan.packages, homeDir, + packageManager: scan.packageManager, prompts: createInstallPrompts('hooks'), readFs: fsCache.getReadFs(), root: cwd, From 53c0ef84817af0e49a9f6bc8ec0617a5411c9b62 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 30 Jul 2026 19:57:02 -0700 Subject: [PATCH 58/60] feat(intent): enhance validation and catalogue handling for skills --- packages/intent/src/commands/validate.ts | 224 +++++++++++++++++- packages/intent/src/session-catalog.ts | 32 +-- .../intent/src/skills/catalogue-contract.ts | 16 ++ packages/intent/src/skills/categories.ts | 10 + packages/intent/tests/cli.test.ts | 210 ++++++++++++++++ 5 files changed, 462 insertions(+), 30 deletions(-) create mode 100644 packages/intent/src/skills/catalogue-contract.ts diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 961f0d99..ebbce3a0 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -8,7 +8,20 @@ import { basename, dirname, join, relative, resolve } from 'node:path' import { fail, isCliFailure } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' import { findWorkspacePackages } from '../setup/workspace-patterns.js' +import { + buildSessionCatalogue, + formatSessionCatalogue, +} from '../session-catalog.js' +import { containsLocalPath } from '../shared/local-path.js' +import { isKnownSkillType } from '../skills/categories.js' +import { + SESSION_CATALOGUE_MAX_BYTES, + SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH, + normalizeWhitespace, +} from '../skills/catalogue-contract.js' +import { isSkillUseParseError, parseSkillUse } from '../skills/use.js' import { printWarnings } from './support.js' +import type { IntentSkillList, IntentSkillSummary } from '../core/index.js' import type { ProjectContext } from '../core/project-context.js' interface ValidationError { @@ -32,6 +45,25 @@ interface SetVersionPlan { filePath: string } +interface CatalogueValidationSkill { + file: string + rawDescription: string + summary: IntentSkillSummary +} + +function catalogueInput(skills: Array): IntentSkillList { + return { + packageManager: 'unknown', + skills, + packages: [], + hiddenSourceCount: 0, + hiddenSources: [], + warnings: [], + notices: [], + conflicts: [], + } +} + export interface ValidateCommandOptions { check?: boolean fix?: boolean @@ -325,22 +357,23 @@ function collectAgentSkillSpecWarnings({ message: 'Agent Skills spec warning: compatibility should be a non-empty string', }) - } else if (fm.compatibility.length > 500) { + } else if ([...fm.compatibility].length > 500) { warnings.push({ file: rel, - message: `Agent Skills spec warning: compatibility exceeds 500 characters (${fm.compatibility.length} chars)`, + message: `Agent Skills spec warning: compatibility exceeds 500 characters (${[...fm.compatibility].length} chars)`, }) } } if ( fm['allowed-tools'] !== undefined && - typeof fm['allowed-tools'] !== 'string' + (typeof fm['allowed-tools'] !== 'string' || + fm['allowed-tools'].trim().length === 0) ) { warnings.push({ file: rel, message: - 'Agent Skills spec warning: allowed-tools should be a space-separated string', + 'Agent Skills spec warning: allowed-tools should be a non-empty space-separated string', }) } @@ -408,6 +441,10 @@ async function runValidateCommandInternal( const fixPlans: Array = [] const setVersionPlans: Array = [] let validatedCount = 0 + const reportCatalogueWarning = (warning: ValidationWarning): void => { + if (options.check) errors.push(warning) + else warnings.push(formatWarning(warning)) + } if (explicitDir && findSkillFiles(skillsDirs[0]!).length === 0) { fail('No SKILL.md files found') @@ -424,6 +461,20 @@ async function runValidateCommandInternal( cwd: process.cwd(), targetPath: skillsDir, }) + const packageRoot = validateContext.packageRoot ?? dirname(skillsDir) + let packageName = basename(packageRoot) + if ( + validateContext.targetPackageJsonPath && + existsSync(validateContext.targetPackageJsonPath) + ) { + try { + const packageJson = JSON.parse( + readFileSync(validateContext.targetPackageJsonPath, 'utf8'), + ) as { name?: unknown } + if (typeof packageJson.name === 'string') packageName = packageJson.name + } catch {} + } + const catalogueSkills: Array = [] for (const filePath of skillFiles) { const rel = relative(process.cwd(), filePath) @@ -440,9 +491,9 @@ async function runValidateCommandInternal( continue } - let fm: Record + let parsedFrontmatter: unknown try { - fm = parseYaml(match[1]) as Record + parsedFrontmatter = parseYaml(match[1]) } catch (err) { const detail = err instanceof Error ? err.message : String(err) errors.push({ @@ -451,6 +502,14 @@ async function runValidateCommandInternal( }) continue } + if (!isRecord(parsedFrontmatter)) { + errors.push({ + file: rel, + message: 'YAML frontmatter must be a mapping', + }) + continue + } + const fm = parsedFrontmatter const fixPlan = collectFrontmatterFixPlan({ filePath, fm, rel }) if (fixPlan) fixPlans.push(fixPlan) @@ -467,12 +526,25 @@ async function runValidateCommandInternal( if (!fm.name) { errors.push({ file: rel, message: 'Missing required field: name' }) + } else if (typeof fm.name !== 'string' || fm.name.trim().length === 0) { + errors.push({ + file: rel, + message: 'name must be a non-empty string', + }) } if (!fm.description) { errors.push({ file: rel, message: 'Missing required field: description', }) + } else if ( + typeof fm.description !== 'string' || + fm.description.trim().length === 0 + ) { + errors.push({ + file: rel, + message: 'description must be a non-empty string', + }) } if (typeof fm.name === 'string') { @@ -533,11 +605,14 @@ async function runValidateCommandInternal( } } - if (typeof fm.description === 'string' && fm.description.length > 1024) { - errors.push({ - file: rel, - message: `Description exceeds 1024 character limit (${fm.description.length} chars)`, - }) + if (typeof fm.description === 'string') { + const descriptionLength = [...fm.description].length + if (descriptionLength > 1024) { + errors.push({ + file: rel, + message: `Description exceeds 1024 character limit (${descriptionLength} chars)`, + }) + } } if ( @@ -554,6 +629,27 @@ async function runValidateCommandInternal( ...collectAgentSkillSpecWarnings({ fm, rel }).map(formatWarning), ) + if (typeof fm.description === 'string') { + const skillName = relative(skillsDir, dirname(filePath)).replace( + /\\/g, + '/', + ) + catalogueSkills.push({ + file: rel, + rawDescription: fm.description, + summary: { + use: `${packageName}#${skillName}`, + packageName, + packageRoot, + packageVersion: '0.0.0', + packageSource: 'local', + skillName, + description: fm.description, + type: readScalarField(fm, 'type'), + }, + }) + } + const lineCount = content.split(/\r?\n/).length if (lineCount > 500) { errors.push({ @@ -563,6 +659,112 @@ async function runValidateCommandInternal( } } + const renderableSkills: Array = [] + const renderedSkills: Array<{ + description: string + input: CatalogueValidationSkill + }> = [] + for (const skill of catalogueSkills) { + try { + parseSkillUse(skill.summary.use) + } catch (error) { + reportCatalogueWarning({ + file: skill.file, + message: `malformed catalogue use: ${isSkillUseParseError(error) ? error.message : String(error)}`, + }) + continue + } + + renderableSkills.push(skill) + const renderedSkill = buildSessionCatalogue( + catalogueInput([skill.summary]), + ).skills[0] + const type = skill.summary.type + if (type !== undefined && !isKnownSkillType(type)) { + reportCatalogueWarning({ + file: skill.file, + message: `unknown metadata.type "${type}"; skill is ${renderedSkill ? 'included in' : 'excluded from'} the catalogue`, + }) + } + if (!renderedSkill) continue + + renderedSkills.push({ + description: renderedSkill.description, + input: skill, + }) + const normalizedLength = [...normalizeWhitespace(skill.rawDescription)] + .length + const hasLocalPath = containsLocalPath( + normalizeWhitespace(skill.rawDescription), + ) + if (hasLocalPath) { + reportCatalogueWarning({ + file: skill.file, + message: 'catalogue description contains a local path and is blanked', + }) + } + if ( + !hasLocalPath && + normalizedLength > SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH + ) { + const renderedLength = [...renderedSkill.description].length + reportCatalogueWarning({ + file: skill.file, + message: `catalogue description is truncated from ${normalizedLength} to ${renderedLength} characters (${normalizedLength - renderedLength} lost)`, + }) + } + } + + const packageCatalogueInput = catalogueInput( + renderableSkills.map((skill) => skill.summary), + ) + const fullCatalogue = buildSessionCatalogue(packageCatalogueInput, { + maxSkills: renderableSkills.length, + }) + const fullCatalogueText = formatSessionCatalogue(fullCatalogue, { + maxBytes: Number.MAX_SAFE_INTEGER, + }) + const defaultCatalogue = buildSessionCatalogue(packageCatalogueInput) + const defaultCatalogueLines = new Set( + formatSessionCatalogue(defaultCatalogue).split('\n'), + ) + const skillsOutsideLimits = fullCatalogue.skills.filter( + (skill) => + !defaultCatalogueLines.has(`- ${skill.id}: ${skill.description}`), + ) + if (skillsOutsideLimits.length > 0) { + const packageJsonPath = + validateContext.targetPackageJsonPath ?? + join(packageRoot, 'package.json') + reportCatalogueWarning({ + file: relative(process.cwd(), packageJsonPath), + message: `catalogue renders ${Buffer.byteLength(fullCatalogueText)}/${SESSION_CATALOGUE_MAX_BYTES} bytes; skills outside limits: ${skillsOutsideLimits.map((skill) => skill.id).join(', ')}`, + }) + } + + const skillsByDescription = new Map< + string, + Array + >() + for (const rendered of renderedSkills) { + const matches = skillsByDescription.get(rendered.description) ?? [] + matches.push(rendered.input) + skillsByDescription.set(rendered.description, matches) + } + for (const matches of skillsByDescription.values()) { + if (matches.length < 2) continue + for (const skill of matches) { + const duplicateUses = matches + .filter((match) => match !== skill) + .map((match) => match.summary.use) + .join(', ') + reportCatalogueWarning({ + file: skill.file, + message: `catalogue description duplicates ${duplicateUses}`, + }) + } + } + // In monorepos, _artifacts lives at the workspace root, not under each package's skills/ dir. const artifactsDir = join(skillsDir, '_artifacts') if (!validateContext.isMonorepo && existsSync(artifactsDir)) { diff --git a/packages/intent/src/session-catalog.ts b/packages/intent/src/session-catalog.ts index 0386db45..aa53db9f 100644 --- a/packages/intent/src/session-catalog.ts +++ b/packages/intent/src/session-catalog.ts @@ -20,6 +20,13 @@ 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 { + SESSION_CATALOGUE_MAX_BYTES, + SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH, + SESSION_CATALOGUE_MAX_SKILLS, + normalizeWhitespace, + truncateText, +} from './skills/catalogue-contract.js' import { parseSkillUse } from './skills/use.js' import { findWorkspacePackages } from './setup/workspace-patterns.js' import type * as NodePath from 'node:path' @@ -27,10 +34,7 @@ import type { IntentSkillList } from './core/index.js' import type { ReadFs } from './shared/utils.js' const CACHE_SCHEMA_VERSION = 4 -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', @@ -87,7 +91,7 @@ export function buildSessionCatalogue( result: IntentSkillList, options: { maxSkills?: number } = {}, ): SessionCatalogue { - const maxSkills = options.maxSkills ?? DEFAULT_MAX_SKILLS + const maxSkills = options.maxSkills ?? SESSION_CATALOGUE_MAX_SKILLS const allSkills = result.skills .filter(isGeneratedMappingSkill) .map((skill): SessionSkillSummary => { @@ -95,7 +99,10 @@ export function buildSessionCatalogue( const normalizedDescription = normalizeWhitespace(skill.description) const description = containsLocalPath(normalizedDescription) ? '' - : truncateText(normalizedDescription, MAX_DESCRIPTION_LENGTH) + : truncateText( + normalizedDescription, + SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH, + ) return { id: skill.use, description: description || `Use ${skill.use}`, @@ -112,7 +119,7 @@ export function formatSessionCatalogue( catalogue: SessionCatalogue, options: { maxBytes?: number } = {}, ): string { - const maxBytes = options.maxBytes ?? DEFAULT_MAX_CONTEXT_BYTES + const maxBytes = options.maxBytes ?? SESSION_CATALOGUE_MAX_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}.`, @@ -320,19 +327,6 @@ 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.` } diff --git a/packages/intent/src/skills/catalogue-contract.ts b/packages/intent/src/skills/catalogue-contract.ts new file mode 100644 index 00000000..950acedb --- /dev/null +++ b/packages/intent/src/skills/catalogue-contract.ts @@ -0,0 +1,16 @@ +export const SESSION_CATALOGUE_MAX_BYTES = 8_000 +export const SESSION_CATALOGUE_MAX_SKILLS = 50 +export const SESSION_CATALOGUE_MAX_DESCRIPTION_LENGTH = 180 + +export function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +export function truncateText(value: string, maxLength: number): string { + const codePoints = [...value] + if (codePoints.length <= maxLength) return value + return `${codePoints + .slice(0, maxLength - 3) + .join('') + .trimEnd()}...` +} diff --git a/packages/intent/src/skills/categories.ts b/packages/intent/src/skills/categories.ts index 09581f87..a155b008 100644 --- a/packages/intent/src/skills/categories.ts +++ b/packages/intent/src/skills/categories.ts @@ -3,6 +3,16 @@ import type { SkillEntry } from '../shared/types.js' export type SkillCategory = 'maintainer' | 'meta' | 'reference' | 'task' const MAINTAINER_TYPES = new Set(['maintainer', 'maintainer-only']) +const KNOWN_SKILL_TYPES = new Set([ + 'task', + 'reference', + 'meta', + ...MAINTAINER_TYPES, +]) + +export function isKnownSkillType(type: string): boolean { + return KNOWN_SKILL_TYPES.has(type.trim().toLowerCase()) +} export function getSkillCategory( skill: Pick, diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 35b2533d..31b8c2c6 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -2626,6 +2626,163 @@ describe('cli commands', () => { ) }) + it('warns when a catalogue description is truncated', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-length-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/catalogue', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + writeSkillMd(join(root, 'skills', 'long'), { + name: 'long', + description: 'a'.repeat(200), + }) + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'skills/long/SKILL.md: catalogue description is truncated from 200 to 180 characters (20 lost)', + ) + }) + + it('reports per-skill catalogue shaping and classification warnings', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-skills-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/catalogue', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + writeSkillMd(join(root, 'skills', 'local'), { + name: 'local', + description: 'Read /Users/person/project/file.ts', + }) + const unknownDir = join(root, 'skills', 'unknown') + mkdirSync(unknownDir, { recursive: true }) + writeFileSync( + join(unknownDir, 'SKILL.md'), + [ + '---', + 'name: unknown', + 'description: Unknown catalogue type', + 'metadata:', + ' type: surprising', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + writeSkillMd(join(root, 'skills', 'duplicate-a'), { + name: 'duplicate-a', + description: 'Shared description', + }) + writeSkillMd(join(root, 'skills', 'duplicate-b'), { + name: 'duplicate-b', + description: 'Shared description', + }) + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'skills/local/SKILL.md: catalogue description contains a local path and is blanked', + ) + expect(output).toContain( + 'skills/unknown/SKILL.md: unknown metadata.type "surprising"; skill is included in the catalogue', + ) + expect(output).toContain( + 'skills/duplicate-a/SKILL.md: catalogue description duplicates @fixture/catalogue#duplicate-b', + ) + expect(output).toContain( + 'skills/duplicate-b/SKILL.md: catalogue description duplicates @fixture/catalogue#duplicate-a', + ) + }) + + it('warns instead of crashing when a catalogue use is malformed', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-use-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + writeSkillMd(join(root, 'skills', 'core'), { + name: 'core', + description: 'Core concepts', + }) + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain( + 'skills/core/SKILL.md: malformed catalogue use: Invalid skill use "#core": package is required.', + ) + }) + + it('reports package catalogue bytes and skills outside the limits', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-budget-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/catalogue', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + for (let index = 0; index < 51; index++) { + const skillName = `skill-${String(index).padStart(2, '0')}` + writeSkillMd(join(root, 'skills', skillName), { + name: skillName, + description: `${skillName} ${'a'.repeat(160)}`, + }) + } + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('package.json: catalogue renders ') + expect(output).toContain('/8000 bytes; skills outside limits:') + expect(output).toContain('@fixture/catalogue#skill-50') + }) + + it('escalates catalogue warnings in check mode', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-check-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/catalogue', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + writeSkillMd(join(root, 'skills', 'long'), { + name: 'long', + description: 'a'.repeat(200), + }) + process.chdir(root) + + const exitCode = await main(['validate', '--check']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('Validation failed with 1 error(s)') + expect(output).toContain( + 'skills/long/SKILL.md: catalogue description is truncated from 200 to 180 characters (20 lost)', + ) + }) + it('keeps nested Intent skill names valid without Agent Skills spec warnings', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-nested-')) tempDirs.push(root) @@ -3225,6 +3382,59 @@ describe('cli commands', () => { expect(output).toContain('metadata must be a mapping') }) + it('validates Agent Skills scalar frontmatter fields', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-scalars-')) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'db-core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: 42', + 'description: []', + 'allowed-tools: " "', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('name must be a non-empty string') + expect(output).toContain('description must be a non-empty string') + expect(output).toContain( + 'Agent Skills spec warning: allowed-tools should be a non-empty space-separated string', + ) + }) + + it('fails when SKILL.md frontmatter is not a mapping', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-frontmatter-')) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'db-core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + ['---', 'null', '---', '', 'Skill content here.', ''].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('YAML frontmatter must be a mapping') + }) + it('does not flag array-valued top-level keys as non-spec scalars', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-array-key-')) tempDirs.push(root) From ff5f4556438b3aa6b740b4b6f9b1cfe528644f98 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 30 Jul 2026 20:13:05 -0700 Subject: [PATCH 59/60] fix(intent): align catalogue type warnings with the authoring vocabulary --- packages/intent/src/commands/validate.ts | 20 ++++++++--- packages/intent/src/skills/categories.ts | 7 ++++ packages/intent/tests/cli.test.ts | 46 +++++++++++++++++++++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index ebbce3a0..c4a88a7f 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -168,6 +168,11 @@ function collectPackagingWarnings(context: ProjectContext): Array { return warnings } +function displayPath(filePath: string): string { + const rel = relative(process.cwd(), filePath) + return rel.startsWith('..') ? filePath : rel +} + function formatWarning({ file, message }: ValidationWarning): string { return `${file}: ${message}` } @@ -477,7 +482,7 @@ async function runValidateCommandInternal( const catalogueSkills: Array = [] for (const filePath of skillFiles) { - const rel = relative(process.cwd(), filePath) + const rel = displayPath(filePath) const content = readFileSync(filePath, 'utf8') const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/) @@ -685,6 +690,11 @@ async function runValidateCommandInternal( file: skill.file, message: `unknown metadata.type "${type}"; skill is ${renderedSkill ? 'included in' : 'excluded from'} the catalogue`, }) + } else if (!renderedSkill) { + reportCatalogueWarning({ + file: skill.file, + message: `metadata.type "${type}" is excluded from the catalogue; agents will not see this skill`, + }) } if (!renderedSkill) continue @@ -737,7 +747,7 @@ async function runValidateCommandInternal( validateContext.targetPackageJsonPath ?? join(packageRoot, 'package.json') reportCatalogueWarning({ - file: relative(process.cwd(), packageJsonPath), + file: displayPath(packageJsonPath), message: `catalogue renders ${Buffer.byteLength(fullCatalogueText)}/${SESSION_CATALOGUE_MAX_BYTES} bytes; skills outside limits: ${skillsOutsideLimits.map((skill) => skill.id).join(', ')}`, }) } @@ -778,7 +788,7 @@ async function runValidateCommandInternal( const artifactPath = join(artifactsDir, fileName) if (!existsSync(artifactPath)) { errors.push({ - file: relative(process.cwd(), artifactPath), + file: displayPath(artifactPath), message: 'Missing required artifact', }) continue @@ -787,7 +797,7 @@ async function runValidateCommandInternal( const content = readFileSync(artifactPath, 'utf8') if (content.trim().length === 0) { errors.push({ - file: relative(process.cwd(), artifactPath), + file: displayPath(artifactPath), message: 'Artifact file is empty', }) continue @@ -799,7 +809,7 @@ async function runValidateCommandInternal( } catch (err) { const detail = err instanceof Error ? err.message : String(err) errors.push({ - file: relative(process.cwd(), artifactPath), + file: displayPath(artifactPath), message: `Invalid YAML in artifact file: ${detail}`, }) } diff --git a/packages/intent/src/skills/categories.ts b/packages/intent/src/skills/categories.ts index a155b008..e40d9b32 100644 --- a/packages/intent/src/skills/categories.ts +++ b/packages/intent/src/skills/categories.ts @@ -3,10 +3,17 @@ import type { SkillEntry } from '../shared/types.js' export type SkillCategory = 'maintainer' | 'meta' | 'reference' | 'task' const MAINTAINER_TYPES = new Set(['maintainer', 'maintainer-only']) +// core..security are what the authoring meta-skills emit; getSkillCategory maps them to 'task'. const KNOWN_SKILL_TYPES = new Set([ 'task', 'reference', 'meta', + 'core', + 'sub-skill', + 'framework', + 'lifecycle', + 'composition', + 'security', ...MAINTAINER_TYPES, ]) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 31b8c2c6..08b05aab 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -2707,6 +2707,48 @@ describe('cli commands', () => { ) }) + it('accepts authoring types and reports catalogue exclusion for known non-task types', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-types-')) + tempDirs.push(root) + writeJson(join(root, 'package.json'), { + name: '@fixture/types', + devDependencies: { '@tanstack/intent': '^0.3.6' }, + keywords: ['tanstack-intent'], + files: ['skills', '!skills/_artifacts'], + }) + const writeTypedSkill = (name: string, type: string): void => { + const dir = join(root, 'skills', name) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'SKILL.md'), + [ + '---', + `name: ${name}`, + `description: Describes ${name} behaviour for agents.`, + 'metadata:', + ` type: ${type}`, + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + } + writeTypedSkill('core-skill', 'core') + writeTypedSkill('composition-skill', 'composition') + writeTypedSkill('reference-skill', 'reference') + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).not.toContain('unknown metadata.type') + expect(output).toContain( + 'skills/reference-skill/SKILL.md: metadata.type "reference" is excluded from the catalogue; agents will not see this skill', + ) + }) + it('warns instead of crashing when a catalogue use is malformed', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-catalogue-use-')) tempDirs.push(root) @@ -3416,7 +3458,9 @@ describe('cli commands', () => { }) it('fails when SKILL.md frontmatter is not a mapping', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-frontmatter-')) + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-validate-frontmatter-'), + ) tempDirs.push(root) const skillDir = join(root, 'skills', 'db-core') From 14ce5bc9e74b9c80f94c2abe14445fba5ce2ce75 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 30 Jul 2026 20:26:42 -0700 Subject: [PATCH 60/60] docs --- docs/cli/intent-exclude.md | 80 +++++----- docs/cli/intent-hooks.md | 51 +++---- docs/cli/intent-install.md | 93 +++++------- docs/cli/intent-list.md | 128 +++++----------- docs/cli/intent-load.md | 82 +++++----- docs/cli/intent-sync.md | 48 ++++++ docs/cli/intent-validate.md | 32 +++- docs/concepts/configuration.md | 10 +- docs/concepts/trust-model.md | 39 +++-- docs/config.json | 8 + docs/getting-started/quick-start-consumers.md | 140 ++++++++---------- docs/getting-started/troubleshooting.md | 30 ++++ docs/overview.md | 110 +++----------- 13 files changed, 394 insertions(+), 457 deletions(-) create mode 100644 docs/cli/intent-sync.md create mode 100644 docs/getting-started/troubleshooting.md diff --git a/docs/cli/intent-exclude.md b/docs/cli/intent-exclude.md index 1c1d4db2..38774460 100644 --- a/docs/cli/intent-exclude.md +++ b/docs/cli/intent-exclude.md @@ -1,43 +1,37 @@ ---- -title: intent exclude -id: intent-exclude ---- - -`intent exclude` manages `package.json#intent.exclude` entries. - -```bash -npx @tanstack/intent@latest exclude [list|add|remove] [pattern] [--json] -``` - -## Options - -- `--json`: print the configured exclude patterns as JSON - -## Actions - -1. `list` (default): print current excludes -2. `add `: append one exclude pattern -3. `remove `: remove one exclude pattern - -## Examples - -```bash -npx @tanstack/intent@latest exclude -npx @tanstack/intent@latest exclude list --json -npx @tanstack/intent@latest exclude add @tanstack/router#experimental-* -npx @tanstack/intent@latest exclude remove @tanstack/router#experimental-* -``` - -## Behavior - -- Reads and writes the current working directory `package.json` -- Creates `intent.exclude` when missing -- Keeps existing excludes and appends new patterns in order -- Validates pattern syntax before writing -- Refuses invalid `package.json` structures for `intent` and `intent.exclude` - -## Related - -- [Configuration](../concepts/configuration) -- [intent list](./intent-list) -- [intent load](./intent-load) +--- +title: intent exclude +id: intent-exclude +--- + +`intent exclude` manages the `intent.exclude` list in your `package.json`. Excludes remove packages or individual skills after the `intent.skills` allowlist resolves, so an excluded skill never reaches your agent even when its package is trusted. + + +@tanstack/intent@latest exclude [list|add|remove] [pattern] [--json] + + +## Actions + +- `list` (default): print the configured excludes. Add `--json` for machine-readable output. +- `add `: append one exclude pattern. +- `remove `: remove one exclude pattern. + +```bash +npx @tanstack/intent@latest exclude +npx @tanstack/intent@latest exclude list --json +npx @tanstack/intent@latest exclude add @tanstack/router#experimental-* +npx @tanstack/intent@latest exclude remove @tanstack/router#experimental-* +``` + +For the pattern grammar - whole packages, single skills, and globs - see [Configuration](../concepts/configuration). + +## Behavior + +`add` and `remove` edit the `package.json` in the current directory, creating `intent.exclude` if it is missing and keeping existing entries in order. Intent validates a pattern before writing and refuses an invalid `intent` or `intent.exclude` structure. `list` prints `Configured excludes:` with one entry per line, or `No excludes configured.` when the list is empty. + +An excluded package does not trigger the unlisted-source warning, because excluding it is an explicit decision. + +## Related + +- [Configuration](../concepts/configuration) - the exclude pattern grammar. +- [`intent list`](./intent-list) - see what remains after excludes. +- [`intent load`](./intent-load) - excluded skills refuse to load. diff --git a/docs/cli/intent-hooks.md b/docs/cli/intent-hooks.md index 44e1c9f0..69fb7e6d 100644 --- a/docs/cli/intent-hooks.md +++ b/docs/cli/intent-hooks.md @@ -3,49 +3,38 @@ title: intent hooks id: intent-hooks --- -`intent hooks install` installs lifecycle hooks that surface available Intent skills and enforce loading matching guidance before edits in supported agents. +`intent hooks run` runs the agent lifecycle hook that shows your coding agent which skills are available at the start of a session. You do not usually run it yourself: `intent install` wires it into the agent's session-start hook when you choose hook delivery. -```bash -npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copilot,claude,codex|all] -``` + +@tanstack/intent@latest hooks run --agent copilot|claude|codex + ## Options -- `--scope `: hook install scope, either `project` or `user`; defaults to `project` -- `--agents `: comma-separated hook agents to configure (`copilot`, `claude`, `codex`) or `all`; defaults to `all` +- `--agent `: the agent whose hook format to emit, one of `copilot`, `claude`, or `codex`. Required. -## Behavior +## What it does -- Installs hook behavior without writing an `intent-skills` guidance block. -- Adds a session-start skill catalog for supported agents so the agent sees available `skill-id: description` entries before it starts work. -- Keeps edit enforcement in place: supported edit tools are blocked until the agent runs `intent load ` for matching guidance. -- `--scope project` writes project-local hook config for agents that support it. -- `--scope user` writes user-level agent config and stores runner scripts under `~/.tanstack/intent/hooks`. -- `--agents all` is the default. In project scope, Copilot is skipped because the supported Copilot CLI hook location is user-scoped. -- Run `intent install` separately when you also want to write project guidance. -- Use `package.json#intent.skills` and `package.json#intent.exclude` to control which skills are surfaced in the session catalog. +At the start of an agent session, the hook prints a short catalogue of the skills your project trusts, each with the command to load it, so the agent knows what is available before it starts work. It reads the session event on stdin and responds only to session-start events; for anything else it prints nothing. -## Hook support +The catalogue lists only skills accepted in `intent.lock`, and it is capped to keep sessions small: at most 50 skills and about 8 KB, with long descriptions trimmed. If it cannot build the catalogue it fails open, so the session continues and the hook prints a note to run `intent catalog` outside the session to see why. -| Agent | Project scope | User scope | Hooks installed | -| --- | --- | --- | --- | -| Claude Code | `.claude/settings.json` | `~/.claude/settings.json` | `SessionStart` skill catalog plus `PreToolUse` edit gate | -| Codex | `.codex/hooks.json` | `~/.codex/hooks.json` | `SessionStart` skill catalog plus `PreToolUse` edit gate; Codex hook interception is not a complete security boundary | -| GitHub Copilot CLI | Guidance via `.github/copilot-instructions.md`; blocking hooks are not project-scoped | `$COPILOT_HOME/hooks/hooks.json` or `~/.copilot/hooks/hooks.json` | `SessionStart` skill catalog plus `PreToolUse` edit gate in user scope | -| Cursor | Guidance only | Guidance only | Use `AGENTS.md` or Cursor rules; no blocking hook is installed | -| Generic `AGENTS.md` agents | Guidance only | Guidance only | Use the `intent-skills` guidance block; no blocking hook is installed | +Hooks surface skills; they do not block edits. They are a convenience for getting skills in front of your agent, not a security boundary - the trust guarantees come from the source policy and the lockfile. See the [trust model](../concepts/trust-model). -`.github/copilot-instructions.md` is a supported project guidance target for `intent install`. GitHub Copilot CLI hook enforcement uses the user-scoped Copilot hooks directory because that is the supported hook location. +## Installing hooks -Codex requires users to review and trust non-managed hooks before they run. If Codex reports hooks awaiting review, open its hook browser and trust the generated Intent hook. +Choose hook delivery when you run [`intent install`](./intent-install). Because the supported hook locations live in your home directory, these hooks are user-scoped and apply across your repositories, so `install` asks before writing them. It configures a session-start hook for the agents you target and removes any earlier Intent edit-gate hooks. -## Status messages +| Agent | Hook config | +| --- | --- | +| Claude Code | `~/.claude/settings.json` | +| Codex | `~/.codex/hooks.json` | +| GitHub Copilot CLI | `~/.copilot/hooks/hooks.json` (or `$COPILOT_HOME/hooks/hooks.json`) | -- Hook installed: `Installed Intent hooks for claude (project) in .claude/settings.json.` -- Hook skipped: `Skipped Intent hooks for copilot: project scope is not supported; use --scope user` +Codex may hold new hooks for review; open its hook browser and trust the Intent hook if prompted. ## Related -- [intent install](./intent-install) -- [intent list](./intent-list) -- [intent load](./intent-load) +- [`intent install`](./intent-install) - set up hook delivery. +- [`intent list`](./intent-list) - see which skills are available. +- [`intent load`](./intent-load) - print a skill's guidance. diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index 85b70e92..57785a4f 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -3,61 +3,45 @@ title: intent install id: intent-install --- -`intent install` creates or updates an `intent-skills` guidance block in a project guidance file. +`intent install` sets up trusted skill delivery for your project. It records which packages you trust, locks the skill content you accept, and delivers those skills to your coding agents. Run it once to set up a project, and again after dependencies change to review and accept updates. For a step-by-step walkthrough, see the [consumer quick start](../getting-started/quick-start-consumers). -```bash -npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices] -``` +The default is an interactive setup where you choose how skills are delivered: symlinks, lifecycle hooks, or a static guidance block. `--map` writes the static guidance block directly, without the interactive prompts and without a terminal. + + +@tanstack/intent@latest install [--map] [--dry-run] [--global] [--global-only] [--no-notices] + ## Options -### Guidance output +- `--map`: write the static guidance block directly, without the interactive setup. +- `--dry-run`: report what install would write, and change nothing. +- `--global`: with `--map`, include global packages after the project packages. +- `--global-only`: with `--map`, use only global packages. +- `--no-notices`: suppress non-critical notices on stderr. -- `--map`: write explicit task-to-skill mappings instead of lightweight loading guidance -- `--dry-run`: print the generated block without writing files -- `--print-prompt`: print the agent setup prompt instead of writing files +## Interactive setup -### Mapping scan scope +The default `install` runs an interactive setup, so it needs a terminal. For CI or a non-interactive shell, use `--map`. -- `--global`: include global packages after project packages when `--map` is passed -- `--global-only`: install mappings from global packages only when `--map` is passed -- `--no-notices`: suppress non-critical notices on stderr +Intent asks how to deliver skills, where to deliver them, and which skills to trust, then confirms before writing. There are three delivery choices: -## Behavior +- **Symlinks** link the accepted skill folders into your agent directories. +- **Lifecycle hooks** surface accepted skills at the start of an agent session. +- **Static guidance block** writes an `intent-skills` block into a file such as `AGENTS.md`. -- Writes lightweight skill loading guidance by default. -- Creates `AGENTS.md` when no managed block exists. -- Updates an existing managed block in a supported config file. -- Preserves all content outside the managed block. -- Scans packages and writes compact `id`, `run`, and `for` mappings only when `--map` is passed. -- Surfaces packages permitted by `package.json#intent.skills` in `--map` mode. See [Configuration](../concepts/configuration). -- Skips reference, meta, maintainer, and maintainer-only skills in `--map` mode. -- Writes compact skill identities and runnable guidance commands instead of local file paths in `--map` mode. -- Verifies the managed block before reporting success. -- Prints `No intent-enabled skills found.` and does not create a config file when `--map` finds no actionable skills. +Every choice records your trusted sources in `package.json` (`intent.skills`, plus any `intent.exclude`) and the content you accepted in `intent.lock`. -Supported config files: `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`. +Symlinks and hooks are managed delivery: Intent also writes `.intent/delivery.json` and keeps the skills in place. With symlinks it runs [`intent sync`](./intent-sync) once, adds the links to `.git/info/exclude`, and adds a `prepare: intent sync` script when Intent is a dev dependency, then prints a line such as `Installed 5 skills using symlink.` The static guidance block is a snapshot rather than managed delivery, the same output as the `--map` snapshot below; Intent prints a line such as `Installed 5 skills to AGENTS.md as a static guidance block.` -## Default output +Use `--dry-run` to preview any of this without writing files. See the [trust model](../concepts/trust-model) for how trusted sources and accepted content combine. -The default block tells agents to discover skills and load matching guidance on demand: +## Portable snapshot with --map -```markdown - -## Skill Loading - -Before editing files for a substantial task: -- Run `npx @tanstack/intent@latest list` from the workspace root to see available local skills. -- If a listed skill matches the task, run `npx @tanstack/intent@latest load #` before changing files. -- Use the loaded `SKILL.md` guidance while making the change. -- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed. -- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns. - -``` +`install --map` writes the static guidance block without the interactive setup, and unlike the default it does not need a terminal, so it suits CI. The resulting block lists each skill with the command your agent runs to load it. It is a snapshot: it does not update when dependencies change, so re-run the command to refresh it. -## Mapping output +Supported files are `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, and `.github/copilot-instructions.md`. In a terminal, Intent asks which file to use or lets you name another project file. It includes only packages permitted by `intent.skills` and skips reference, meta, and maintainer skills. `--global` and `--global-only` add global packages. On a project with no policy yet, a terminal run also helps you pick which skills to trust and writes `intent.skills` and `intent.lock`. -`--map` writes compact skill identities and commands: +The block stores portable identities and commands, never local file paths: ```yaml @@ -69,26 +53,21 @@ tanstackIntent: ``` -- `id`: portable skill identity in `#` format -- `run`: package-manager-aware command agents should run before editing -- `for`: task-routing phrase for agents -- The block does not store `load` paths, absolute paths, or package-manager-internal paths +- `id`: portable skill identity in `#` form. +- `run`: the package-manager-aware command to load the skill. +- `for`: a task-routing phrase for the agent. -## Status messages +Intent verifies the block after writing and reports the result, such as `Created AGENTS.md with 1 mapping.` or `No changes to AGENTS.md; 2 mappings already current.` If it finds no usable skills it prints `No intent-enabled skills found.` and writes nothing. -- Created: `Created AGENTS.md with 1 mapping.` -- Updated: `Updated AGENTS.md with 2 mappings.` -- Unchanged: `No changes to AGENTS.md; 2 mappings already current.` -- Guidance created: `Created AGENTS.md with skill loading guidance.` -- Guidance unchanged: `No changes to AGENTS.md; skill loading guidance already current.` -- Placement tip: `Tip: Keep the intent-skills block near the top of AGENTS.md so agents read it before task-specific instructions.` -- No actionable skills in `--map` mode: `No intent-enabled skills found.` +## When install stops -To suppress trust and migration notices in automation, pass `--no-notices`. +- **No terminal.** The interactive setup needs a TTY. Without one, install stops and points you to `--map`. +- **Symlinks not possible.** Archive-backed and Yarn Plug'n'Play sources cannot be symlinked. Install stops and tells you to choose hook delivery or the static guidance block instead. +- **Target conflict.** If a delivery target already contains a conflicting file, install stops and lists the paths so you can move them. ## Related -- [intent list](./intent-list) -- [intent load](./intent-load) -- [intent hooks](./intent-hooks) -- [Quick Start for Consumers](../getting-started/quick-start-consumers) +- [Consumer quick start](../getting-started/quick-start-consumers) +- [Trust model](../concepts/trust-model) +- [`intent list`](./intent-list) +- [`intent hooks`](./intent-hooks) diff --git a/docs/cli/intent-list.md b/docs/cli/intent-list.md index c1fe1e9a..e8a0bca1 100644 --- a/docs/cli/intent-list.md +++ b/docs/cli/intent-list.md @@ -3,42 +3,39 @@ title: intent list id: intent-list --- -`intent list` discovers skill-enabled packages and prints available skills. +`intent list` shows the skills your project can use. It scans installed dependencies and workspace packages, applies your `intent.skills` policy, and prints the packages and skills that reach your agent. -```bash -npx @tanstack/intent@latest list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--no-notices] -``` + +npx @tanstack/intent@latest list [--json] [--debug] [--global] [--global-only] [--show-hidden] [--why] [--no-notices] + ## Options -- `--json`: print JSON instead of text output -- `--debug`: print discovery debug details to stderr -- `--global`: include global packages after project packages -- `--global-only`: list global packages only -- `--show-hidden`: show unlisted hidden skill sources when run outside an agent session -- `--no-notices`: suppress non-critical notices on stderr - -## What you get - -- Scans project and workspace dependencies for intent-enabled packages and skills -- Surfaces packages permitted by `package.json#intent.skills` (see [Allowlist](#allowlist)) -- Includes global packages only when `--global` or `--global-only` is passed -- Includes warnings from discovery -- Excludes packages and skills matched by package.json `intent.exclude` -- Prints debug details to stderr when `--debug` is passed -- If no packages are discovered, prints `No intent-enabled packages found.` -- Summary line with package count and skill count -- Package table columns: `PACKAGE`, `SOURCE`, `VERSION`, `SKILLS` -- Skill tree grouped by package -- Optional warnings section (`⚠ ...` per warning) -- Optional notices section on stderr (`ℹ ...` per notice), suppressed by `--no-notices` - -`SOURCE` is a lightweight indicator showing whether the selected package came from local discovery or explicit global scanning. -When both local and global packages are scanned, local packages take precedence. +- `--json`: print the machine-readable list instead of the text tables. +- `--global`: include global packages after the project packages. +- `--global-only`: list global packages only. +- `--show-hidden`: also list sources that `intent.skills` does not permit, so you can decide what to enable. Has no effect in an agent session. +- `--why`: explain why each skill is shown or hidden. Has no effect in an agent session. +- `--debug`: print discovery details to stderr. +- `--no-notices`: suppress non-critical notices on stderr for this run. + +## What it shows + +`list` prints a summary line, a package table with `PACKAGE`, `SOURCE`, `VERSION`, and `SKILLS` columns, and a skill tree grouped by package. `SOURCE` shows whether a package came from local discovery or global scanning; when the same package is found both locally and globally, the local one is used. If nothing is found, `list` prints `No intent-enabled packages found.` + +Warnings print under `Warnings:` (each prefixed `⚠`), and notices print under `Notices:` on stderr (each prefixed `ℹ`). Suppress notices for one run with `--no-notices`, or set `INTENT_NO_NOTICES=1` for CI and wrapper scripts. + +`list` scans the project's `node_modules`, or Yarn's Plug'n'Play API when there is no usable `node_modules`. It reads package files only and never runs package code; see the [trust model](../concepts/trust-model). + +## Which packages appear + +`list` shows only packages permitted by `package.json#intent.skills`, then removes anything matched by `intent.exclude`. A missing or empty `intent.skills` permits nothing, so `list` shows no packages until you add a source; the exact `"*"` entry shows every discovered package. See [Configuration](../concepts/configuration) for the entry grammar and special forms. + +A package that ships skills but is not permitted is hidden. Outside an agent session, `list` names hidden sources; `--show-hidden` lists them and `--why` explains each decision. In an agent session, hidden sources are reported by count only, so run `intent list --show-hidden` outside the session to review candidates. An entry that matches no discovered package is reported too. ## JSON output -`--json` prints an adapter-friendly skill list: +`--json` prints a stable shape for tools and agents: ```json { @@ -50,9 +47,7 @@ When both local and global packages are scanned, local packages take precedence. "packageVersion": "5.0.0", "packageSource": "local", "skillName": "fetching", - "description": "Query data fetching patterns", - "type": "skill (optional)", - "framework": "react (optional)" + "description": "Query data fetching patterns" } ], "packages": [ @@ -75,71 +70,22 @@ When both local and global packages are scanned, local packages take precedence. "conflicts": [ { "packageName": "string", - "chosen": { - "version": "string", - "packageRoot": "string" - }, - "variants": [ - { - "version": "string", - "packageRoot": "string" - } - ] + "chosen": { "version": "string", "packageRoot": "string" }, + "variants": [{ "version": "string", "packageRoot": "string" }] } ] } ``` -When the same package exists both locally and globally and global scanning is enabled, `intent list` prefers the local package. -When project `node_modules` exists, `intent list` scans it. In Yarn PnP projects without usable `node_modules`, `intent list` uses Yarn's PnP API. - -## Allowlist - -`package.json#intent.skills` is the allowlist that decides which discovered packages are surfaced. Only listed packages contribute skills. - -```json -{ - "intent": { - "skills": ["@tanstack/query", "workspace:@scope/internal"] - } -} -``` +Each skill also carries `type` and `framework` when the skill sets them. In an agent session, package paths are blanked and `conflicts` is empty. -Each entry is one source: - -- `@scope/pkg` or `pkg`: an npm package reachable through the dependency tree. -- `workspace:@scope/pkg`: a package in the current workspace. -- `@scope/*` or `workspace:@scope/*`: every discovered package of that kind whose name matches the pattern. -- `git:/#`: reserved, and not yet supported. - -The list as a whole has three special forms: - -- **Absent** (no `intent.skills` key): every discovered package is surfaced, with a deprecation notice printed to stderr on each run until you set `intent.skills`. This is the upgrade path for existing projects. A future version will require an explicit allowlist. -- **Empty** (`"skills": []`): no package is surfaced, with an info notice printed to stderr. -- **Wildcard** (`"skills": ["*"]`): every discovered package is surfaced, with an acknowledged-risk notice printed to stderr. This exact trust-all entry is distinct from a scoped package pattern such as `@tanstack/*`. - -A package that ships skills but is not listed or matched by a pattern is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. In agent sessions, hidden sources are reported by count only; run `intent list --show-hidden` outside the agent session to review candidates. An exact entry or pattern that matches no discovered package is reported as well. Package patterns support `*` wildcards. Matching is currently by package name. See [Configuration](../concepts/configuration) and [Trust model](../concepts/trust-model). - -## Excludes - -Package excludes are hard filters for packages that should not be used in a repo, applied after the allowlist. -Intent reads `intent.exclude` arrays from package.json files while walking from the workspace or project root to the current working directory. -Manage persistent excludes with `intent exclude add|remove|list`. - -```json -{ - "intent": { - "exclude": ["@tanstack/*devtools*", "@tanstack/router#experimental-*"] - } -} -``` - -A pattern without `#` excludes a whole package. A pattern with `#` excludes a single skill (`@scope/pkg#search-params`), and the skill segment may itself be a glob (`@scope/pkg#experimental-*`). A pattern may cross package boundaries at skill granularity (`*#experimental-*`). The `#*` shortcut (`@scope/pkg#*`) excludes the whole package. Only exact names and `*` wildcards are supported on each segment. Bare package-name patterns keep working unchanged. +## Common errors -An excluded package never triggers the unlisted-source warning, because an exclude is an explicit decision rather than an oversight. +- Scanner failures print as errors. +- Unsupported environments, such as Deno projects without `node_modules`. `list` needs a resolvable `node_modules` or a Yarn Plug'n'Play setup. -## Common errors +## Related -- Scanner failures are printed as errors -- Unsupported environments: - - Deno projects without `node_modules` +- [Configuration](../concepts/configuration) - the `intent.skills` and `intent.exclude` grammar. +- [Trust model](../concepts/trust-model) - why discovery does not grant trust. +- [`intent load`](./intent-load) - print a matching skill's `SKILL.md`. diff --git a/docs/cli/intent-load.md b/docs/cli/intent-load.md index 28043d29..0b324112 100644 --- a/docs/cli/intent-load.md +++ b/docs/cli/intent-load.md @@ -3,43 +3,36 @@ title: intent load id: intent-load --- -`intent load` loads a compact skill identity from the current install and prints the matching `SKILL.md` content. +`intent load` prints the `SKILL.md` for a skill in one of your trusted packages, matched to the version installed in your project. Your coding agent runs it to pull a skill's guidance into context, and you can run it yourself to read one. -```bash -npx @tanstack/intent@latest load # [--path] [--json] [--debug] [--global] [--global-only] -``` + +@tanstack/intent@latest load # [--path] [--json] [--debug] [--global] [--global-only] + + +The package may be scoped or unscoped, and the skill may include slash-separated sub-skill names. An unambiguous short skill name works when only one package-prefixed skill matches. + + +@tanstack/intent@latest load @tanstack/query#fetching +@tanstack/intent@latest load @tanstack/query#core/fetching +@tanstack/intent@latest load some-lib#core --path + ## Options -- `--path`: print the resolved skill path instead of the file content -- `--json`: print structured JSON with metadata and content -- `--debug`: print resolution debug details to stderr -- `--global`: load from project packages first, then global packages -- `--global-only`: load from global packages only - -## What you get - -- Validates `#` before scanning -- Scans project-local packages by default -- Includes global packages only when `--global` or `--global-only` is passed -- Refuses before scanning when the target package is not permitted by `package.json#intent.skills` -- Refuses before scanning when the target package or skill matches `intent.exclude` -- Prefers local packages when `--global` is used and the same package exists locally and globally -- Accepts an unambiguous short skill name when a package-prefixed skill exists -- Prints raw `SKILL.md` content by default -- Prints the scanner-reported path when `--path` is passed -- Prints debug details to stderr when `--debug` is passed - -The package can be scoped or unscoped. The skill can include slash-separated sub-skill names. - -Examples: - -```bash -npx @tanstack/intent@latest load @tanstack/query#fetching -npx @tanstack/intent@latest load @tanstack/query#core/fetching -npx @tanstack/intent@latest load @tanstack/router-core#auth-and-guards -npx @tanstack/intent@latest load some-lib#core --path -``` +- `--path`: print the resolved file path instead of the content. Cannot be combined with `--json`. +- `--json`: print the content plus metadata as JSON. Cannot be combined with `--path`. +- `--debug`: print resolution details to stderr. +- `--global`: load from project packages first, then global packages. +- `--global-only`: load from global packages only. + +## What it checks + +Before printing anything, `load` confirms the skill is one you are allowed to use: + +- The package must be permitted by `package.json#intent.skills`, and must not be removed by `intent.exclude`. +- If `intent.lock` exists, the skill must be recorded in it and its content must still match the accepted hash. `load` refuses a skill that was never accepted or whose content changed since you accepted it. Run `intent install` to review and accept a new baseline. + +Without a lockfile, `load` applies the source policy only and does not check content. It reads project packages by default; `--global` and `--global-only` add or switch to global packages, and a local package wins when the same one exists in both. ## JSON output @@ -60,19 +53,16 @@ npx @tanstack/intent@latest load some-lib#core --path ## Common errors -- Missing separator: `Invalid skill use "@tanstack/query": expected #.` -- Empty package: `Invalid skill use "#core": package is required.` -- Empty skill: `Invalid skill use "@tanstack/query#": skill is required.` -- Missing package: `Cannot resolve skill use "...": package "..." was not found.` -- Missing skill: `Cannot resolve skill use "...": skill "..." was not found in package "...".` -- Skill suggestion: `Did you mean @tanstack/router-core#router-core/auth-and-guards?` -- Unlisted package: `Cannot load skill use "...": package "..." is not listed in intent.skills.` -- Excluded package: `Cannot load skill use "...": package "..." is excluded by Intent configuration.` -- Excluded skill: `Cannot load skill use "...": skill "..." is excluded by Intent configuration.` +- **Malformed use.** `Invalid skill use "@tanstack/query": expected #.`, or a similar message for an empty package or skill. +- **Not found.** `Cannot resolve skill use "...": package "..." was not found.`, or the same for a missing skill, sometimes with a `Did you mean ...?` suggestion. +- **Not trusted.** `Cannot load skill use "...": package "..." is not listed in intent.skills.` +- **Excluded.** `Cannot load skill use "...": package "..." is excluded by Intent configuration.`, or the same for a skill. +- **Not accepted.** `Cannot load skill use "...": skill is not accepted in intent.lock.` +- **Content changed.** `Cannot load skill use "...": installed content does not match intent.lock.` ## Related -- [intent list](./intent-list) -- [intent install](./intent-install) -- [Trust model](../concepts/trust-model) -- [Configuration](../concepts/configuration) +- [`intent list`](./intent-list) - find loadable skills. +- [`intent install`](./intent-install) - accept skills into the lockfile. +- [Trust model](../concepts/trust-model) - how the policy and lockfile gate loading. +- [Configuration](../concepts/configuration) - the `intent.skills` and `intent.exclude` grammar. diff --git a/docs/cli/intent-sync.md b/docs/cli/intent-sync.md new file mode 100644 index 00000000..a8715d56 --- /dev/null +++ b/docs/cli/intent-sync.md @@ -0,0 +1,48 @@ +--- +title: intent sync +id: intent-sync +--- + +`intent sync` updates the managed symlinks for symlink delivery so your agent sees the skills you have accepted. Run it after installing dependencies or pulling changes. With symlink delivery, `install` also adds a `prepare` script so `sync` runs after each `npm install`. + + +@tanstack/intent@latest sync [--dry-run] [--json] + + +## Options + +- `--dry-run`: report what sync would change without touching any links. +- `--json`: print the result as JSON. + +## Prerequisites + +`sync` works only with symlink delivery, and it needs the state that `intent install` writes: + +- `.intent/delivery.json` set to symlink delivery. Without it, sync prints a note to run `intent install` and stops without changing anything. +- `package.json` policy and `intent.lock`. Without them, sync stops and tells you to run `intent install` first. + +For hook delivery, run `intent install` instead; `sync` does not manage hooks. + +## What it does + +`sync` reconciles the symlinks in your agent directories against `intent.lock`: it adds links for accepted skills and removes links that no longer belong. It reports anything that needs your attention rather than accepting it silently: + +- **New dependencies** that ship skills you have not trusted. +- **New skills** in a package you already trust. +- **Changed skill content** that no longer matches `intent.lock`. + +New and changed skills are held for review. In a terminal, sync offers to enable or exclude new dependencies; otherwise it only reports them. To accept changed content as a new baseline, run `intent install`. When it runs from the `prepare` script, sync never prompts. + +## When sync stops + +- **No delivery configured.** sync prints a note to run `intent install` and exits without changes. +- **Missing policy or lockfile.** sync stops and points you to `intent install`. +- **Hook delivery.** sync manages symlinks only; use `intent install` to repair hooks. +- **Symlinks not possible.** Archive-backed and Yarn Plug'n'Play sources cannot be symlinked; sync stops and tells you to use hook delivery. +- **Link conflict.** If a managed target already holds an unmanaged file, sync stops and lists the paths. +- **Malformed install state.** sync stops and explains how to restore or reset the Intent-managed links and state. + +## Related + +- [`intent install`](./intent-install) - configure delivery and accept a new baseline. +- [Trust model](../concepts/trust-model) - how the lockfile gates content. diff --git a/docs/cli/intent-validate.md b/docs/cli/intent-validate.md index b54b4d2a..0a96833c 100644 --- a/docs/cli/intent-validate.md +++ b/docs/cli/intent-validate.md @@ -3,7 +3,7 @@ title: intent validate id: intent-validate --- -`intent validate` checks `SKILL.md` files and artifacts for structural problems. +`intent validate` checks `SKILL.md` files, artifacts, and the rendered session catalogue. ```bash npx @tanstack/intent@latest validate [] [--github-summary] [--fix] [--check] @@ -17,12 +17,12 @@ npx @tanstack/intent@latest validate [] [--github-summary] [--fix] [--check ## Options - `--github-summary`: write a GitHub Actions step summary when `GITHUB_STEP_SUMMARY` is set -- `--check`: fail if any `SKILL.md` has fixable frontmatter migrations pending, without writing files +- `--check`: fail on catalogue warnings or fixable frontmatter migrations, without writing files - `--fix`: rewrite fixable `SKILL.md` frontmatter migrations, then validate the result ## Frontmatter migration fixes -Use `--check` in CI to detect mechanical frontmatter migrations that have not been applied: +Use `--check` in CI to detect catalogue warnings and mechanical frontmatter migrations that have not been applied: ```bash npx @tanstack/intent@latest validate --check @@ -49,17 +49,20 @@ npx @tanstack/intent@latest validate --fix ## Validation checks -For each discovered `SKILL.md`: +For each discovered `SKILL.md`, validation checks the [Agent Skills specification](https://agentskills.io/specification) fields and Intent-specific constraints: - Frontmatter delimiter and structure are valid -- YAML frontmatter parses successfully -- Required fields exist: `name`, `description` +- YAML frontmatter parses as a mapping +- Required fields `name` and `description` are non-empty strings - `name` is a single leaf segment matching the skill's parent directory (no slashes); the namespace is carried by the directory path - `name` uses only lowercase letters, numbers, and hyphens - `name` is at most 64 characters - Only spec top-level keys are allowed (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`); Intent-specific scalars (`type`, `library`, `library_version`, `framework`) must live under `metadata` - `metadata`, when present, is a mapping of string values - `description` length is at most 1024 characters +- `license`, when present, is a non-empty string +- `compatibility`, when present, is a non-empty string of at most 500 characters +- `allowed-tools`, when present, is a non-empty space-separated string - `type: framework` requires `requires` to be an array - Total file length is at most 500 lines @@ -69,6 +72,23 @@ If `/_artifacts` exists, it also validates artifacts: - Required files must be non-empty - `.yaml` artifacts must parse successfully +## Catalogue warnings + +Validation builds minimal skill summaries from the parsed frontmatter and passes them through the same catalogue builder and formatter used for agent sessions. It reports these warnings per skill: + +- Description truncation beyond 180 characters, including the number of characters lost +- Description blanking when it contains a local filesystem path +- Unknown `metadata.type` values and whether the skill remains in the catalogue +- Known types excluded from the catalogue, because agents will not see those skills +- Duplicate descriptions after catalogue normalization and truncation +- Malformed `#` uses + +For each package, validation also reports the full rendered byte count against the 8000-byte budget and lists skills omitted by the 50-skill or byte limit. + +Catalogue findings are warnings by default. `--check` promotes them to validation errors. + +Agent Skills field warnings and packaging warnings remain informational in `--check` mode. + ## Packaging warnings Packaging warnings are always computed from `package.json` in the current working directory: diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index 8b9d27ad..8efa8d1f 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -18,7 +18,7 @@ Intent merges these keys from every `package.json` between the current working d ## `intent.skills` -`intent.skills` is the allowlist. Only packages it permits contribute skills to `list`, `load`, `install`, and `stale`. See [Trust model](./trust-model) for the reasoning. +`intent.skills` is the allowlist. Only packages it permits contribute skills to commands like `list` and `load`. Interactive `install` is where you set this list. See [Trust model](./trust-model) for the reasoning. ### Source entries @@ -31,21 +31,21 @@ Each array entry names one source: | `@scope/*` or `workspace:@scope/*` | npm or workspace | Every discovered package of that kind whose name matches the pattern. | | `git:/#` | git | Reserved. Not yet supported, and rejected until a future version adds it. | -A malformed entry fails the whole command, and every bad entry is reported at once. Package patterns support `*` wildcards, including scoped patterns such as `@tanstack/*`. Intent matches allowlist entries against discovered package names. This matching will tighten in a future version. +A malformed entry fails the whole command, and every bad entry is reported at once. Package patterns support `*` wildcards, including scoped patterns such as `@tanstack/*`. Intent matches allowlist entries against discovered package names. ### Special forms The list as a whole has three special forms: -- **Absent.** No `intent.skills` key. Every discovered package is surfaced, and Intent prints a deprecation notice to stderr on each run until you set `intent.skills`. This is the upgrade path for existing projects. A future version will require an explicit allowlist. -- **Empty.** `"skills": []`. No package is surfaced. Intent prints an info notice to stderr. +- **Absent or empty.** No `intent.skills` key, or `"skills": []`. Intent permits no sources, so nothing is surfaced until you list at least one entry. It prints a notice to stderr saying no sources are permitted. +- **Explicit entries.** Intent surfaces only the packages that match a listed entry. - **Wildcard.** `"skills": ["*"]`. Every discovered package is surfaced. Unlike a package pattern such as `@tanstack/*`, this exact entry crosses package scopes and source kinds. Intent prints an acknowledged-risk notice to stderr, since unvetted skills may reach your agent. A package that ships skills but is not listed is dropped. When packages are dropped this way, Intent prints one summary line naming them so you can opt in. A listed package that was not discovered is reported as well. ### Existing projects -A project that has not set `intent.skills` keeps working. Intent surfaces every discovered package and prints the deprecation notice described under the absent form. Nothing breaks. Add an allowlist when you are ready, before a future version requires one. Run `intent list` to confirm which packages are surfaced. +A project that has not set `intent.skills` surfaces no skills until you list at least one source. Configuration written for earlier versions still parses, but Intent no longer treats a missing `intent.skills` as permission to surface every package, so you have to opt in. Run `intent install` to choose which packages to trust, or add entries to `intent.skills` by hand, then run `intent list` to confirm what is surfaced. ### Suppressing notices temporarily diff --git a/docs/concepts/trust-model.md b/docs/concepts/trust-model.md index 65ea0b4b..f26fbb4c 100644 --- a/docs/concepts/trust-model.md +++ b/docs/concepts/trust-model.md @@ -3,26 +3,45 @@ title: Trust model id: trust-model --- -Intent surfaces skills from your dependencies into your coding agent's guidance. A skill is instructions an agent follows, so the set of packages allowed to contribute skills is a trust decision. Intent makes that decision explicit through the `intent.skills` allowlist. +A skill is instructions your coding agent follows, so letting a dependency contribute one carries the same weight as running its code. Intent keeps that decision explicit and in your hands: a skill reaches your agent only when you have trusted its package and accepted its content. -## Explicit sources +## Two gates: sources and content + +Trust has two parts, and a skill has to pass both: + +- **Sources**: which packages may contribute skills at all, set by `intent.skills` in `package.json`. +- **Content**: which exact skill files you have accepted, recorded in `intent.lock`. + +The source policy decides who is allowed in. The lockfile pins what you actually agreed to, so a later change cannot slip through unnoticed. + +## Which packages you trust A package ships skills in a `skills/` directory. Discovery finds every installed package that has one, including transitive dependencies. Discovery does not grant trust. -`package.json#intent.skills` is the gate. A discovered package contributes skills only when an exact entry or `*` pattern in the allowlist matches it. An unlisted package is dropped, and Intent reports it so you can opt in or ignore it. +`intent.skills` is the gate, and it is required. With no `intent.skills` key, or an empty list, Intent permits no sources and surfaces nothing until you list at least one. An explicit entry matches packages by name; the exact `"*"` entry allows every discovered package, which is why Intent warns when you use it. See the [special forms](./configuration#special-forms) in Configuration for each case. + +Trust does not propagate. A package you trust may depend on another package that ships skills, but that dependency stays untrusted unless a separate entry matches it. A package that ships skills but is not listed is dropped, and Intent names it so you can opt in or leave it out. -The gate is opt-in today. A project with no `intent.skills` key still surfaces every discovered package, and Intent prints a deprecation notice to stderr on each run until you set `intent.skills`. A future version will require an explicit allowlist. See the [special forms](./configuration#special-forms) in Configuration. +## Which content you accepted -Trust does not propagate. A listed package may depend on another package that ships skills, but that dependency stays unlisted unless another entry matches it. Exact entries allow one source; patterns such as `@tanstack/*` explicitly allow every matching source. +`intent.lock` records every accepted skill as a path and a content hash. When a lockfile is present, `load` enforces it: it refuses a skill that is not in the lock, and refuses one whose installed content no longer matches the recorded hash. -## Static discovery +This is what stops a dependency update from quietly changing what your agent reads. When an update adds a skill or changes an accepted one, `sync` reports it for review instead of accepting it, and you take the new content by running `install` again. Without a lockfile, Intent falls back to the source policy alone and does not check content. + +## Discovery never runs package code Intent reads package data as files. It never imports, requires, or executes the code of a discovered package to find or load a skill. Adding a package to your dependency tree cannot run that package's code through Intent. -One exception is sanctioned: in Yarn Plug'n'Play projects, Intent loads Yarn's PnP runtime (`.pnp.cjs`) to map package identities to readable locations. It loads no package entry points, bins, lifecycle scripts, or other package-provided JavaScript. An ESLint rule enforces this invariant in the discovery code. +One exception is sanctioned: in Yarn Plug'n'Play projects, Intent loads Yarn's PnP runtime (`.pnp.cjs`) to map package identities to readable locations. It loads no package entry points, bins, lifecycle scripts, or other package-provided JavaScript, and an ESLint rule enforces that invariant in the discovery code. + +## Delivery affects the guarantee + +The lockfile check runs when Intent runs. How skills reach your agent between those runs depends on the delivery method you chose at install. + +Symlink delivery links the live package folders into your agent's directories. A package update can change linked content before Intent re-checks `intent.lock`; Intent detects the drift the next time it runs, but it cannot stop an agent from reading changed content in the meantime. Hook delivery surfaces only skills already accepted in the lockfile, so changes are held for review before they can reach the agent. Choose hooks when you want every change reviewed first. -## What the allowlist does not cover yet +## Current limits -Matching is currently by package name. A `workspace:foo` entry and a bare `foo` entry both authorize a discovered package named `foo`, because the scanner does not yet distinguish a workspace member from a published package of the same name. This errs toward permitting a same-named package, never toward denying one you listed. A future version tightens matching once the scanner carries that signal. +Matching is currently by package name. A `workspace:foo` entry and a bare `foo` entry both authorize a discovered package named `foo`, because the scanner does not yet distinguish a workspace member from a published package of the same name. This errs toward permitting a same-named package, never toward denying one you listed. -The `git:` source kind is reserved. Intent parses and validates the shape, then rejects it until a future version can pin the resolved ref and content hash. A git entry never loads silently. +The `git:` source kind is reserved. Intent validates the shape but rejects it for now, so a git entry never loads silently. diff --git a/docs/config.json b/docs/config.json index 11e09090..efbee84b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -17,6 +17,10 @@ "label": "Quick Start (Consumers)", "to": "getting-started/quick-start-consumers" }, + { + "label": "Troubleshooting", + "to": "getting-started/troubleshooting" + }, { "label": "Quick Start (Maintainers)", "to": "getting-started/quick-start-maintainers" @@ -47,6 +51,10 @@ "label": "intent install", "to": "cli/intent-install" }, + { + "label": "intent sync", + "to": "cli/intent-sync" + }, { "label": "intent hooks", "to": "cli/intent-hooks" diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 374742a8..3f0041fe 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -3,123 +3,103 @@ title: Quick Start for Consumers id: quick-start-consumers --- -Get started using Intent to help your agent discover and load package skills. +When a library you depend on ships Agent Skills, Intent puts that guidance in front of your coding agent. A skill tells your agent what to do, so you choose which dependencies to trust and how their skills reach your agent. -## 1. Run install +## Before you start -The install command guides your agent through the setup process: +You need a project with a `package.json` and at least one installed dependency that ships skills. To check what dependencies in your project offer skills before you set anything up, Intent can scan your `node_modules` and report the candidates: -```bash -npx @tanstack/intent@latest install -``` + -Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: `pnpm dlx`, `yarn dlx`, or `bunx`. +@tanstack/intent@latest list --show-hidden -This creates or updates an `intent-skills` guidance block. It: + -1. Checks for existing `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.) -2. Writes lightweight instructions for skill discovery and loading -3. Preserves content outside the managed block -4. Verifies the managed block before reporting success +Until you trust a package, its skills stay hidden, so `--show-hidden` is what reveals the candidates. Without it, a fresh project reports no packages even when a dependency ships skills. -If an `intent-skills` block already exists, Intent updates that file in place. -If no block exists, `AGENTS.md` is the default target. +## How to run Intent -Intent creates guidance like: +Every command in this guide works with `npx @tanstack/intent@latest` and no install. That is fine for a quick start or a one-off, but `@latest` fetches whatever version is current, so a new release can change how a command behaves. -```markdown - -## Skill Loading +For the most stable experience, add Intent as a dev dependency: -Before editing files for a substantial task: -- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills. -- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load #` before changing files. -- Use the loaded `SKILL.md` guidance while making the change. -- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed. -- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns. - -``` + -Intent detects the package manager when generating this block, so the runner may be `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`. +@tanstack/intent -To enforce loading guidance before edits in supported agents, opt in to hooks: + -```bash -npx @tanstack/intent@latest hooks install -``` +Your lockfile then records the exact version, so everyone on your team runs the same Intent and upgrades happen when you choose. With Intent installed and symlink delivery, `install` also adds a `prepare` script that runs `intent sync` after each `npm install`, so your managed links stay current without anyone remembering to run it. -Project-scoped hooks are installed for Claude Code and Codex. `intent install` can write project guidance to `.github/copilot-instructions.md`, but GitHub Copilot CLI hook enforcement is user-scoped, so configure it explicitly: +## Install skills -```bash -npx @tanstack/intent@latest hooks install --scope user --agents copilot -``` +`install` runs an interactive setup, so run it in a terminal. For CI or a non-interactive shell, use [a portable snapshot](#portable-snapshots) instead. -Cursor and generic `AGENTS.md` agents use the guidance block only. + -Hooks add the available Intent skill catalog to supported agent sessions and keep the edit gate active until the agent loads matching full guidance. To tailor what appears in the session catalog, configure `intent.skills` and `intent.exclude` in `package.json`. +@tanstack/intent@latest install -## 2. Choose which packages' skills to use + -`package.json#intent.skills` is an allowlist of the packages whose skills you want surfaced. +Intent asks: -```json -{ - "intent": { - "skills": ["@tanstack/*"] - } -} -``` +- **How to deliver skills.** You can symlink the skill folders into your agent's directories, install lifecycle hooks that list available skills at the start of a session, or write a static snapshot of skill mappings into an agent file such as `AGENTS.md`. +- **Where to put them.** Intent pre-selects the agent tools it can detect, such as GitHub Copilot, Cursor, Claude Code, Codex, VS Code, or a shared `.agents` directory. Symlinks support all of these; hooks at this time only support GitHub Copilot, Claude Code, and Codex (if you'd like to add hooks for other agents or platforms, we welcome contributions). You can also choose a custom folder for symlinks or hooks. +- **Which skills to trust.** Enable every skill it found, everything under a certain package name (eg. `@tanstack/*`), or pick individual skills. Only the packages you enable here can provide skills to your agent. +- **A final confirmation** before it writes anything. -List the packages or `*` package patterns you trust. Intent then surfaces skills from matching packages and leaves the rest out. See the [source entries](../concepts/configuration#source-entries) in Configuration for the forms an entry can take, and [Trust model](../concepts/trust-model) for why the allowlist exists. +> [!WARNING] +> Using symlinks can expose live package content before Intent can re-checks it. This means a skill can be updated in a dependency without Intent reviewing it first. If you want to review new or changed skills before they reach your agent, choose hook delivery instead. -## 3. Use skills in your workflow +Once finished, Intent prints a line describing how many skills were installed, e.g., `Installed 5 skills using symlink.` -When your agent works on a task that matches an available skill, it loads the matching `SKILL.md` into context. +## What install writes -Load a skill manually: +Intent records your choices in three files: -```bash -npx @tanstack/intent@latest load @tanstack/react-query#core -``` +- `package.json` holds `intent.skills`, the list of sources you trust, as well as the `intent.exclude` patterns that remove skills or packages you do not want. +- `intent.lock` holds contains the accepted skill contents, so teams can share the same baseline. It also records the package versions that shipped those skills, so Intent can detect when a dependency update changes its skills, or the contents of a skill you already accepted have changed. +- `.intent/delivery.json` holds your local delivery method and targets. -This prints the skill content for the installed package version. +> [!NOTE] +> If you chose symlinks, Intent adds the managed links to `.git/info/exclude` so they do not get committed. -If you want explicit task-to-skill mappings in your agent config, opt in: +Commit `package.json` and `intent.lock` if you're looking for the project to share the same trusted sources and accepted skills. `.intent/` stays local to your checkout. -```bash -npx @tanstack/intent@latest install --map -``` +## Check that it worked -## 4. Keep skills up-to-date +List the skills your project now trusts: -Skills version with library releases. When you update a library: + -```bash -npm update @tanstack/react-query -``` +@tanstack/intent@latest list -The new version brings updated skills automatically. The skills are shipped with the library, so you get the version that matches your installed code. If a package is installed both locally and globally and global scanning is enabled, Intent prefers the local version. + -If you need to see what skills have changed, run: +Intent prints a summary such as `5 intent-enabled packages, 12 skills`, then the packages you trusted and their skills. Load one to read its guidance: -```bash -npx @tanstack/intent@latest list -``` + -Use `--json` for machine-readable output: +@tanstack/intent@latest load @tanstack/query#fetching -```bash -npx @tanstack/intent@latest list --json -``` + -Global package scanning is opt-in: +Replace `@tanstack/query#fetching` with a package and skill from your own list. `load` prints the `SKILL.md` shipped with the version installed in your project, and your agent can run the same command when it needs that guidance. -```bash -npx @tanstack/intent@latest list --global -``` +If a command does not behave as described, see [Troubleshooting](./troubleshooting). -You can also check if any skills reference outdated source documentation: +## Keep skills current -```bash -npx @tanstack/intent@latest stale -``` +Updating a dependency can add, remove, or change its skills. With symlink delivery, run [`intent sync`](../cli/intent-sync) to update the links; it flags new or changed skills for review before they reach your agent. Run `install` again when you are ready to accept a new baseline. See the [trust model](../concepts/trust-model) for how that review works. + +## Portable snapshots + +`install --map` writes a static list of skill mappings into an agent file such as `AGENTS.md` instead of setting up managed delivery: + + + +@tanstack/intent@latest install --map + + + +The snapshot does not update when dependencies change, so re-run the command to refresh it. Hooks and symlinks keep skills current automatically, so they are the more reliable choice for everyday use; reach for `--map` when you want committed guidance or cannot use managed delivery. An MCP server is planned for a future release. diff --git a/docs/getting-started/troubleshooting.md b/docs/getting-started/troubleshooting.md new file mode 100644 index 00000000..e5ff50bc --- /dev/null +++ b/docs/getting-started/troubleshooting.md @@ -0,0 +1,30 @@ +--- +title: Troubleshooting +id: troubleshooting +--- + +Fixes for common problems when you consume skills with Intent. New to Intent? Start with the [consumer quick start](./quick-start-consumers). + +## `list` reports no packages + +Before you install, nothing is trusted, so `intent list` reports no packages even when a dependency ships skills. Run `intent list --show-hidden` to see the candidates, then enable the ones you want during `install`. + +## A skill you expected is missing + +Add `--why` to see how Intent classified each source: + + + +@tanstack/intent@latest list --show-hidden --why + + + +A skill can be missing because you did not enable its package during install, or because an `intent.exclude` pattern removed it. + +## Install exits without prompting + +Interactive install needs a terminal. In CI or a non-interactive shell, use [a portable snapshot](./quick-start-consumers#portable-snapshots) instead. + +## Symlinks are not available + +Some setups, such as Yarn Plug'n'Play, cannot expose package skills as real folders. Choose another method of delivery instead when `install` asks how to deliver skills. diff --git a/docs/overview.md b/docs/overview.md index 237fffeb..9886ee8c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,88 +1,22 @@ ---- -title: Overview -id: overview ---- - -`@tanstack/intent` is a CLI for shipping and consuming Agent Skills as package artifacts. - -Skills are markdown documents that teach AI coding agents how to use your library correctly. Intent versions them with your releases and ships them inside npm packages. It discovers skills from your project and workspace dependencies, then helps agents load them when working on matching tasks. - -## What Intent does - -Intent provides tooling for two workflows: - -**For consumers:** - -- Discover skills from your project and workspace dependencies -- Control which packages' skills are surfaced with an allowlist -- Add lightweight skill loading guidance to your agent config -- Add hook enforcement for agents that support blocking lifecycle hooks -- Keep skills synchronized with library versions - -**For maintainers (library teams):** - -- Scaffold skills through AI-assisted domain discovery -- Validate SKILL.md format and packaging -- Ship skills in the same release pipeline as code -- Track staleness when source docs change - -## How it works - -### Discovery and installation - -Examples use `npx` for npm projects. In pnpm, Yarn, or Bun projects, use the matching runner: - -| Tool | Pattern | -| ---- | -------------------------------------------- | -| npm | `npx @tanstack/intent@latest ` | -| pnpm | `pnpm dlx @tanstack/intent@latest ` | -| Yarn | `yarn dlx @tanstack/intent@latest ` | -| Bun | `bunx @tanstack/intent@latest ` | - -```bash -npx @tanstack/intent@latest list -``` - -Scans the current project's installed dependencies for intent-enabled packages, including `node_modules`, workspace dependencies, and Yarn PnP projects without `node_modules`. You can narrow which packages are surfaced with `package.json#intent.skills`. See the [Trust model](./concepts/trust-model) and [Configuration](./concepts/configuration) for how the allowlist works. -Global package scanning is explicit; pass `--global` to include global packages or `--global-only` to ignore local packages. -When both local and global packages are scanned, local packages take precedence. - -```bash -npx @tanstack/intent@latest install -``` - -Creates or updates lightweight `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.). Existing guidance is updated in place; otherwise `AGENTS.md` is the default target. Pass `--map` to opt in to explicit task-to-skill mappings. - -```bash -npx @tanstack/intent@latest hooks install -``` - -Installs hook enforcement for supported agents. Project-scoped hooks are available for Claude Code and Codex. GitHub Copilot CLI project guidance can live in `.github/copilot-instructions.md`, while blocking hooks are user-scoped. Cursor and generic `AGENTS.md` agents use guidance only. - -```bash -npx @tanstack/intent@latest load @tanstack/query#fetching -``` - -Loads the matching `SKILL.md` content for the installed package version. Pass `--path` when you need the resolved skill file path for debugging. - -### Scaffolding and validation - -```bash -npx @tanstack/intent@latest scaffold -``` - -Guides your agent through domain discovery, tree generation, and skill authoring with interactive maintainer interviews. - -```bash -npx @tanstack/intent@latest validate -``` - -Enforces SKILL.md format rules and packaging requirements before publish. - -### Staleness tracking - -```bash -npx @tanstack/intent@latest stale -``` - -Detects when skills reference outdated source documentation or library versions. +--- +title: Overview +id: overview +--- + +An Agent Skill is a set of instructions that helps a coding agent work with a library or handle a particular task. `@tanstack/intent` lets libraries include these skills in their packages, so each package version can carry matching guidance. + +Skills come from your dependencies, and a skill tells your agent what to do, so Intent only uses skills from the packages you choose to trust. You decide which packages may provide skills and how your agents receive them. + +## Use skills from a dependency + +If a dependency already includes skills, start with the [consumer quick start](./getting-started/quick-start-consumers). During installation, you approve the packages you trust to provide skills and choose how to deliver those skills to your agents. + +Intent records the sources you approved in `package.json`, the accepted skill contents in `intent.lock`, and your local delivery choice in `.intent/delivery.json`. A package that ships skills contributes nothing until you list it among the sources you trust. + +Symlink installs use `sync` to keep agent links current, and it flags new or changed skills for review before they reach your agent. Hooks are another delivery option, and a static guidance block can write the skills into a file such as `AGENTS.md`. Read the [trust model](./concepts/trust-model) for how packages and skill changes are approved, or [configuration](./concepts/configuration) for the available settings. + +## Publish skills with a library + +If you maintain a library, keep its skills in the package alongside the code they describe, so each release ships the guidance written for it. Start with the [maintainer quick start](./getting-started/quick-start-maintainers). + +Intent scaffolds skills with your agent, validates their format and packaging before you publish, and reports when a skill looks stale as the library changes. The [registry](./registry) explains how to make a published package discoverable.