diff --git a/.changeset/hook-script-fixed-text.md b/.changeset/hook-script-fixed-text.md new file mode 100644 index 0000000..59a5777 --- /dev/null +++ b/.changeset/hook-script-fixed-text.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Write the command parser in the hook script as fixed text, so the script content no longer changes with the build tooling. Hook behavior does not change. The next `intent hooks install` rewrites the installed script once and reports `Updated Intent hooks`; later runs report no changes. diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 58ea0b6..49b9b2c 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -8,7 +8,7 @@ import { ALL_HOOK_AGENTS, HOOK_AGENT_ADAPTERS } from './adapters.js' import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON, - parseIntentInvocation, + INTENT_INVOCATION_PATTERN, } from './policy.js' import type { HookAgent, HookInstallScope } from './types.js' @@ -89,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 parseIntentInvocation = ${parseIntentInvocation.toString()} +const INTENT_INVOCATION_PATTERN = ${INTENT_INVOCATION_PATTERN} try { await main() @@ -279,6 +279,17 @@ function stateFileForEvent(event) { return join(tmpdir(), 'tanstack-intent-hooks', key + '.jsonl') } +function parseIntentInvocation(command) { + if (typeof command !== 'string') return undefined + const match = command.match(INTENT_INVOCATION_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 observationFromEvent(event) { if (!event || typeof event !== 'object') return undefined const toolName = event.tool_name ?? event.toolName diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts index c0580c1..c99fa01 100644 --- a/packages/intent/src/hooks/policy.ts +++ b/packages/intent/src/hooks/policy.ts @@ -15,6 +15,9 @@ export const EDIT_TOOLS_BY_AGENT: Record> = { export const GATE_DENY_REASON = 'Blocked: check TanStack guidance before editing. If a listed skill matches, load it, then retry the edit.' +export const INTENT_INVOCATION_PATTERN = + /(?:^|&&|\|\||;|\|)\s*((?:bunx\s+--no-install\s+--package\s+@tanstack\/intent\s+intent)|(?:npm\s+exec\s+--no\s+--\s+intent)|(?:yarn\s+exec\s+intent)|(?: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)?)|(?:(?:[^\s|;&]*[\\/])?intent))\s+(list|load)(?:\s+([^\s|;&]+))?/i + export function parseIntentInvocation( command: unknown, ): IntentInvocation | undefined { @@ -25,9 +28,7 @@ export function parseIntentInvocation( // The bare `intent` form also accepts a path prefix such as // `node_modules/.bin/intent`, which the session catalog suggests when the // project has the CLI installed. - const match = command.match( - /(?:^|&&|\|\||;|\|)\s*((?:bunx\s+--no-install\s+--package\s+@tanstack\/intent\s+intent)|(?:npm\s+exec\s+--no\s+--\s+intent)|(?:yarn\s+exec\s+intent)|(?: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)?)|(?:(?:[^\s|;&]*[\\/])?intent))\s+(list|load)(?:\s+([^\s|;&]+))?/i, - ) + const match = command.match(INTENT_INVOCATION_PATTERN) 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 5293d11..ef30f7e 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -16,6 +16,7 @@ import { formatHookInstallResult, runInstallHooks, } from '../src/hooks/install.js' +import { parseIntentInvocation } from '../src/hooks/policy.js' const tempDirs: Array = [] @@ -36,6 +37,22 @@ function readJson(filePath: string): Record { } describe('hook installer', () => { + const commands = [ + ['intent list', true], + ['npm exec --no -- intent list', true], + ['yarn exec intent load @tanstack/router#routing', true], + ['bunx --no-install --package @tanstack/intent 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 + it.each(['claude', 'codex', 'copilot'] as const)( 'preserves invocation parsing in the standalone %s runner', (agent) => { @@ -46,21 +63,6 @@ describe('hook installer', () => { agent === 'copilot' ? { permissionDecision: 'deny' } : { hookSpecificOutput: { permissionDecision: 'deny' } } - const commands = [ - ['intent list', true], - ['npm exec --no -- intent list', true], - ['yarn exec intent load @tanstack/router#routing', true], - ['bunx --no-install --package @tanstack/intent 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, @@ -91,6 +93,25 @@ describe('hook installer', () => { 30_000, ) + it.each(['claude', 'codex', 'copilot'] as const)( + 'parses commands in the %s runner exactly like the source parser', + (agent) => { + const parseInScript = parserFromScript(buildHookRunnerScript(agent)) + const inputs = [ + ...commands.map(([command]) => command), + 'INTENT LOAD @tanstack/router#routing', + 'cd packages/app && ../../node_modules/.bin/intent load @tanstack/x#y', + 'node_modules\\.bin\\intent load @tanstack/x#y', + 'my-intent load @tanstack/x#y', + ['intent list'], + undefined, + ] + for (const input of inputs) { + expect(parseInScript(input)).toStrictEqual(parseIntentInvocation(input)) + } + }, + ) + 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) @@ -724,6 +745,16 @@ function runHookScript(scriptPath: string, event: Record) { }) } +function parserFromScript(script: string): (command: unknown) => unknown { + const pattern = /^const INTENT_INVOCATION_PATTERN = .+$/m.exec(script) + const parser = /^function parseIntentInvocation\(command\) \{$.+?^\}$/ms.exec( + script, + ) + return new Function( + `${pattern?.[0]}\n${parser?.[0]}\nreturn parseIntentInvocation`, + )() +} + function writeFakeIntentListCommand(root: string): string { const scriptPath = join(root, 'fake-intent-list.mjs') writeFileSync(