From a17880bdfb184ddb33a7c8656c4f4c93e9b01e8f Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Fri, 11 Sep 2026 13:45:28 -0700 Subject: [PATCH] refactor: reuse hook invocation parser in standalone runners --- .changeset/reuse-hook-parser.md | 5 ++ packages/intent/src/hooks/install.ts | 19 +++---- packages/intent/src/hooks/policy.ts | 7 ++- packages/intent/tests/hooks-install.test.ts | 50 +++++++++++++++++++ .../tests/integration/packed-release.test.ts | 48 ++++++++++++++++++ 5 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 .changeset/reuse-hook-parser.md diff --git a/.changeset/reuse-hook-parser.md b/.changeset/reuse-hook-parser.md new file mode 100644 index 00000000..64912973 --- /dev/null +++ b/.changeset/reuse-hook-parser.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Generate standalone hook command parsing from the tested policy parser. Preserve agent-specific output, install ownership, and fail-open catalog behavior. diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index a7513daa..d0f70337 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -5,7 +5,11 @@ 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 { + EDIT_TOOLS_BY_AGENT, + GATE_DENY_REASON, + parseIntentInvocation, +} from './policy.js' import type { HookAgent, HookInstallScope } from './types.js' type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' @@ -85,7 +89,7 @@ const CATALOG_COMMAND = ${JSON.stringify(catalogCommand)} const LOAD_COMMAND = ${JSON.stringify(loadCommand)} 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 +const parseIntentInvocation = ${parseIntentInvocation.toString()} try { await main() @@ -244,17 +248,6 @@ function observationFromEvent(event) { 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 } diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts index b10eb2d3..ec2ced11 100644 --- a/packages/intent/src/hooks/policy.ts +++ b/packages/intent/src/hooks/policy.ts @@ -6,9 +6,6 @@ import type { 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']), @@ -25,7 +22,9 @@ export function parseIntentInvocation( return undefined } - const match = command.match(INTENT_COMMAND_PATTERN) + 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+(list|load)(?:\s+([^\s|;&]+))?/i, + ) if (!match?.[1] || !match[2]) { return undefined diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 095f0d80..ec368538 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -36,6 +36,56 @@ function readJson(filePath: string): Record { } describe('hook installer', () => { + it.each(['claude', 'codex', 'copilot'] as const)( + 'preserves invocation parsing in the standalone %s runner', + (agent) => { + const root = tempRoot(`intent-hooks-parser-${agent}-`) + const scriptPath = join(root, `intent-${agent}-gate.mjs`) + writeFileSync(scriptPath, buildHookRunnerScript(agent)) + const denial = + agent === 'copilot' + ? { permissionDecision: 'deny' } + : { hookSpecificOutput: { permissionDecision: 'deny' } } + const commands = [ + ['intent list', true], + ['pnpm exec intent load @tanstack/router#routing', true], + ['pnpm dlx @tanstack/intent@latest list --json', true], + ['npx @tanstack/intent@latest load @tanstack/router#routing', true], + ['yarn dlx @tanstack/intent list', true], + ['bunx @tanstack/intent list', true], + ['npm test || intent load @tanstack/router#routing', true], + ['echo intent load @tanstack/router#routing', false], + ['# intent list', false], + ['intent load', false], + ] as const + for (const [index, [command, checked]] of commands.entries()) { + const event = { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: `parser-${index}`, + } + const edit = { + ...event, + tool_name: agent === 'codex' ? 'apply_patch' : 'Edit', + } + const before = runHookScript(scriptPath, edit) + expect(before.status).toBe(0) + expect(JSON.parse(before.stdout)).toMatchObject(denial) + const observation = runHookScript(scriptPath, { + ...event, + toolName: 'Bash', + toolArgs: JSON.stringify({ command }), + }) + expect(observation.status).toBe(0) + expect(observation.stdout).toBe('') + const after = runHookScript(scriptPath, edit) + expect(after.status).toBe(0) + if (checked) expect(after.stdout).toBe('') + else expect(JSON.parse(after.stdout)).toMatchObject(denial) + } + }, + ) + 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) diff --git a/packages/intent/tests/integration/packed-release.test.ts b/packages/intent/tests/integration/packed-release.test.ts index 99e804f1..82d4fe87 100644 --- a/packages/intent/tests/integration/packed-release.test.ts +++ b/packages/intent/tests/integration/packed-release.test.ts @@ -99,6 +99,54 @@ afterAll(() => { }) describe('packed release', () => { + it('installs standalone hooks from the packed CLI', () => { + const installed = run([ + 'hooks', + 'install', + '--agents', + 'claude,codex', + '--scope', + 'project', + ]) + expect(installed.status, installed.stderr).toBe(0) + for (const agent of ['claude', 'codex']) { + const scriptPath = join(cwd, `standalone-${agent}.mjs`) + writeFileSync( + scriptPath, + readFileSync(join(cwd, '.intent', 'hooks', `intent-${agent}-gate.mjs`)), + ) + const event = { + cwd, + hook_event_name: 'PreToolUse', + session_id: `packed-${agent}`, + } + const hook = (input: Record) => + spawnSync(process.execPath, [scriptPath], { + cwd, + encoding: 'utf8', + input: JSON.stringify({ ...event, ...input }), + timeout, + }) + const edit = { tool_name: agent === 'codex' ? 'apply_patch' : 'Edit' } + const before = hook(edit) + expect(before.status, before.stderr).toBe(0) + expect(JSON.parse(before.stdout)).toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }) + const observed = hook({ + tool_name: 'Bash', + tool_input: { + command: 'npm test || pnpm exec intent load release-fixture#core', + }, + }) + expect(observed.status, observed.stderr).toBe(0) + expect(observed.stdout).toBe('') + const after = hook(edit) + expect(after.status, after.stderr).toBe(0) + expect(after.stdout).toBe('') + } + }) + it('ships every meta resource and validates the extracted skills', () => { const meta = join(packageRoot, 'meta') for (const entry of readdirSync(meta, {