diff --git a/.changeset/mighty-friends-doubt.md b/.changeset/mighty-friends-doubt.md new file mode 100644 index 0000000..03ca286 --- /dev/null +++ b/.changeset/mighty-friends-doubt.md @@ -0,0 +1,9 @@ +--- +'@tanstack/intent': patch +--- + +Add proactive skill catalogs to installed Intent hooks. + +`intent hooks install` now installs session-start catalog hooks for supported agents alongside the existing edit gate. Agents see the allowed local Intent skills at session start, resume, clear, and compact where the agent supports those lifecycle events, then still need to run `intent load` before editing. + +The generated hook loads the catalog through the Intent CLI with agent audience redaction instead of importing package code from the target repository. diff --git a/docs/cli/intent-hooks.md b/docs/cli/intent-hooks.md index a1aab8c..44e1c9f 100644 --- a/docs/cli/intent-hooks.md +++ b/docs/cli/intent-hooks.md @@ -3,7 +3,7 @@ title: intent hooks id: intent-hooks --- -`intent hooks install` installs lifecycle hooks that enforce loading matching guidance before edits in supported agents. +`intent hooks install` installs lifecycle hooks that surface available Intent skills and enforce loading matching guidance before edits in supported agents. ```bash npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copilot,claude,codex|all] @@ -16,19 +16,22 @@ npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copil ## Behavior -- Installs hook enforcement without writing an `intent-skills` guidance block. +- 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. ## Hook support -| Agent | Project scope | User scope | Enforcement | +| Agent | Project scope | User scope | Hooks installed | | --- | --- | --- | --- | -| Claude Code | `.claude/settings.json` | `~/.claude/settings.json` | Blocks configured edit tools with `PreToolUse` | -| Codex | `.codex/hooks.json` | `~/.codex/hooks.json` | Blocks supported `Bash`, `apply_patch`, and MCP tool calls; 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` | Blocks supported edit tools with `PreToolUse` | +| 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 | diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index b3369b0..c801f77 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -56,6 +56,8 @@ npx @tanstack/intent@latest hooks install --scope user --agents copilot 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`. + ## 2. Choose which packages' skills to use `package.json#intent.skills` is an allowlist of the packages whose skills you want surfaced. diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 2d488e0..fe8fa00 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -141,7 +141,10 @@ function createCli(): CAC { }) cli - .command('hooks [action]', 'Manage agent hooks that enforce skill loading') + .command( + 'hooks [action]', + 'Manage agent hooks that surface and enforce skill loading', + ) .usage( 'hooks install [--scope project|user] [--agents copilot,claude,codex|all]', ) diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 98f03e5..fad29e0 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -1,7 +1,9 @@ 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' 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' @@ -25,7 +27,8 @@ export type InstallHooksOptions = { scope?: string } -const STATUS_MESSAGE = 'Checking Intent guidance' +const GATE_STATUS_MESSAGE = 'Checking Intent guidance' +const CATALOG_STATUS_MESSAGE = 'Loading Intent skill catalog' export function runInstallHooks({ agents, @@ -56,22 +59,47 @@ export function validateHookInstallOptions({ parseAgents(agents) } -export function buildHookRunnerScript(agent: HookAgent): string { +export function buildHookRunnerScript( + agent: HookAgent, + catalogCommand = formatIntentCommand( + detectPackageManager(), + 'list --json --no-notices', + ), +): string { const editTools = [...EDIT_TOOLS_BY_AGENT[agent]].sort() return `#!/usr/bin/env node import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { createHash } from 'node:crypto' +import { performance } from 'node:perf_hooks' const AGENT = ${JSON.stringify(agent)} +const CATALOG_COMMAND = ${JSON.stringify(catalogCommand)} const EDIT_TOOLS = new Set(${JSON.stringify(editTools)}) const GATE_DENY_REASON = ${JSON.stringify(GATE_DENY_REASON)} const INTENT_COMMAND_PATTERN = /(?:^|&&|\\|\\||;|\\|)\\s*((?:bunx\\s+@tanstack\\/intent(?:@latest)?)|(?:pnpm\\s+exec\\s+intent)|(?:pnpm\\s+dlx\\s+@tanstack\\/intent(?:@latest)?)|(?:npx\\s+@tanstack\\/intent(?:@latest)?)|(?:yarn\\s+dlx\\s+@tanstack\\/intent(?:@latest)?)|(?:intent))\\s+(list|load)(?:\\s+([^\\s|;&]+))?/i try { + await main() +} catch { +} + +process.exit(0) + +async function main() { const event = readEventFromStdin() + + if (isSessionStartEvent(event)) { + const additionalContext = await createSessionCatalogContext(rootForEvent(event)) + if (additionalContext) { + process.stdout.write(JSON.stringify(sessionStartOutput(additionalContext))) + } + return + } + const stateFile = stateFileForEvent(event) const observation = observationFromEvent(event) @@ -83,11 +111,8 @@ try { if (typeof toolName === 'string' && EDIT_TOOLS.has(toolName) && !hasLoad(stateFile)) { process.stdout.write(JSON.stringify(denyOutput())) } -} catch { } -process.exit(0) - function readEventFromStdin() { try { return JSON.parse(readFileSync(0, 'utf8')) @@ -96,6 +121,101 @@ function readEventFromStdin() { } } +function isSessionStartEvent(event) { + return (event?.hook_event_name ?? event?.hookEventName) === 'SessionStart' +} + +function rootForEvent(event) { + return typeof event?.cwd === 'string' ? event.cwd : process.cwd() +} + +async function createSessionCatalogContext(root) { + try { + const start = performance.now() + const result = readIntentList(root) + const durationMs = performance.now() - start + console.error( + \`[intent-\${AGENT}-session-catalog] listIntentSkills found \${result.skills.length} skills from \${result.packages.length} packages in \${formatDuration(durationMs)} (packageJsonReadCount=\${result.debug?.scan.packageJsonReadCount ?? 'unknown'})\`, + ) + return formatSessionCatalog(result) + } catch { + return '' + } +} + +function readIntentList(root) { + const output = execFileSync(CATALOG_COMMAND, { + cwd: root, + encoding: 'utf8', + env: { ...process.env, INTENT_AUDIENCE: 'agent' }, + maxBuffer: 1024 * 1024, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 9000, + }) + return JSON.parse(output) +} + +function formatDuration(durationMs) { + return \`\${durationMs.toFixed(1)}ms\` +} + +function formatSessionCatalog(result) { + if (!Array.isArray(result.skills) || result.skills.length === 0) return '' + + return [ + 'TanStack Intent skills are available in this repository.', + '', + 'Before substantial work, check whether one listed skill clearly matches the user task. If one clearly matches, load that full skill guidance with the Intent CLI before proceeding.', + '', + 'If no skill clearly matches, continue normally. Do not load a skill just to improve phrasing or gather nonessential context.', + '', + 'Available local Intent skills:', + formatSkillCatalog(result.skills), + formatWarnings(result), + ] + .filter(Boolean) + .join('\\n') +} + +function formatSkillCatalog(skills) { + return skills + .map((skill) => \`- \${skill.use}: \${normalizeDescription(skill.description)}\`) + .join('\\n') +} + +function normalizeDescription(description) { + return typeof description === 'string' ? description.replace(/\\s+/g, ' ').trim() : '' +} + +function formatWarnings(result) { + const warnings = [ + ...(Array.isArray(result.warnings) ? result.warnings : []), + ...(Array.isArray(result.conflicts) + ? result.conflicts.map( + (conflict) => + \`Version conflict for \${conflict.packageName}; using \${conflict.chosen.version}\`, + ) + : []), + ] + + if (warnings.length === 0) return '' + return \`\\nWarnings:\\n\${warnings.map((warning) => \`- \${warning}\`).join('\\n')}\` +} + +function sessionStartOutput(additionalContext) { + if (AGENT === 'copilot') { + return { additionalContext } + } + + return { + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext, + }, + } +} + function stateFileForEvent(event) { const sessionId = typeof event?.session_id === 'string' ? event.session_id : 'unknown' const cwd = typeof event?.cwd === 'string' ? event.cwd : process.cwd() @@ -229,9 +349,16 @@ function installAgentHook({ homeDir, root, }) - const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent)) + const catalogCommand = formatIntentCommand( + detectPackageManager(root), + 'list --json --no-notices', + ) + const scriptStatus = writeIfChanged( + scriptPath, + buildHookRunnerScript(agent, catalogCommand), + ) const configStatus = updateJsonConfig(configPath, (config) => - upsertAdapterPreToolUseHook({ + upsertAdapterHooks({ config, configKind: adapter.configKind, project: scope === 'project', @@ -278,7 +405,7 @@ function hookInstallResult({ } } -function upsertAdapterPreToolUseHook({ +function upsertAdapterHooks({ config, configKind, project, @@ -291,20 +418,36 @@ function upsertAdapterPreToolUseHook({ }): Record { switch (configKind) { case 'claude-settings': - return upsertClaudePreToolUseHook(config, project, scriptPath) + return upsertClaudeHooks(config, project, scriptPath) case 'codex-hooks': - return upsertCodexPreToolUseHook(config, project, scriptPath) + return upsertCodexHooks(config, project, scriptPath) case 'copilot-hooks': - return upsertCopilotPreToolUseHook(config, scriptPath) + return upsertCopilotHooks(config, scriptPath) } } -function upsertClaudePreToolUseHook( +function upsertClaudeHooks( config: Record, project: boolean, scriptPath: string, ): Record { const hooks = objectValue(config.hooks) + hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { + matcher: 'startup|resume|clear|compact', + hooks: [ + { + type: 'command', + command: 'node', + args: [ + project + ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs' + : scriptPath, + ], + timeout: 10, + statusMessage: CATALOG_STATUS_MESSAGE, + }, + ], + }) hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit', hooks: [ @@ -317,19 +460,32 @@ function upsertClaudePreToolUseHook( : scriptPath, ], timeout: 10, - statusMessage: STATUS_MESSAGE, + statusMessage: GATE_STATUS_MESSAGE, }, ], }) return { ...config, hooks } } -function upsertCodexPreToolUseHook( +function upsertCodexHooks( config: Record, project: boolean, scriptPath: string, ): Record { const hooks = objectValue(config.hooks) + hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { + matcher: 'startup|resume|clear|compact', + hooks: [ + { + type: 'command', + command: project + ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' + : `node ${quoteShell(scriptPath)}`, + timeout: 10, + statusMessage: CATALOG_STATUS_MESSAGE, + }, + ], + }) hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { matcher: 'Bash|apply_patch|Edit|Write', hooks: [ @@ -339,18 +495,21 @@ function upsertCodexPreToolUseHook( ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' : `node ${quoteShell(scriptPath)}`, timeout: 10, - statusMessage: STATUS_MESSAGE, + statusMessage: GATE_STATUS_MESSAGE, }, ], }) return { ...config, hooks } } -function upsertCopilotPreToolUseHook( +function upsertCopilotHooks( config: Record, scriptPath: string, ): Record { const hooks = objectValue(config.hooks) + hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), { + command: `node ${quoteShell(scriptPath)}`, + }) hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { command: `node ${quoteShell(scriptPath)}`, }) @@ -389,7 +548,7 @@ 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\.mjs(?:$|[?#\s"'])/i.test( value, ) } diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 00ba7c8..8048aa0 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -69,6 +69,15 @@ describe('hook installer', () => { }) const claudeConfig = readJson(join(root, '.claude', 'settings.json')) + expect(claudeConfig.hooks.SessionStart).toHaveLength(1) + expect(claudeConfig.hooks.SessionStart[0].matcher).toBe( + 'startup|resume|clear|compact', + ) + expect(claudeConfig.hooks.SessionStart[0].hooks[0]).toMatchObject({ + command: 'node', + args: ['${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs'], + type: 'command', + }) expect(claudeConfig.hooks.PreToolUse).toHaveLength(1) expect(claudeConfig.hooks.PreToolUse[0].matcher).toBe( 'Bash|Write|Edit|MultiEdit|NotebookEdit', @@ -80,6 +89,12 @@ describe('hook installer', () => { }) 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', ) @@ -109,8 +124,11 @@ 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( @@ -161,6 +179,7 @@ describe('hook installer', () => { const second = runInstallHooks({ agents: 'claude', root, scope: 'project' }) const config = readJson(settingsPath) + expect(config.hooks.SessionStart).toHaveLength(1) expect(config.hooks.PreToolUse).toHaveLength(2) expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('echo keep') expect(second[0]).toMatchObject({ status: 'unchanged' }) @@ -197,6 +216,7 @@ describe('hook installer', () => { runInstallHooks({ agents: 'claude', root, scope: 'project' }) const config = readJson(settingsPath) + expect(config.hooks.SessionStart).toHaveLength(1) expect(config.hooks.PreToolUse).toHaveLength(2) expect(config.hooks.PreToolUse[0].hooks).toEqual([ { type: 'command', command: 'echo keep' }, @@ -237,6 +257,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( @@ -278,6 +299,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]).toMatchObject({ command: 'echo keep', @@ -294,6 +316,8 @@ describe('hook installer', () => { expect(script).toContain('const AGENT = "claude"') expect(script).toContain('permissionDecision') expect(script).not.toMatch(/Blocked:.*intent\s+(list|load)/i) + expect(script).not.toContain('@tanstack/intent/core') + expect(script).not.toContain('createRequire') }) it('runs the generated gate script through the load then edit cycle', () => { @@ -333,6 +357,99 @@ describe('hook installer', () => { expect(afterLoad.stdout).toBe('') }) + it.each(['claude', 'codex', 'copilot'] as const)( + 'emits session catalog context for %s', + (agent) => { + const root = tempRoot(`intent-hooks-session-catalog-${agent}-`) + const catalogCommand = writeFakeIntentListCommand(root) + const scriptPath = join( + root, + '.intent', + 'hooks', + `intent-${agent}-gate.mjs`, + ) + mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) + writeFileSync(scriptPath, buildHookRunnerScript(agent, catalogCommand)) + + const result = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'SessionStart', + session_id: 'session-a', + source: 'startup', + }) + + expect(result.status).toBe(0) + const output = JSON.parse(result.stdout) as any + const context = + agent === 'copilot' + ? output.additionalContext + : output.hookSpecificOutput.additionalContext + expect(context).toContain('TanStack Intent skills are available') + expect(context).toContain( + '- @tanstack/router#routing: Router routing guidance', + ) + expect(context).toContain('load that full skill guidance') + expect(context).not.toContain('intent load ') + if (agent !== 'copilot') { + expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart') + } + }, + ) + + it('does not unlock edits after session catalog context', () => { + const root = tempRoot('intent-hooks-session-catalog-gate-') + const catalogCommand = writeFakeIntentListCommand(root) + const scriptPath = join(root, '.intent', 'hooks', 'intent-claude-gate.mjs') + mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) + writeFileSync(scriptPath, buildHookRunnerScript('claude', catalogCommand)) + + const sessionStart = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'SessionStart', + session_id: 'session-a', + source: 'startup', + }) + const edit = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + + expect(sessionStart.status).toBe(0) + expect(JSON.parse(sessionStart.stdout)).toMatchObject({ + hookSpecificOutput: { hookEventName: 'SessionStart' }, + }) + expect(edit.status).toBe(0) + expect(JSON.parse(edit.stdout)).toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }) + }) + + it('continues silently when session catalog loading fails', () => { + const root = tempRoot('intent-hooks-session-catalog-missing-') + const scriptPath = join(root, '.intent', 'hooks', 'intent-claude-gate.mjs') + mkdirSync(join(root, '.intent', 'hooks'), { recursive: true }) + writeFileSync( + scriptPath, + buildHookRunnerScript( + 'claude', + `${quoteShell(process.execPath)} ${quoteShell(join(root, 'missing.mjs'))}`, + ), + ) + + const result = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'SessionStart', + session_id: 'session-a', + source: 'startup', + }) + + expect(result.status).toBe(0) + expect(result.stdout).toBe('') + }) + it('does not unlock edits after non-executed load text', () => { const root = tempRoot('intent-hooks-non-executed-load-') const scriptPath = join(root, 'intent-claude-gate.mjs') @@ -411,3 +528,32 @@ function runHookScript(scriptPath: string, event: Record) { input: JSON.stringify(event), }) } + +function writeFakeIntentListCommand(root: string): string { + const scriptPath = join(root, 'fake-intent-list.mjs') + writeFileSync( + scriptPath, + `if (process.env.INTENT_AUDIENCE !== 'agent') { + process.exit(1) +} + +console.log(JSON.stringify({ + conflicts: [], + debug: { scan: { packageJsonReadCount: 3 } }, + packages: [{ name: '@tanstack/router' }], + skills: [ + { + description: 'Router routing guidance', + use: '@tanstack/router#routing', + }, + ], + warnings: [], + })) +`, + ) + return `${quoteShell(process.execPath)} ${quoteShell(scriptPath)}` +} + +function quoteShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +}