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
9 changes: 9 additions & 0 deletions .changeset/mighty-friends-doubt.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 9 additions & 6 deletions docs/cli/intent-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand 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 <skill-id>` 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 |

Expand Down
2 changes: 2 additions & 0 deletions docs/getting-started/quick-start-consumers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]',
)
Expand Down
193 changes: 176 additions & 17 deletions packages/intent/src/hooks/install.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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'))
Expand All @@ -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()
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -278,7 +405,7 @@ function hookInstallResult({
}
}

function upsertAdapterPreToolUseHook({
function upsertAdapterHooks({
config,
configKind,
project,
Expand All @@ -291,20 +418,36 @@ function upsertAdapterPreToolUseHook({
}): Record<string, unknown> {
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<string, unknown>,
project: boolean,
scriptPath: string,
): Record<string, unknown> {
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: [
Expand All @@ -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<string, unknown>,
project: boolean,
scriptPath: string,
): Record<string, unknown> {
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: [
Expand All @@ -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<string, unknown>,
scriptPath: string,
): Record<string, unknown> {
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)}`,
})
Expand Down Expand Up @@ -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,
)
}
Expand Down
Loading