Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hook-script-fixed-text.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 13 additions & 2 deletions packages/intent/src/hooks/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions packages/intent/src/hooks/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export const EDIT_TOOLS_BY_AGENT: Record<HookAgent, ReadonlySet<string>> = {
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 {
Expand All @@ -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
Expand Down
61 changes: 46 additions & 15 deletions packages/intent/tests/hooks-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
formatHookInstallResult,
runInstallHooks,
} from '../src/hooks/install.js'
import { parseIntentInvocation } from '../src/hooks/policy.js'

const tempDirs: Array<string> = []

Expand All @@ -36,6 +37,22 @@ function readJson(filePath: string): Record<string, any> {
}

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) => {
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -724,6 +745,16 @@ function runHookScript(scriptPath: string, event: Record<string, unknown>) {
})
}

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(
Expand Down