diff --git a/.changeset/hot-bottles-float.md b/.changeset/hot-bottles-float.md new file mode 100644 index 0000000..189ca31 --- /dev/null +++ b/.changeset/hot-bottles-float.md @@ -0,0 +1,7 @@ +--- +'@tanstack/intent': minor +--- + +Add `intent hooks install` for supported AI coding agents. + +This adds lifecycle-hook installation for supported agents, including project/user scope handling, generated hook runner scripts, and agent-specific enforcement policy. It also documents the hook setup flow and adds eval/test coverage for hooked intent discovery. diff --git a/docs/cli/intent-hooks.md b/docs/cli/intent-hooks.md new file mode 100644 index 0000000..a1aab8c --- /dev/null +++ b/docs/cli/intent-hooks.md @@ -0,0 +1,48 @@ +--- +title: intent hooks +id: intent-hooks +--- + +`intent hooks install` installs lifecycle hooks that enforce loading matching guidance before edits in supported agents. + +```bash +npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copilot,claude,codex|all] +``` + +## Options + +- `--scope `: hook install scope, either `project` or `user`; defaults to `project` +- `--agents `: comma-separated hook agents to configure (`copilot`, `claude`, `codex`) or `all`; defaults to `all` + +## Behavior + +- Installs hook enforcement without writing an `intent-skills` guidance block. +- `--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. + +## Hook support + +| Agent | Project scope | User scope | Enforcement | +| --- | --- | --- | --- | +| 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` | +| 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 | + +`.github/copilot-instructions.md` is a supported project guidance target for `intent install`. GitHub Copilot CLI hook enforcement uses the user-scoped Copilot hooks directory because that is the supported hook location. + +Codex requires users to review and trust non-managed hooks before they run. If Codex reports hooks awaiting review, open its hook browser and trust the generated Intent hook. + +## Status messages + +- Hook installed: `Installed Intent hooks for claude (project) in .claude/settings.json.` +- Hook skipped: `Skipped Intent hooks for copilot: project scope is not supported; use --scope user` + +## Related + +- [intent install](./intent-install) +- [intent list](./intent-list) +- [intent load](./intent-load) diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index 8d444e0..85b70e9 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -11,9 +11,14 @@ npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--glob ## Options +### Guidance output + - `--map`: write explicit task-to-skill mappings instead of lightweight loading guidance - `--dry-run`: print the generated block without writing files - `--print-prompt`: print the agent setup prompt instead of writing files + +### Mapping scan scope + - `--global`: include global packages after project packages when `--map` is passed - `--global-only`: install mappings from global packages only when `--map` is passed - `--no-notices`: suppress non-critical notices on stderr @@ -24,10 +29,10 @@ npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--glob - Creates `AGENTS.md` when no managed block exists. - Updates an existing managed block in a supported config file. - Preserves all content outside the managed block. -- Scans packages and writes compact `when` and `use` mappings only when `--map` is passed. +- Scans packages and writes compact `id`, `run`, and `for` mappings only when `--map` is passed. - Surfaces packages permitted by `package.json#intent.skills` in `--map` mode. See [Configuration](../concepts/configuration). - Skips reference, meta, maintainer, and maintainer-only skills in `--map` mode. -- Writes compact `when` and `use` entries instead of load paths in `--map` mode. +- Writes compact skill identities and runnable guidance commands instead of local file paths in `--map` mode. - Verifies the managed block before reporting success. - Prints `No intent-enabled skills found.` and does not create a config file when `--map` finds no actionable skills. @@ -41,9 +46,10 @@ The default block tells agents to discover skills and load matching guidance on ## Skill Loading -Before substantial work: -- Skill check: run `npx @tanstack/intent@latest list`, or use skills already listed in context. -- Skill guidance: if one local skill clearly matches the task, run `npx @tanstack/intent@latest load #` and follow the returned `SKILL.md`. +Before editing files for a substantial task: +- Run `npx @tanstack/intent@latest list` from the workspace root to see available local skills. +- If a listed skill matches the task, run `npx @tanstack/intent@latest load #` before changing files. +- Use the loaded `SKILL.md` guidance while making the change. - Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed. - Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns. @@ -51,19 +57,21 @@ Before substantial work: ## Mapping output -`--map` writes compact skill identities: +`--map` writes compact skill identities and commands: ```yaml -# Skill mappings - load `use` with `npx @tanstack/intent@latest load `. -skills: - - when: "Query data fetching patterns" - use: "@tanstack/query#fetching" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query#fetching" + run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching patterns" ``` -- `when`: task-routing phrase for agents -- `use`: portable skill identity in `#` format +- `id`: portable skill identity in `#` format +- `run`: package-manager-aware command agents should run before editing +- `for`: task-routing phrase for agents - The block does not store `load` paths, absolute paths, or package-manager-internal paths ## Status messages @@ -82,4 +90,5 @@ To suppress trust and migration notices in automation, pass `--no-notices`. - [intent list](./intent-list) - [intent load](./intent-load) +- [intent hooks](./intent-hooks) - [Quick Start for Consumers](../getting-started/quick-start-consumers) diff --git a/docs/config.json b/docs/config.json index 656dad5..11e0909 100644 --- a/docs/config.json +++ b/docs/config.json @@ -47,6 +47,10 @@ "label": "intent install", "to": "cli/intent-install" }, + { + "label": "intent hooks", + "to": "cli/intent-hooks" + }, { "label": "intent exclude", "to": "cli/intent-exclude" diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 5e4fa0f..b3369b0 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -31,9 +31,10 @@ Intent creates guidance like: ## Skill Loading -Before substantial work: -- Skill check: run `pnpm dlx @tanstack/intent@latest list`, or use skills already listed in context. -- Skill guidance: if one local skill clearly matches the task, run `pnpm dlx @tanstack/intent@latest load #` and follow the returned `SKILL.md`. +Before editing files for a substantial task: +- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills. +- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load #` before changing files. +- Use the loaded `SKILL.md` guidance while making the change. - Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed. - Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns. @@ -41,6 +42,20 @@ Before substantial work: Intent detects the package manager when generating this block, so the runner may be `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`. +To enforce loading guidance before edits in supported agents, opt in to hooks: + +```bash +npx @tanstack/intent@latest hooks install +``` + +Project-scoped hooks are installed for Claude Code and Codex. `intent install` can write project guidance to `.github/copilot-instructions.md`, but GitHub Copilot CLI hook enforcement is user-scoped, so configure it explicitly: + +```bash +npx @tanstack/intent@latest hooks install --scope user --agents copilot +``` + +Cursor and generic `AGENTS.md` agents use the guidance block only. + ## 2. Choose which packages' skills to use `package.json#intent.skills` is an allowlist of the packages whose skills you want surfaced. @@ -106,4 +121,3 @@ You can also check if any skills reference outdated source documentation: ```bash npx @tanstack/intent@latest stale ``` - diff --git a/docs/overview.md b/docs/overview.md index 1d62c3a..237fffe 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -12,12 +12,15 @@ Skills are markdown documents that teach AI coding agents how to use your librar Intent provides tooling for two workflows: **For consumers:** + - Discover skills from your project and workspace dependencies - Control which packages' skills are surfaced with an allowlist - Add lightweight skill loading guidance to your agent config +- Add hook enforcement for agents that support blocking lifecycle hooks - Keep skills synchronized with library versions **For maintainers (library teams):** + - Scaffold skills through AI-assisted domain discovery - Validate SKILL.md format and packaging - Ship skills in the same release pipeline as code @@ -50,6 +53,12 @@ npx @tanstack/intent@latest install Creates or updates lightweight `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.). Existing guidance is updated in place; otherwise `AGENTS.md` is the default target. Pass `--map` to opt in to explicit task-to-skill mappings. +```bash +npx @tanstack/intent@latest hooks install +``` + +Installs hook enforcement for supported agents. Project-scoped hooks are available for Claude Code and Codex. GitHub Copilot CLI project guidance can live in `.github/copilot-instructions.md`, while blocking hooks are user-scoped. Cursor and generic `AGENTS.md` agents use guidance only. + ```bash npx @tanstack/intent@latest load @tanstack/query#fetching ``` diff --git a/evals/intent-discovery/README.md b/evals/intent-discovery/README.md index 77b880d..76f3647 100644 --- a/evals/intent-discovery/README.md +++ b/evals/intent-discovery/README.md @@ -22,6 +22,14 @@ pnpm eval:intent-discovery:report Set `INTENT_DISCOVERY_RUN_COUNT=3` with the live commands to run each live condition three times and include `pass@k` / `pass^k` in the generated summary. +## Live eval speed + +Only the live `copilot -p` subprocess runs are slow; the saved-transcript suite (`pnpm eval:intent-discovery`) is unaffected. + +- `INTENT_DISCOVERY_LIVE_CONCURRENCY` bounds how many live runs execute at once (default `1`, clamped to an integer `>= 1`). Values above `1` measured slower here: concurrent `copilot -p` calls on one account contend upstream (a run with its own isolated `COPILOT_HOME` still slowed ~2x), so raise it only with separate accounts or dedicated infrastructure. +- `COPILOT_MODEL` selects the Copilot model end-to-end. The adapter passes the process environment through to `copilot -p`, and the CLI honors `COPILOT_MODEL`. `INTENT_DISCOVERY_COPILOT_MODEL` only sets the model label recorded in report metadata; it does not change the model the CLI runs. +- `INTENT_DISCOVERY_RUN_COUNT` stays `1` by default for iteration. Set it to `3` only when measuring `pass@k` / `pass^k`. + The optional LLM judge is secondary. It can annotate whether final answers appear to apply loaded guidance, but it never changes deterministic scores such as `StrictIntentInvocation`, `CorrectSkillLoaded`, or `AutonomousDiscoverySuccess`. ## Current scope diff --git a/evals/intent-discovery/condition-setup.eval.ts b/evals/intent-discovery/condition-setup.eval.ts index 53e5bf1..0fec098 100644 --- a/evals/intent-discovery/condition-setup.eval.ts +++ b/evals/intent-discovery/condition-setup.eval.ts @@ -47,7 +47,7 @@ describe('Intent discovery condition setup', () => { expect(result.filesWritten).toHaveLength(4) expect(agents).toContain('Skill Loading') expect(agents).toContain('npx @tanstack/intent@latest list') - expect(agents).not.toContain('\nskills:\n') + expect(agents).not.toContain('\ntanstackIntent:\n') expect(packageJson).toContain('"@tanstack/router"') expect( existsSync( @@ -81,8 +81,11 @@ describe('Intent discovery condition setup', () => { 'utf8', ) - expect(agents).toContain('skills:') - expect(agents).toContain('use: "@tanstack/router#routing"') + expect(agents).toContain('tanstackIntent:') + expect(agents).toContain('id: "@tanstack/router#routing"') + expect(agents).toContain( + 'run: "npx @tanstack/intent@latest load @tanstack/router#routing"', + ) } finally { prepared.cleanup() } diff --git a/evals/intent-discovery/corpus/conditions.ts b/evals/intent-discovery/corpus/conditions.ts index 656d067..0190f37 100644 --- a/evals/intent-discovery/corpus/conditions.ts +++ b/evals/intent-discovery/corpus/conditions.ts @@ -15,6 +15,10 @@ const intentDiscoveryConditions = [ id: 'mapped-intent', countsTowardAutonomousScore: true, }, + { + id: 'hooked-intent', + countsTowardAutonomousScore: true, + }, { id: 'explicit-intent-control', countsTowardAutonomousScore: false, diff --git a/evals/intent-discovery/corpus/live-tasks.ts b/evals/intent-discovery/corpus/live-tasks.ts index d0977de..80c7451 100644 --- a/evals/intent-discovery/corpus/live-tasks.ts +++ b/evals/intent-discovery/corpus/live-tasks.ts @@ -46,6 +46,20 @@ export const liveTasks: Array = [ failureClass: 'strict-success', }, }, + { + id: 'live-router-hooked-intent', + fixture: 'router-basic', + condition: 'hooked-intent', + explicitnessLevel: 2, + prompt: routerPrompt, + expectedSkillAreas: ['router'], + expected: { + strictInvocation: true, + correctSkillLoaded: true, + referenceOnly: false, + failureClass: 'strict-success', + }, + }, { id: 'live-router-explicit-intent-control', fixture: 'router-basic', diff --git a/evals/intent-discovery/harness/intent-hooks/gate.mjs b/evals/intent-discovery/harness/intent-hooks/gate.mjs new file mode 100644 index 0000000..d70df60 --- /dev/null +++ b/evals/intent-discovery/harness/intent-hooks/gate.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import { + appendObservation, + readEventFromStdin, + readObservations, +} from './hook-io.mjs' +import { + gateDecision, + hasLoadFromObservations, + observationFromEvent, +} from './hook-core.mjs' + +try { + const event = readEventFromStdin() + const observation = observationFromEvent(event) + + if (observation) { + appendObservation(observation) + } + + const toolName = event?.tool_name ?? event?.toolName + const decision = gateDecision({ + toolName, + hasLoaded: hasLoadFromObservations(readObservations()), + }) + + if (decision.decision === 'deny') { + process.stdout.write( + JSON.stringify({ + permissionDecision: 'deny', + permissionDecisionReason: decision.reason, + }), + ) + } +} catch { + // Fail open: never block on hook error. +} + +process.exit(0) diff --git a/evals/intent-discovery/harness/intent-hooks/hook-core.d.mts b/evals/intent-discovery/harness/intent-hooks/hook-core.d.mts new file mode 100644 index 0000000..3050482 --- /dev/null +++ b/evals/intent-discovery/harness/intent-hooks/hook-core.d.mts @@ -0,0 +1,36 @@ +export type IntentAction = 'list' | 'load' + +export type IntentInvocation = { + action: IntentAction + skillUse?: string +} + +export type IntentObservation = { + action: IntentAction + skillUse?: string + raw: string +} + +export type GateDecision = + | { decision: 'allow' } + | { decision: 'deny'; reason: string } + +export const EDIT_TOOLS: Set +export const GATE_DENY_REASON: string + +export function parseIntentInvocation( + command: unknown, +): IntentInvocation | undefined + +export function observationFromEvent( + event: unknown, +): IntentObservation | undefined + +export function gateDecision(input: { + toolName: unknown + hasLoaded: boolean +}): GateDecision + +export function hasLoadFromObservations( + observations: Array<{ action?: string } | null | undefined>, +): boolean diff --git a/evals/intent-discovery/harness/intent-hooks/hook-core.mjs b/evals/intent-discovery/harness/intent-hooks/hook-core.mjs new file mode 100644 index 0000000..8391838 --- /dev/null +++ b/evals/intent-discovery/harness/intent-hooks/hook-core.mjs @@ -0,0 +1,80 @@ +const INTENT_COMMAND_PATTERN = + /(?:^|\s|&&|;|\|)\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 = new Set([ + 'Write', + 'Edit', + 'MultiEdit', + 'NotebookEdit', +]) + +export const GATE_DENY_REASON = + 'Blocked: load the matching TanStack guidance before editing. Use the guidance command from the AGENTS.md tanstackIntent block, then retry the edit.' + +export 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() + const skillUse = action === 'load' ? match[3] : undefined + + if (action === 'load' && !skillUse) { + return undefined + } + + return { action, skillUse } +} + +export function observationFromEvent(event) { + if (!event || typeof event !== 'object') { + return undefined + } + + const toolName = event.tool_name ?? event.toolName + const toolInput = event.tool_input ?? event.toolArgs + + if (toolName !== 'Bash') { + return undefined + } + + const command = + typeof toolInput === 'string' + ? safeCommandFromString(toolInput) + : toolInput?.command + + const parsed = parseIntentInvocation(command) + + if (!parsed) { + return undefined + } + + return { action: parsed.action, skillUse: parsed.skillUse, raw: command } +} + +export function gateDecision({ toolName, hasLoaded }) { + if (EDIT_TOOLS.has(toolName) && !hasLoaded) { + return { decision: 'deny', reason: GATE_DENY_REASON } + } + + return { decision: 'allow' } +} + +export function hasLoadFromObservations(observations) { + return observations.some((entry) => entry?.action === 'load') +} + +function safeCommandFromString(value) { + try { + const parsed = JSON.parse(value) + return typeof parsed?.command === 'string' ? parsed.command : value + } catch { + return value + } +} diff --git a/evals/intent-discovery/harness/intent-hooks/hook-io.mjs b/evals/intent-discovery/harness/intent-hooks/hook-io.mjs new file mode 100644 index 0000000..2c0b076 --- /dev/null +++ b/evals/intent-discovery/harness/intent-hooks/hook-io.mjs @@ -0,0 +1,49 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname } from 'node:path' + +const ATTRIB_FILE = process.env.INTENT_DISCOVERY_GATE_STATE + +export function readEventFromStdin() { + try { + return JSON.parse(readFileSync(0, 'utf8')) + } catch { + return {} + } +} + +export function appendObservation(observation) { + if (!ATTRIB_FILE) { + return + } + + try { + mkdirSync(dirname(ATTRIB_FILE), { recursive: true }) + appendFileSync( + ATTRIB_FILE, + `${JSON.stringify({ ts: new Date().toISOString(), ...observation })}\n`, + ) + } catch { + // Fail open: a hook must never brick the run. + } +} + +export function readObservations() { + if (!ATTRIB_FILE || !existsSync(ATTRIB_FILE)) { + return [] + } + + try { + return readFileSync(ATTRIB_FILE, 'utf8') + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)] + } catch { + return [] + } + }) + } catch { + return [] + } +} diff --git a/evals/intent-discovery/harness/prepare-copilot-home.ts b/evals/intent-discovery/harness/prepare-copilot-home.ts new file mode 100644 index 0000000..dc48ce3 --- /dev/null +++ b/evals/intent-discovery/harness/prepare-copilot-home.ts @@ -0,0 +1,60 @@ +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const harnessDir = dirname(fileURLToPath(import.meta.url)) +const hooksSourceDir = join(harnessDir, 'intent-hooks') +const runsDir = join(dirname(harnessDir), 'runs') +const gateHomeDir = join(runsDir, '.copilot-homes', 'gate') +const gateStateDir = join(runsDir, 'latest', 'gate-state') + +export type GateRun = { + copilotHome: string + stateFile: string +} + +let builtGateHome: string | undefined + +export function prepareGateRun(runId: string): GateRun { + const copilotHome = buildGateHome() + + mkdirSync(gateStateDir, { recursive: true }) + const stateFile = join(gateStateDir, `${runId}.jsonl`) + rmSync(stateFile, { force: true }) + + return { copilotHome, stateFile } +} + +function buildGateHome(): string { + if (builtGateHome) { + return builtGateHome + } + + const realHome = join(homedir(), '.copilot') + + mkdirSync(join(gateHomeDir, 'hooks'), { recursive: true }) + copyIfPresent(join(realHome, 'config.json'), join(gateHomeDir, 'config.json')) + copyIfPresent( + join(realHome, 'permissions-config.json'), + join(gateHomeDir, 'permissions-config.json'), + ) + copyIfPresent(join(realHome, 'ide'), join(gateHomeDir, 'ide')) + + const command = `node ${join(hooksSourceDir, 'gate.mjs')}` + + writeFileSync( + join(gateHomeDir, 'hooks', 'hooks.json'), + `${JSON.stringify({ hooks: { PreToolUse: [{ command }] } }, null, 2)}\n`, + ) + + builtGateHome = gateHomeDir + + return gateHomeDir +} + +function copyIfPresent(source: string, destination: string): void { + if (existsSync(source)) { + cpSync(source, destination, { recursive: true }) + } +} diff --git a/evals/intent-discovery/harness/run-copilot-task.ts b/evals/intent-discovery/harness/run-copilot-task.ts index f1295aa..859af10 100644 --- a/evals/intent-discovery/harness/run-copilot-task.ts +++ b/evals/intent-discovery/harness/run-copilot-task.ts @@ -3,6 +3,8 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { spawn } from 'node:child_process' import { parseIntentCommand } from './parse-intent-commands' +import { prepareGateRun } from './prepare-copilot-home' +import type { GateRun } from './prepare-copilot-home' import type { IntentDiscoveryTask } from '../corpus/tasks' import type { NormalizedMessage, @@ -56,7 +58,12 @@ export async function runCopilotTask( throw new LiveCopilotRunnerUnavailableError() } - const result = await runCommand({ command, input }) + const gateState = + input.task.condition === 'hooked-intent' + ? prepareGateRun(sanitizeFileName(input.runId)) + : undefined + + const result = await runCommand({ command, input, gateState }) const transcript = transcriptFromCommandResult(result) const transcriptPath = writeTranscript(input.runId, transcript) const intentCommandCaptures = captureIntentCommands(transcript) @@ -123,9 +130,11 @@ type IntentCommandCapture = { async function runCommand({ command, input, + gateState, }: { command: string input: RunCopilotTaskInput + gateState?: GateRun }): Promise { return new Promise((resolve, reject) => { let settled = false @@ -134,6 +143,12 @@ async function runCommand({ shell: true, env: { ...process.env, + ...(gateState + ? { + COPILOT_HOME: gateState.copilotHome, + INTENT_DISCOVERY_GATE_STATE: gateState.stateFile, + } + : {}), INTENT_DISCOVERY_TASK_ID: input.task.id, INTENT_DISCOVERY_FIXTURE: input.task.fixture, INTENT_DISCOVERY_PROMPT: input.task.prompt, diff --git a/evals/intent-discovery/harness/setup-intent-condition.ts b/evals/intent-discovery/harness/setup-intent-condition.ts index 3ae9da5..7db3271 100644 --- a/evals/intent-discovery/harness/setup-intent-condition.ts +++ b/evals/intent-discovery/harness/setup-intent-condition.ts @@ -3,14 +3,14 @@ import { join } from 'node:path' import { buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, -} from '../../../packages/intent/src/commands/install-writer.js' +} from '../../../packages/intent/src/commands/install/guidance.js' import { expectedSkillUseByArea, packageAllowlistByArea, } from '../corpus/skill-uses' import type { IntentDiscoveryCondition } from '../corpus/conditions' import type { ExpectedSkillArea } from '../corpus/tasks' -import type { ScanResult } from '../../../packages/intent/src/types.js' +import type { ScanResult } from '../../../packages/intent/src/shared/types.js' export type AppliedIntentCondition = { condition: IntentDiscoveryCondition @@ -116,7 +116,7 @@ function writeAgentsFile({ }): string { const agentsPath = join(workspacePath, 'AGENTS.md') const block = - condition === 'mapped-intent' + condition === 'mapped-intent' || condition === 'hooked-intent' ? mappedGuidanceBlock(expectedSkillAreas) : loadingGuidanceBlock() diff --git a/evals/intent-discovery/intent-hooks.eval.ts b/evals/intent-discovery/intent-hooks.eval.ts new file mode 100644 index 0000000..cccb3b3 --- /dev/null +++ b/evals/intent-discovery/intent-hooks.eval.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + EDIT_TOOLS, + GATE_DENY_REASON, + gateDecision, + hasLoadFromObservations, + observationFromEvent, + parseIntentInvocation, +} from './harness/intent-hooks/hook-core.mjs' +import { applyIntentCondition } from './harness/setup-intent-condition' +import { prepareFixtureWorkspace } from './harness/prepare-fixture' + +describe('intent hook core', () => { + it('parses intent load and list invocations across runners', () => { + expect( + parseIntentInvocation( + 'npx @tanstack/intent@latest load @tanstack/router#routing', + ), + ).toEqual({ action: 'load', skillUse: '@tanstack/router#routing' }) + expect(parseIntentInvocation('intent list')).toEqual({ action: 'list' }) + expect( + parseIntentInvocation('cd packages/app && intent load @tanstack/x#y'), + ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) + }) + + it('ignores non-intent commands and load without a skill use', () => { + expect(parseIntentInvocation('npm run build')).toBeUndefined() + expect(parseIntentInvocation('intent load')).toBeUndefined() + expect(parseIntentInvocation(undefined)).toBeUndefined() + }) + + it('observes intent commands only from Bash tool calls', () => { + expect( + observationFromEvent({ + tool_name: 'Bash', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }), + ).toEqual({ + action: 'load', + skillUse: '@tanstack/router#routing', + raw: 'intent load @tanstack/router#routing', + }) + expect( + observationFromEvent({ + tool_name: 'Edit', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }), + ).toBeUndefined() + expect( + observationFromEvent({ + tool_name: 'Bash', + tool_input: { command: 'echo hello' }, + }), + ).toBeUndefined() + }) + + it('denies edits until a load is observed, allows shell tools', () => { + expect(gateDecision({ toolName: 'Edit', hasLoaded: false })).toEqual({ + decision: 'deny', + reason: GATE_DENY_REASON, + }) + expect(gateDecision({ toolName: 'Write', hasLoaded: false })).toEqual({ + decision: 'deny', + reason: GATE_DENY_REASON, + }) + expect(gateDecision({ toolName: 'Edit', hasLoaded: true })).toEqual({ + decision: 'allow', + }) + expect(gateDecision({ toolName: 'Bash', hasLoaded: false })).toEqual({ + decision: 'allow', + }) + expect(EDIT_TOOLS.has('Write')).toBe(true) + expect(EDIT_TOOLS.has('Edit')).toBe(true) + }) + + it('detects a prior load from observation records', () => { + expect(hasLoadFromObservations([{ action: 'list' }])).toBe(false) + expect( + hasLoadFromObservations([{ action: 'list' }, { action: 'load' }]), + ).toBe(true) + }) + + it('keeps the deny reason free of parseable intent commands', () => { + expect(parseIntentInvocation(GATE_DENY_REASON)).toBeUndefined() + expect(/intent\s+(list|load)/i.test(GATE_DENY_REASON)).toBe(false) + }) +}) + +describe('hooked-intent condition setup', () => { + it('writes the mapped guidance block the gate points to', () => { + const prepared = prepareFixtureWorkspace({ fixture: 'router-basic' }) + + try { + applyIntentCondition({ + condition: 'hooked-intent', + expectedSkillAreas: ['router'], + workspacePath: prepared.workspacePath, + }) + const agents = readFileSync( + join(prepared.workspacePath, 'AGENTS.md'), + 'utf8', + ) + + expect(agents).toContain('tanstackIntent:') + expect(agents).toContain('id: "@tanstack/router#routing"') + expect(agents).toContain( + 'run: "npx @tanstack/intent@latest load @tanstack/router#routing"', + ) + } finally { + prepared.cleanup() + } + }) +}) diff --git a/evals/intent-discovery/live-copilot-harness.eval.ts b/evals/intent-discovery/live-copilot-harness.eval.ts index 631f7cf..faac304 100644 --- a/evals/intent-discovery/live-copilot-harness.eval.ts +++ b/evals/intent-discovery/live-copilot-harness.eval.ts @@ -97,17 +97,19 @@ describe('Intent discovery live Copilot harness', () => { rmSync(tempDir, { recursive: true, force: true }) } }) +}) +describe.concurrent('Intent discovery live runs', () => { for (const liveTask of liveTasks) { for (let runIndex = 1; runIndex <= liveRunCount; runIndex += 1) { it.skipIf(process.env.INTENT_DISCOVERY_RUN_LIVE !== '1')( `live/${liveTask.condition}/${liveTask.fixture}/run-${runIndex}`, - async (context) => { + async ({ task: contextTask, expect }) => { const task = liveRunTask(liveTask, runIndex) const result = await runLiveHarness(task) attachLiveEvalMetadata({ - contextTask: context.task, + contextTask, result, task, }) diff --git a/evals/intent-discovery/vitest.evals.config.ts b/evals/intent-discovery/vitest.evals.config.ts index 28a386a..95e1c10 100644 --- a/evals/intent-discovery/vitest.evals.config.ts +++ b/evals/intent-discovery/vitest.evals.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ include: ['evals/intent-discovery/**/*.eval.ts'], testTimeout: 120_000, hookTimeout: 120_000, + maxConcurrency: liveConcurrencyFromEnv(), reporters: ['default'], env: { VITEST_EVALS_REPLAY_DIR: @@ -13,3 +14,13 @@ export default defineConfig({ }, }, }) + +function liveConcurrencyFromEnv(): number { + const raw = Number(process.env.INTENT_DISCOVERY_LIVE_CONCURRENCY ?? '1') + + if (!Number.isFinite(raw)) { + return 1 + } + + return Math.max(1, Math.trunc(raw)) +} diff --git a/knip.json b/knip.json index fb1b4d4..f46e762 100644 --- a/knip.json +++ b/knip.json @@ -4,15 +4,18 @@ ".": { "entry": [ "scripts/*.ts", - "evals/intent-discovery/*.eval.ts", - "evals/intent-discovery/bin/*.mjs" + "evals/intent-discovery/**/*.ts", + "evals/intent-discovery/**/*.mjs", + "evals/intent-discovery/harness/intent-hooks/gate.mjs" ], "ignoreBinaries": ["copilot", "diff"], - "ignoreFiles": ["evals/intent-discovery/fixtures/**/src/**/*"] + "ignoreFiles": [ + "evals/intent-discovery/fixtures/**/src/**/*", + "evals/intent-discovery/harness/intent-hooks/hook-core.d.mts" + ] }, "packages/intent": { "entry": ["src/index.ts", "src/cli.ts", "src/core.ts", "src/setup.ts"], - "ignore": ["meta/**"], "ignoreDependencies": ["verdaccio"] } } diff --git a/packages/intent/README.md b/packages/intent/README.md index fc4a9da..8a4aabd 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -122,6 +122,7 @@ The real risk with any derived artifact is staleness. `npx @tanstack/intent@late | Command | Description | | -------------------------------------------------- | --------------------------------------------------- | | `npx @tanstack/intent@latest install` | Set up skill loading guidance in agent config files | +| `npx @tanstack/intent@latest hooks install` | Install hook enforcement for supported agents | | `npx @tanstack/intent@latest list [--json]` | Discover local intent-enabled packages | | `npx @tanstack/intent@latest load ` | Load `#` SKILL.md content | | `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 481590e..0498628 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -3,10 +3,11 @@ import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { cac } from 'cac' -import { fail, isCliFailure } from './cli-error.js' +import { fail, isCliFailure } from './shared/cli-error.js' import type { CAC } from 'cac' import type { ExcludeCommandOptions } from './commands/exclude.js' -import type { InstallCommandOptions } from './commands/install.js' +import type { HooksInstallCommandOptions } from './commands/hooks/command.js' +import type { InstallCommandOptions } from './commands/install/command.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' import type { StaleCommandOptions } from './commands/stale.js' @@ -79,7 +80,7 @@ function createCli(): CAC { .example('meta domain-discovery') .action(async (name?: string) => { const [{ getMetaDir }, { runMetaCommand }] = await Promise.all([ - import('./cli-support.js'), + import('./commands/support.js'), import('./commands/meta.js'), ]) await runMetaCommand(name, getMetaDir()) @@ -127,18 +128,42 @@ function createCli(): CAC { .example('install --global') .action(async (options: InstallCommandOptions) => { const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ - import('./cli-support.js'), - import('./commands/install.js'), + import('./commands/support.js'), + import('./commands/install/command.js'), ]) await runInstallCommand(options, scanIntentsOrFail) }) + cli + .command('hooks [action]', 'Manage agent hooks that enforce skill loading') + .usage( + 'hooks install [--scope project|user] [--agents copilot,claude,codex|all]', + ) + .option('--scope ', 'Hook install scope: project or user') + .option('--agents ', 'Hook agents: copilot,claude,codex, or all') + .example('hooks install') + .example('hooks install --scope user --agents copilot') + .action( + async ( + action: string | undefined, + options: HooksInstallCommandOptions, + ) => { + if (action !== 'install') { + fail('Unknown hooks action: expected install.') + } + + const { runHooksInstallCommand } = + await import('./commands/hooks/command.js') + runHooksInstallCommand(options) + }, + ) + cli .command('scaffold', 'Print maintainer scaffold prompt') .usage('scaffold') .action(async () => { const [{ getMetaDir }, { runScaffoldCommand }] = await Promise.all([ - import('./cli-support.js'), + import('./commands/support.js'), import('./commands/scaffold.js'), ]) runScaffoldCommand(getMetaDir()) @@ -160,7 +185,7 @@ function createCli(): CAC { async (targetDir: string | undefined, options: StaleCommandOptions) => { const [{ resolveStaleTargets }, { runStaleCommand }] = await Promise.all([ - import('./cli-support.js'), + import('./commands/support.js'), import('./commands/stale.js'), ]) await runStaleCommand(targetDir, options, resolveStaleTargets) @@ -175,7 +200,7 @@ function createCli(): CAC { .usage('edit-package-json') .action(async () => { const { runEditPackageJsonCommand } = - await import('./commands/edit-package-json.js') + await import('./commands/setup/edit-package-json.js') await runEditPackageJsonCommand(process.cwd()) }) @@ -188,8 +213,8 @@ function createCli(): CAC { .action(async () => { const [{ getMetaDir }, { runSetupGithubActionsCommand }] = await Promise.all([ - import('./cli-support.js'), - import('./commands/setup-github-actions.js'), + import('./commands/support.js'), + import('./commands/setup/github-actions.js'), ]) await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) }) @@ -203,8 +228,8 @@ function createCli(): CAC { .action(async () => { const [{ getMetaDir }, { runSetupGithubActionsCommand }] = await Promise.all([ - import('./cli-support.js'), - import('./commands/setup-github-actions.js'), + import('./commands/support.js'), + import('./commands/setup/github-actions.js'), ]) await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) }) diff --git a/packages/intent/src/commands/exclude.ts b/packages/intent/src/commands/exclude.ts index 2b6f3b3..d525409 100644 --- a/packages/intent/src/commands/exclude.ts +++ b/packages/intent/src/commands/exclude.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { fail } from '../cli-error.js' +import { fail } from '../shared/cli-error.js' import { compileExcludePatterns } from '../core/excludes.js' export interface ExcludeCommandOptions { diff --git a/packages/intent/src/commands/hooks/command.ts b/packages/intent/src/commands/hooks/command.ts new file mode 100644 index 0000000..aca1eee --- /dev/null +++ b/packages/intent/src/commands/hooks/command.ts @@ -0,0 +1,26 @@ +import { + formatHookInstallResult, + runInstallHooks, + validateHookInstallOptions, +} from '../../hooks/install.js' + +export interface HooksInstallCommandOptions { + agents?: string + scope?: string +} + +export function runHooksInstallCommand( + options: HooksInstallCommandOptions, +): void { + validateHookInstallOptions(options) + + const results = runInstallHooks({ + agents: options.agents, + root: process.cwd(), + scope: options.scope, + }) + + for (const result of results) { + console.log(formatHookInstallResult(result)) + } +} diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install/command.ts similarity index 89% rename from packages/intent/src/commands/install.ts rename to packages/intent/src/commands/install/command.ts index 020b0e9..718a7c6 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install/command.ts @@ -1,22 +1,22 @@ import { relative } from 'node:path' -import { fail } from '../cli-error.js' -import { detectIntentCommandPackageManager } from '../command-runner.js' +import { fail } from '../../shared/cli-error.js' +import { detectIntentCommandPackageManager } from '../../shared/command-runner.js' import { coreOptionsFromGlobalFlags, noticeOptionsFromGlobalFlags, printNotices, printWarnings, -} from '../cli-support.js' +} from '../support.js' import { buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, resolveIntentSkillsBlockTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, -} from './install-writer.js' -import type { GlobalScanFlags } from '../cli-support.js' -import type { IntentCoreOptions } from '../core.js' -import type { ScanResult } from '../types.js' +} from './guidance.js' +import type { GlobalScanFlags } from '../support.js' +import type { IntentCoreOptions } from '../../core/index.js' +import type { ScanResult } from '../../shared/types.js' export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. @@ -27,7 +27,7 @@ Hard rules: - If skills are discovered and no mapping block exists, create AGENTS.md unless the user asks for another supported config file. - If a mapping block already exists in a supported config file, update that file. - Preserve all content outside the managed block unchanged. -- Store compact \`use\` values in the managed block; do not write \`load\` paths. +- Store compact \`id\` values and runnable \`run\` commands in the managed block; do not write local paths. - Never write absolute local file paths, node_modules paths, or package-manager-internal paths in the managed block. - Verify the target file before your final response. @@ -87,19 +87,20 @@ Follow these steps in order: Use this exact block: -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - when: "describe the task or code area here" - use: "@scope/package#skill-name" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@scope/package#skill-name" + run: "npx @tanstack/intent@latest load @scope/package#skill-name" + for: "describe the task or code area here" Rules: - - Use the user's own words for \`when\` descriptions - - Use compact \`use\` values in \`#\` format - - Do not include \`load\` + - Use the user's own words for \`for\` descriptions + - Use compact \`id\` values in \`#\` format + - Include a \`run\` command that loads the matching \`id\` - Do not include machine-specific directories such as \`/Users/...\`, \`/home/...\`, \`/private/...\`, drive letters, temp workspace paths, \`.pnpm/\`, \`.bun/\`, or \`.yarn/\`. - - Agents should load \`use\` at runtime with \`npx @tanstack/intent@latest load \` + - Agents should run the \`run\` command before editing matching files - Keep entries concise - this block is read on every agent task - Preserve all content outside the block tags unchanged - If the user is on Deno, note that this setup is best-effort today and relies on npm interop @@ -108,9 +109,9 @@ skills: Before reporting completion: - Confirm the target file exists - Confirm it contains both managed block markers - - Confirm every mapping has \`when\` and \`use\` - - Confirm every \`use\` parses as \`#\` - - Confirm no mapping includes \`load\` + - Confirm every mapping has \`id\`, \`run\`, and \`for\` + - Confirm every \`id\` parses as \`#\` + - Confirm no mapping includes local file paths - Confirm no path-like machine-specific values are stored in the managed block - Confirm every discovered actionable skill is mapped, skipped by rule, or deferred by user choice diff --git a/packages/intent/src/commands/install-writer.ts b/packages/intent/src/commands/install/guidance.ts similarity index 77% rename from packages/intent/src/commands/install-writer.ts rename to packages/intent/src/commands/install/guidance.ts index e7a9524..0ba3d55 100644 --- a/packages/intent/src/commands/install-writer.ts +++ b/packages/intent/src/commands/install/guidance.ts @@ -1,9 +1,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseYaml } from 'yaml' -import { formatIntentCommand } from '../command-runner.js' -import { formatSkillUse, parseSkillUse } from '../skill-use.js' -import type { ScanResult, SkillEntry } from '../types.js' +import { formatIntentCommand } from '../../shared/command-runner.js' +import { isGeneratedMappingSkill } from '../../skills/categories.js' +import type { ScanResult, SkillEntry } from '../../shared/types.js' +import { formatSkillUse, parseSkillUse } from '../../skills/use.js' const INTENT_SKILLS_START = '' const INTENT_SKILLS_END = '' @@ -15,13 +16,6 @@ const SUPPORTED_AGENT_CONFIG_FILES = [ '.github/copilot-instructions.md', ] -const NON_ACTIONABLE_SKILL_TYPES = new Set([ - 'maintainer', - 'maintainer-only', - 'meta', - 'reference', -]) - export interface IntentSkillsBlockResult { block: string mappingCount: number @@ -98,7 +92,7 @@ function readManagedBlock(content: string): { function parseSkillsList(block: string): { errors: Array - skills: Array + mappings: Array } { const yamlBody = normalizeBlock(block) .split('\n') @@ -108,21 +102,23 @@ function parseSkillsList(block: string): { .join('\n') try { - const parsed = parseYaml(yamlBody) as { skills?: unknown } | null - if (!parsed || !Array.isArray(parsed.skills)) { + const parsed = parseYaml(yamlBody) as { + tanstackIntent?: unknown + } | null + if (!parsed || !Array.isArray(parsed.tanstackIntent)) { return { - errors: ['Managed block must contain a skills list.'], - skills: [], + errors: ['Managed block must contain a tanstackIntent list.'], + mappings: [], } } - return { errors: [], skills: parsed.skills } + return { errors: [], mappings: parsed.tanstackIntent } } catch (err) { return { errors: [ `Managed block contains invalid YAML: ${err instanceof Error ? err.message : String(err)}`, ], - skills: [], + mappings: [], } } } @@ -166,39 +162,53 @@ export function verifyIntentSkillsBlockFile({ } } - const { skills, errors: parseErrors } = parseSkillsList(block) + const { mappings, errors: parseErrors } = parseSkillsList(block) errors.push(...parseErrors) - if (skills.length !== expectedMappingCount) { + if (mappings.length !== expectedMappingCount) { errors.push( - `Expected ${expectedMappingCount} skill mappings, found ${skills.length}.`, + `Expected ${expectedMappingCount} skill mappings, found ${mappings.length}.`, ) } - for (const skill of skills) { - if (!skill || typeof skill !== 'object') { + for (const mappingValue of mappings) { + if (!mappingValue || typeof mappingValue !== 'object') { errors.push('Each skill mapping must be an object.') continue } - const mapping = skill as { load?: unknown; use?: unknown; when?: unknown } + const mapping = mappingValue as { + for?: unknown + id?: unknown + run?: unknown + use?: unknown + when?: unknown + } - if (mapping.load !== undefined) { - errors.push('Skill mappings must use compact `use` entries, not `load`.') + if (mapping.use !== undefined) { + errors.push('Skill mappings must use `id` entries, not `use`.') } - if (typeof mapping.when !== 'string' || mapping.when.trim() === '') { - errors.push('Each skill mapping must include a non-empty `when` field.') + if (mapping.when !== undefined) { + errors.push('Skill mappings must use compact `for` entries, not `when`.') } - if (typeof mapping.use !== 'string') { - errors.push('Each skill mapping must include a `use` field.') + if (typeof mapping.id !== 'string') { + errors.push('Each skill mapping must include an `id` field.') } else { try { - parseSkillUse(mapping.use) + parseSkillUse(mapping.id) } catch (err) { errors.push(err instanceof Error ? err.message : String(err)) } } + + if (typeof mapping.run !== 'string' || mapping.run.trim() === '') { + errors.push('Each skill mapping must include a non-empty `run` field.') + } + + if (typeof mapping.for !== 'string' || mapping.for.trim() === '') { + errors.push('Each skill mapping must include a non-empty `for` field.') + } } return { @@ -226,11 +236,6 @@ function quoteYamlString(value: string): string { return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"` } -function isActionableSkill(skill: SkillEntry): boolean { - const type = skill.type?.trim().toLowerCase() - return !type || !NON_ACTIONABLE_SKILL_TYPES.has(type) -} - function formatWhen(packageName: string, skill: SkillEntry): string { const description = skill.description.replace(/\s+/g, ' ').trim() return description || `Use ${packageName} ${skill.name}` @@ -241,28 +246,33 @@ export function buildIntentSkillsBlock( ): IntentSkillsBlockResult { const lines = [ INTENT_SKILLS_START, - `# Skill mappings - load \`use\` with \`${formatIntentCommand( - scanResult.packageManager, - 'load ', - )}\`.`, - 'skills:', + '# TanStack Intent - before editing files, run the matching guidance command.', + 'tanstackIntent:', ] let mappingCount = 0 for (const pkg of [...scanResult.packages].sort(compareNames)) { for (const skill of [...pkg.skills].sort(compareNames)) { - if (!isActionableSkill(skill)) continue + if (!isGeneratedMappingSkill(skill)) continue mappingCount++ - lines.push(` - when: ${quoteYamlString(formatWhen(pkg.name, skill))}`) lines.push( - ` use: ${quoteYamlString(formatSkillUse(pkg.name, skill.name))}`, + ` - id: ${quoteYamlString(formatSkillUse(pkg.name, skill.name))}`, + ) + lines.push( + ` run: ${quoteYamlString( + formatIntentCommand( + scanResult.packageManager, + `load ${formatSkillUse(pkg.name, skill.name)}`, + ), + )}`, ) + lines.push(` for: ${quoteYamlString(formatWhen(pkg.name, skill))}`) } } if (mappingCount === 0) { - lines[2] = 'skills: []' + lines[2] = 'tanstackIntent: []' } lines.push(INTENT_SKILLS_END) @@ -286,9 +296,10 @@ export function buildIntentSkillGuidanceBlock( INTENT_SKILLS_START, '## Skill Loading', '', - 'Before substantial work:', - `- Skill check: run \`${listCommand}\`, or use skills already listed in context.`, - `- Skill guidance: if one local skill clearly matches the task, run \`${loadCommand}\` and follow the returned \`SKILL.md\`.`, + 'Before editing files for a substantial task:', + `- Run \`${listCommand}\` from the workspace root to see available local skills.`, + `- If a listed skill matches the task, run \`${loadCommand}\` before changing files.`, + '- Use the loaded `SKILL.md` guidance while making the change.', '- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.', '- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.', INTENT_SKILLS_END, diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index ea84286..1108a33 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -4,16 +4,16 @@ import { printDebugInfo, printNotices, printWarnings, -} from '../cli-support.js' -import { formatIntentCommand } from '../command-runner.js' -import { listIntentSkills } from '../core.js' -import type { GlobalScanFlags } from '../cli-support.js' +} from './support.js' +import { formatIntentCommand } from '../shared/command-runner.js' +import { listIntentSkills } from '../core/index.js' +import type { GlobalScanFlags } from './support.js' import type { IntentPackageSummary, IntentSkillList, IntentSkillSummary, -} from '../core.js' -import type { ScanResult } from '../types.js' +} from '../core/index.js' +import type { ScanResult } from '../shared/types.js' export interface ListCommandOptions extends GlobalScanFlags { json?: boolean @@ -105,7 +105,7 @@ export async function runListCommand( } const { computeSkillNameWidth, printSkillTree, printTable } = - await import('../display.js') + await import('../shared/display.js') if (result.packages.length === 0) { console.log('No intent-enabled packages found.') diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index 88db754..7a35de1 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -1,12 +1,12 @@ -import { fail } from '../cli-error.js' -import { coreOptionsFromGlobalFlags, printDebugInfo } from '../cli-support.js' +import { fail } from '../shared/cli-error.js' +import { coreOptionsFromGlobalFlags, printDebugInfo } from './support.js' import { IntentCoreError, loadIntentSkill, resolveIntentSkill, -} from '../core.js' -import type { GlobalScanFlags } from '../cli-support.js' -import type { LoadedIntentSkill, ResolvedIntentSkill } from '../core.js' +} from '../core/index.js' +import type { GlobalScanFlags } from './support.js' +import type { LoadedIntentSkill, ResolvedIntentSkill } from '../core/index.js' export interface LoadCommandOptions extends GlobalScanFlags { json?: boolean diff --git a/packages/intent/src/commands/meta.ts b/packages/intent/src/commands/meta.ts index 3be577e..d75d49d 100644 --- a/packages/intent/src/commands/meta.ts +++ b/packages/intent/src/commands/meta.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' -import { fail } from '../cli-error.js' +import { fail } from '../shared/cli-error.js' export async function runMetaCommand( name: string | undefined, @@ -32,7 +32,7 @@ export async function runMetaCommand( return } - const { parseFrontmatter } = await import('../utils.js') + const { parseFrontmatter } = await import('../shared/utils.js') const entries = readdirSync(metaDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .filter((entry) => existsSync(join(metaDir, entry.name, 'SKILL.md'))) diff --git a/packages/intent/src/commands/edit-package-json.ts b/packages/intent/src/commands/setup/edit-package-json.ts similarity index 60% rename from packages/intent/src/commands/edit-package-json.ts rename to packages/intent/src/commands/setup/edit-package-json.ts index 39a7e07..da7eebf 100644 --- a/packages/intent/src/commands/edit-package-json.ts +++ b/packages/intent/src/commands/setup/edit-package-json.ts @@ -1,4 +1,4 @@ export async function runEditPackageJsonCommand(root: string): Promise { - const { runEditPackageJsonAll } = await import('../setup.js') + const { runEditPackageJsonAll } = await import('../../setup/index.js') runEditPackageJsonAll(root) } diff --git a/packages/intent/src/commands/setup-github-actions.ts b/packages/intent/src/commands/setup/github-actions.ts similarity index 66% rename from packages/intent/src/commands/setup-github-actions.ts rename to packages/intent/src/commands/setup/github-actions.ts index a663592..b5c1870 100644 --- a/packages/intent/src/commands/setup-github-actions.ts +++ b/packages/intent/src/commands/setup/github-actions.ts @@ -2,6 +2,6 @@ export async function runSetupGithubActionsCommand( root: string, metaDir: string, ): Promise { - const { runSetupGithubActions } = await import('../setup.js') + const { runSetupGithubActions } = await import('../../setup/index.js') runSetupGithubActions(root, metaDir) } diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index bb65ef6..462be93 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -1,5 +1,5 @@ -import { isCliFailure } from '../cli-error.js' -import type { StalenessReport } from '../types.js' +import { isCliFailure } from '../shared/cli-error.js' +import type { StalenessReport } from '../shared/types.js' export interface StaleCommandOptions { json?: boolean @@ -87,7 +87,7 @@ async function runGithubReview( createFailedStaleReviewItem, createWorkflowAdvisoryReviewItems, writeStaleReviewWorkflowFiles, - } = await import('../workflow-review.js') + } = await import('../staleness/workflow-review.js') const packageLabel = options.packageLabel ?? 'workspace' try { diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/commands/support.ts similarity index 89% rename from packages/intent/src/cli-support.ts rename to packages/intent/src/commands/support.ts index a79843d..5304d8c 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/commands/support.ts @@ -1,12 +1,16 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { fail } from './cli-error.js' -import { resolveProjectContext } from './core/project-context.js' -import type { IntentCoreOptions } from './core.js' -import type { ScanOptions, ScanResult, StalenessReport } from './types.js' +import { fail } from '../shared/cli-error.js' +import { resolveProjectContext } from '../core/project-context.js' +import type { IntentCoreOptions } from '../core/index.js' +import type { + ScanOptions, + ScanResult, + StalenessReport, +} from '../shared/types.js' -export { printNotices, printWarnings } from './cli-output.js' +export { printNotices, printWarnings } from '../shared/cli-output.js' export interface GlobalScanFlags { debug?: boolean @@ -25,7 +29,7 @@ export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 3 export function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) - return join(thisDir, '..', 'meta') + return join(thisDir, '..', '..', 'meta') } export function getCheckSkillsWorkflowAdvisories(root: string): Array { @@ -51,7 +55,7 @@ export function getCheckSkillsWorkflowAdvisories(root: string): Array { export async function scanIntentsOrFail( coreOptions: IntentCoreOptions = {}, ): Promise { - const { scanForPolicedIntents } = await import('./core/source-policy.js') + const { scanForPolicedIntents } = await import('../core/source-policy.js') try { const { scan } = scanForPolicedIntents({ @@ -133,7 +137,7 @@ export async function resolveStaleTargets( context.workspaceRoot ?? context.packageRoot ?? resolvedRoot const workflowAdvisories = getCheckSkillsWorkflowAdvisories(advisoryRoot) const { buildWorkspaceCoverageSignals, checkStaleness, readPackageName } = - await import('./staleness.js') + await import('../staleness/index.js') const isWorkspaceRootTarget = context.workspaceRoot !== null && resolvedRoot === context.workspaceRoot @@ -155,7 +159,7 @@ export async function resolveStaleTargets( } const { findWorkspaceRoot, getWorkspaceInfo } = - await import('./workspace-patterns.js') + await import('../setup/workspace-patterns.js') const workspaceRoot = findWorkspaceRoot(resolvedRoot) const workspaceInfo = workspaceRoot ? getWorkspaceInfo(workspaceRoot) : null if (workspaceInfo) { @@ -168,7 +172,8 @@ export async function resolveStaleTargets( ), ), ) - const { readIntentArtifacts } = await import('./artifact-coverage.js') + const { readIntentArtifacts } = + await import('../staleness/artifact-coverage.js') const artifacts = existsSync(join(workspaceInfo.root, '_artifacts')) ? readIntentArtifacts(workspaceInfo.root) : null diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index e582700..0f7e617 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -5,10 +5,10 @@ import { writeFileSync, } from 'node:fs' import { basename, dirname, join, relative, resolve } from 'node:path' -import { fail, isCliFailure } from '../cli-error.js' -import { printWarnings } from '../cli-support.js' +import { fail, isCliFailure } from '../shared/cli-error.js' +import { printWarnings } from './support.js' import { resolveProjectContext } from '../core/project-context.js' -import { findWorkspacePackages } from '../workspace-patterns.js' +import { findWorkspacePackages } from '../setup/workspace-patterns.js' import type { ProjectContext } from '../core/project-context.js' interface ValidationError { @@ -329,7 +329,7 @@ async function runValidateCommandInternal( options: ValidateCommandOptions = {}, ): Promise { const [{ parse: parseYaml }, { findSkillFiles, readScalarField }] = - await Promise.all([import('yaml'), import('../utils.js')]) + await Promise.all([import('yaml'), import('../shared/utils.js')]) const context = resolveProjectContext({ cwd: process.cwd(), targetPath: dir, diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index bf593cd..2632019 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1,378 +1 @@ -import { isAbsolute, relative, resolve } from 'node:path' -import { - compileExcludePatterns, - getEffectiveExcludePatterns, -} from './core/excludes.js' -import { createIntentFsCache } from './fs-cache.js' -import { rewriteLoadedSkillMarkdownDestinations } from './core/markdown.js' -import { resolveSkillUseFastPath } from './core/load-resolution.js' -import { resolveProjectContext } from './core/project-context.js' -import { - checkLoadAllowed, - readSkillSourcesConfig, - scanForPolicedIntents, -} from './core/source-policy.js' -import { ResolveSkillUseError, resolveSkillUse } from './resolver.js' -import { formatSkillUse, parseSkillUse } from './skill-use.js' -import type { ResolveSkillResult } from './resolver.js' -import type { IntentFsCache } from './fs-cache.js' -import type { ReadFs } from './utils.js' -import type { ScanOptions, ScanScope } from './types.js' -import type { - IntentCoreErrorCode, - IntentCoreOptions, - IntentSkillList, - IntentSkillSummary, - LoadedIntentSkill, - LoadedIntentSkillDebug, - ResolvedIntentSkill, -} from './core/types.js' - -export type { - IntentCoreErrorCode, - IntentCoreOptions, - IntentPackageSummary, - IntentSkillListDebug, - IntentSkillList, - IntentSkillSummary, - LoadedIntentSkillDebug, - LoadedIntentSkill, - ResolvedIntentSkill, -} from './core/types.js' - -export class IntentCoreError extends Error { - readonly code: IntentCoreErrorCode - readonly suggestedSkills?: Array - - constructor( - code: IntentCoreErrorCode, - message: string, - options: { suggestedSkills?: Array } = {}, - ) { - super(message) - this.name = 'IntentCoreError' - this.code = code - if (options.suggestedSkills) { - this.suggestedSkills = options.suggestedSkills - } - } -} - -function toScanOptions(options: IntentCoreOptions): ScanOptions { - if (options.global && options.globalOnly) { - throw new IntentCoreError( - 'invalid-options', - 'Use either global or globalOnly, not both.', - ) - } - - if (options.globalOnly) { - return { scope: 'global' } - } - - if (options.global) { - return { scope: 'local-and-global' } - } - - return { scope: 'local' } -} - -function getScanScope(options: ScanOptions): ScanScope { - return options.scope ?? (options.includeGlobal ? 'local-and-global' : 'local') -} - -function withFsCache( - options: ScanOptions, - fsCache: IntentFsCache, -): ScanOptions & { fsCache: IntentFsCache } { - return { ...options, fsCache } -} - -function resolveCoreCwd(options: IntentCoreOptions): string { - return resolve(process.cwd(), options.cwd ?? process.cwd()) -} - -export function listIntentSkills( - options: IntentCoreOptions = {}, -): IntentSkillList { - const cwd = resolveCoreCwd(options) - const scanOptions = toScanOptions(options) - const fsCache = createIntentFsCache() - const projectContext = resolveProjectContext({ cwd }) - const { scan, excludePatterns } = scanForPolicedIntents({ - cwd, - scanOptions: withFsCache(scanOptions, fsCache), - coreOptions: options, - context: projectContext, - }) - const packages = scan.packages - const skills = packages.flatMap((pkg) => - pkg.skills.map((skill): IntentSkillSummary => { - return { - use: formatSkillUse(pkg.name, skill.name), - packageName: pkg.name, - packageRoot: pkg.packageRoot, - packageVersion: pkg.version, - packageSource: pkg.source, - skillName: skill.name, - description: skill.description, - type: skill.type, - framework: skill.framework, - } - }), - ) - - const result: IntentSkillList = { - packageManager: scan.packageManager, - skills, - packages: packages.map((pkg) => ({ - name: pkg.name, - version: pkg.version, - source: pkg.source, - packageRoot: pkg.packageRoot, - skillCount: pkg.skills.length, - })), - warnings: scan.warnings, - notices: scan.notices, - conflicts: scan.conflicts, - } - - if (options.debug) { - result.debug = { - cwd, - scope: getScanScope(scanOptions), - excludes: excludePatterns, - packageCount: result.packages.length, - skillCount: result.skills.length, - warningCount: result.warnings.length, - noticeCount: result.notices.length, - conflictCount: result.conflicts.length, - scan: scan.stats, - } - } - - return result -} - -function resolveFromCwd(cwd: string, path: string): string { - return resolve(cwd, path) -} - -function isResolvedPathInsidePackageRoot( - path: string, - packageRoot: string, -): boolean { - const relativePath = relative(packageRoot, path) - return ( - relativePath === '' || - (!relativePath.startsWith('..') && !isAbsolute(relativePath)) - ) -} - -function toResolvedIntentSkill( - cwd: string, - use: string, - resolved: ResolveSkillResult, - readFs: ReadFs, - debug?: LoadedIntentSkillDebug, -): { - realPackageRoot: string - realResolvedPath: string - readFs: ReadFs - result: ResolvedIntentSkill -} { - let realResolvedPath: string - try { - realResolvedPath = readFs.realpathSync.native( - resolveFromCwd(cwd, resolved.path), - ) - } catch { - throw new IntentCoreError( - 'skill-file-not-found', - `Resolved skill file was not found: ${resolved.path}`, - ) - } - const realPackageRoot = readFs.realpathSync.native( - resolveFromCwd(cwd, resolved.packageRoot), - ) - - if (!isResolvedPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { - throw new IntentCoreError( - 'skill-path-outside-package', - `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, - ) - } - - const result: ResolvedIntentSkill = { - path: resolved.path, - packageRoot: resolved.packageRoot, - packageName: resolved.packageName, - skillName: resolved.skillName, - version: resolved.version, - source: resolved.source, - warnings: resolved.warnings, - conflict: resolved.conflict, - } - - if (debug) { - result.debug = debug - } - - return { - realPackageRoot, - realResolvedPath, - readFs, - result, - } -} - -function createLoadedSkillDebug({ - cwd, - excludes, - scan, - resolution, - resolved, - scope, -}: { - cwd: string - excludes: Array - scan: LoadedIntentSkillDebug['scan'] - resolution: LoadedIntentSkillDebug['resolution'] - resolved: ResolveSkillResult - scope: ScanScope -}): LoadedIntentSkillDebug { - return { - cwd, - scope, - resolution, - excludes, - packageName: resolved.packageName, - skillName: resolved.skillName, - version: resolved.version, - source: resolved.source, - path: resolved.path, - warningCount: resolved.warnings.length, - scan, - } -} - -function resolveIntentSkillInCwd( - cwd: string, - use: string, - options: IntentCoreOptions = {}, -): { - realPackageRoot: string - realResolvedPath: string - readFs: ReadFs - result: ResolvedIntentSkill -} { - let parsedUse: ReturnType - try { - parsedUse = parseSkillUse(use) - } catch (err) { - throw new IntentCoreError( - 'invalid-skill-use', - err instanceof Error ? err.message : String(err), - ) - } - - const fsCache = createIntentFsCache() - const projectContext = resolveProjectContext({ cwd }) - const excludePatterns = getEffectiveExcludePatterns(options, projectContext) - const excludeMatchers = compileExcludePatterns(excludePatterns) - const config = readSkillSourcesConfig(cwd, projectContext) - - const refusal = checkLoadAllowed(use, parsedUse, { config, excludeMatchers }) - if (refusal) { - throw new IntentCoreError(refusal.code, refusal.message) - } - - const scanOptions = toScanOptions(options) - const scope = getScanScope(scanOptions) - const fastPathResolved = resolveSkillUseFastPath( - parsedUse, - options, - projectContext, - cwd, - fsCache, - ) - if (fastPathResolved) { - return toResolvedIntentSkill( - cwd, - use, - fastPathResolved, - fsCache.getReadFs(), - options.debug - ? createLoadedSkillDebug({ - cwd, - excludes: excludePatterns, - resolution: 'fast-path', - resolved: fastPathResolved, - scan: fsCache.getStats(), - scope, - }) - : undefined, - ) - } - - const { scan: scanResult } = scanForPolicedIntents({ - cwd, - scanOptions: withFsCache(scanOptions, fsCache), - coreOptions: options, - context: projectContext, - }) - let resolved: ReturnType - try { - resolved = resolveSkillUse(use, scanResult) - } catch (err) { - if (err instanceof ResolveSkillUseError) { - throw new IntentCoreError(err.code, err.message, { - suggestedSkills: err.suggestedSkills, - }) - } - throw err - } - - return toResolvedIntentSkill( - cwd, - use, - resolved, - fsCache.getReadFs(), - options.debug - ? createLoadedSkillDebug({ - cwd, - excludes: excludePatterns, - resolution: 'full-scan', - resolved, - scan: scanResult.stats, - scope, - }) - : undefined, - ) -} - -export function resolveIntentSkill( - use: string, - options: IntentCoreOptions = {}, -): ResolvedIntentSkill { - return resolveIntentSkillInCwd(resolveCoreCwd(options), use, options).result -} - -export function loadIntentSkill( - use: string, - options: IntentCoreOptions = {}, -): LoadedIntentSkill { - const cwd = resolveCoreCwd(options) - const resolved = resolveIntentSkillInCwd(cwd, use, options) - const content = rewriteLoadedSkillMarkdownDestinations({ - content: resolved.readFs.readFileSync(resolved.realResolvedPath, 'utf8'), - cwd, - packageRoot: resolved.realPackageRoot, - skillFilePath: resolved.realResolvedPath, - }) - - return { - ...resolved.result, - content, - } -} +export * from './core/index.js' diff --git a/packages/intent/src/core/index.ts b/packages/intent/src/core/index.ts new file mode 100644 index 0000000..e30c346 --- /dev/null +++ b/packages/intent/src/core/index.ts @@ -0,0 +1 @@ +export * from './intent-core.js' diff --git a/packages/intent/src/core/intent-core.ts b/packages/intent/src/core/intent-core.ts new file mode 100644 index 0000000..3ccc42d --- /dev/null +++ b/packages/intent/src/core/intent-core.ts @@ -0,0 +1,378 @@ +import { isAbsolute, relative, resolve } from 'node:path' +import { + compileExcludePatterns, + getEffectiveExcludePatterns, +} from './excludes.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' +import { rewriteLoadedSkillMarkdownDestinations } from './markdown.js' +import { resolveSkillUseFastPath } from './load-resolution.js' +import { resolveProjectContext } from './project-context.js' +import { + checkLoadAllowed, + readSkillSourcesConfig, + scanForPolicedIntents, +} from './source-policy.js' +import { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' +import { formatSkillUse, parseSkillUse } from '../skills/use.js' +import type { ResolveSkillResult } from '../skills/resolver.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' +import type { ReadFs } from '../shared/utils.js' +import type { ScanOptions, ScanScope } from '../shared/types.js' +import type { + IntentCoreErrorCode, + IntentCoreOptions, + IntentSkillList, + IntentSkillSummary, + LoadedIntentSkill, + LoadedIntentSkillDebug, + ResolvedIntentSkill, +} from './types.js' + +export type { + IntentCoreErrorCode, + IntentCoreOptions, + IntentPackageSummary, + IntentSkillListDebug, + IntentSkillList, + IntentSkillSummary, + LoadedIntentSkillDebug, + LoadedIntentSkill, + ResolvedIntentSkill, +} from './types.js' + +export class IntentCoreError extends Error { + readonly code: IntentCoreErrorCode + readonly suggestedSkills?: Array + + constructor( + code: IntentCoreErrorCode, + message: string, + options: { suggestedSkills?: Array } = {}, + ) { + super(message) + this.name = 'IntentCoreError' + this.code = code + if (options.suggestedSkills) { + this.suggestedSkills = options.suggestedSkills + } + } +} + +function toScanOptions(options: IntentCoreOptions): ScanOptions { + if (options.global && options.globalOnly) { + throw new IntentCoreError( + 'invalid-options', + 'Use either global or globalOnly, not both.', + ) + } + + if (options.globalOnly) { + return { scope: 'global' } + } + + if (options.global) { + return { scope: 'local-and-global' } + } + + return { scope: 'local' } +} + +function getScanScope(options: ScanOptions): ScanScope { + return options.scope ?? (options.includeGlobal ? 'local-and-global' : 'local') +} + +function withFsCache( + options: ScanOptions, + fsCache: IntentFsCache, +): ScanOptions & { fsCache: IntentFsCache } { + return { ...options, fsCache } +} + +function resolveCoreCwd(options: IntentCoreOptions): string { + return resolve(process.cwd(), options.cwd ?? process.cwd()) +} + +export function listIntentSkills( + options: IntentCoreOptions = {}, +): IntentSkillList { + const cwd = resolveCoreCwd(options) + const scanOptions = toScanOptions(options) + const fsCache = createIntentFsCache() + const projectContext = resolveProjectContext({ cwd }) + const { scan, excludePatterns } = scanForPolicedIntents({ + cwd, + scanOptions: withFsCache(scanOptions, fsCache), + coreOptions: options, + context: projectContext, + }) + const packages = scan.packages + const skills = packages.flatMap((pkg) => + pkg.skills.map((skill): IntentSkillSummary => { + return { + use: formatSkillUse(pkg.name, skill.name), + packageName: pkg.name, + packageRoot: pkg.packageRoot, + packageVersion: pkg.version, + packageSource: pkg.source, + skillName: skill.name, + description: skill.description, + type: skill.type, + framework: skill.framework, + } + }), + ) + + const result: IntentSkillList = { + packageManager: scan.packageManager, + skills, + packages: packages.map((pkg) => ({ + name: pkg.name, + version: pkg.version, + source: pkg.source, + packageRoot: pkg.packageRoot, + skillCount: pkg.skills.length, + })), + warnings: scan.warnings, + notices: scan.notices, + conflicts: scan.conflicts, + } + + if (options.debug) { + result.debug = { + cwd, + scope: getScanScope(scanOptions), + excludes: excludePatterns, + packageCount: result.packages.length, + skillCount: result.skills.length, + warningCount: result.warnings.length, + noticeCount: result.notices.length, + conflictCount: result.conflicts.length, + scan: scan.stats, + } + } + + return result +} + +function resolveFromCwd(cwd: string, path: string): string { + return resolve(cwd, path) +} + +function isResolvedPathInsidePackageRoot( + path: string, + packageRoot: string, +): boolean { + const relativePath = relative(packageRoot, path) + return ( + relativePath === '' || + (!relativePath.startsWith('..') && !isAbsolute(relativePath)) + ) +} + +function toResolvedIntentSkill( + cwd: string, + use: string, + resolved: ResolveSkillResult, + readFs: ReadFs, + debug?: LoadedIntentSkillDebug, +): { + realPackageRoot: string + realResolvedPath: string + readFs: ReadFs + result: ResolvedIntentSkill +} { + let realResolvedPath: string + try { + realResolvedPath = readFs.realpathSync.native( + resolveFromCwd(cwd, resolved.path), + ) + } catch { + throw new IntentCoreError( + 'skill-file-not-found', + `Resolved skill file was not found: ${resolved.path}`, + ) + } + const realPackageRoot = readFs.realpathSync.native( + resolveFromCwd(cwd, resolved.packageRoot), + ) + + if (!isResolvedPathInsidePackageRoot(realResolvedPath, realPackageRoot)) { + throw new IntentCoreError( + 'skill-path-outside-package', + `Resolved skill path for "${use}" is outside package root: ${resolved.path}`, + ) + } + + const result: ResolvedIntentSkill = { + path: resolved.path, + packageRoot: resolved.packageRoot, + packageName: resolved.packageName, + skillName: resolved.skillName, + version: resolved.version, + source: resolved.source, + warnings: resolved.warnings, + conflict: resolved.conflict, + } + + if (debug) { + result.debug = debug + } + + return { + realPackageRoot, + realResolvedPath, + readFs, + result, + } +} + +function createLoadedSkillDebug({ + cwd, + excludes, + scan, + resolution, + resolved, + scope, +}: { + cwd: string + excludes: Array + scan: LoadedIntentSkillDebug['scan'] + resolution: LoadedIntentSkillDebug['resolution'] + resolved: ResolveSkillResult + scope: ScanScope +}): LoadedIntentSkillDebug { + return { + cwd, + scope, + resolution, + excludes, + packageName: resolved.packageName, + skillName: resolved.skillName, + version: resolved.version, + source: resolved.source, + path: resolved.path, + warningCount: resolved.warnings.length, + scan, + } +} + +function resolveIntentSkillInCwd( + cwd: string, + use: string, + options: IntentCoreOptions = {}, +): { + realPackageRoot: string + realResolvedPath: string + readFs: ReadFs + result: ResolvedIntentSkill +} { + let parsedUse: ReturnType + try { + parsedUse = parseSkillUse(use) + } catch (err) { + throw new IntentCoreError( + 'invalid-skill-use', + err instanceof Error ? err.message : String(err), + ) + } + + const fsCache = createIntentFsCache() + const projectContext = resolveProjectContext({ cwd }) + const excludePatterns = getEffectiveExcludePatterns(options, projectContext) + const excludeMatchers = compileExcludePatterns(excludePatterns) + const config = readSkillSourcesConfig(cwd, projectContext) + + const refusal = checkLoadAllowed(use, parsedUse, { config, excludeMatchers }) + if (refusal) { + throw new IntentCoreError(refusal.code, refusal.message) + } + + const scanOptions = toScanOptions(options) + const scope = getScanScope(scanOptions) + const fastPathResolved = resolveSkillUseFastPath( + parsedUse, + options, + projectContext, + cwd, + fsCache, + ) + if (fastPathResolved) { + return toResolvedIntentSkill( + cwd, + use, + fastPathResolved, + fsCache.getReadFs(), + options.debug + ? createLoadedSkillDebug({ + cwd, + excludes: excludePatterns, + resolution: 'fast-path', + resolved: fastPathResolved, + scan: fsCache.getStats(), + scope, + }) + : undefined, + ) + } + + const { scan: scanResult } = scanForPolicedIntents({ + cwd, + scanOptions: withFsCache(scanOptions, fsCache), + coreOptions: options, + context: projectContext, + }) + let resolved: ReturnType + try { + resolved = resolveSkillUse(use, scanResult) + } catch (err) { + if (err instanceof ResolveSkillUseError) { + throw new IntentCoreError(err.code, err.message, { + suggestedSkills: err.suggestedSkills, + }) + } + throw err + } + + return toResolvedIntentSkill( + cwd, + use, + resolved, + fsCache.getReadFs(), + options.debug + ? createLoadedSkillDebug({ + cwd, + excludes: excludePatterns, + resolution: 'full-scan', + resolved, + scan: scanResult.stats, + scope, + }) + : undefined, + ) +} + +export function resolveIntentSkill( + use: string, + options: IntentCoreOptions = {}, +): ResolvedIntentSkill { + return resolveIntentSkillInCwd(resolveCoreCwd(options), use, options).result +} + +export function loadIntentSkill( + use: string, + options: IntentCoreOptions = {}, +): LoadedIntentSkill { + const cwd = resolveCoreCwd(options) + const resolved = resolveIntentSkillInCwd(cwd, use, options) + const content = rewriteLoadedSkillMarkdownDestinations({ + content: resolved.readFs.readFileSync(resolved.realResolvedPath, 'utf8'), + cwd, + packageRoot: resolved.realPackageRoot, + skillFilePath: resolved.realResolvedPath, + }) + + return { + ...resolved.result, + content, + } +} diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index 97fda1c..a2a47bf 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -1,16 +1,16 @@ import { existsSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' -import { createIntentFsCache } from '../fs-cache.js' -import { resolveSkillEntry } from '../resolver.js' -import { scanIntentPackageAtRoot } from '../scanner.js' -import { findWorkspacePackages } from '../workspace-patterns.js' -import { getDeps, resolveDepDir } from '../utils.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' +import { resolveSkillEntry } from '../skills/resolver.js' +import { scanIntentPackageAtRoot } from '../discovery/scanner.js' +import { findWorkspacePackages } from '../setup/workspace-patterns.js' +import { getDeps, resolveDepDir } from '../shared/utils.js' import { warningMentionsPackage } from './excludes.js' import { resolveProjectContext } from './project-context.js' -import type { ResolveSkillResult } from '../resolver.js' -import type { IntentFsCache } from '../fs-cache.js' +import type { ResolveSkillResult } from '../skills/resolver.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' import type { ProjectContext } from './project-context.js' -import type { SkillUse } from '../skill-use.js' +import type { SkillUse } from '../skills/use.js' import type { IntentCoreOptions } from './types.js' interface WorkspacePackageInfo { diff --git a/packages/intent/src/core/markdown.ts b/packages/intent/src/core/markdown.ts index 5ebabb8..57a6ed4 100644 --- a/packages/intent/src/core/markdown.ts +++ b/packages/intent/src/core/markdown.ts @@ -1,5 +1,5 @@ import { dirname, isAbsolute, relative, resolve } from 'node:path' -import { toPosixPath } from '../utils.js' +import { toPosixPath } from '../shared/utils.js' function resolveFromCwd(path: string): string { return resolve(process.cwd(), path) diff --git a/packages/intent/src/core/project-context.ts b/packages/intent/src/core/project-context.ts index 8e5a703..2549587 100644 --- a/packages/intent/src/core/project-context.ts +++ b/packages/intent/src/core/project-context.ts @@ -3,7 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path' import { findWorkspaceRoot, readWorkspacePatterns, -} from '../workspace-patterns.js' +} from '../setup/workspace-patterns.js' export type ProjectContext = { cwd: string diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 3c440b8..c44c777 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -1,4 +1,4 @@ -import { scanForIntents } from '../scanner.js' +import { scanForIntents } from '../discovery/scanner.js' import { compileExcludePatterns, getConfigDirs, @@ -13,9 +13,9 @@ import { resolveProjectContext } from './project-context.js' import type { ExcludeMatcher } from './excludes.js' import type { ProjectContext } from './project-context.js' import type { SkillSourcesConfig } from './skill-sources.js' -import type { SkillUse } from '../skill-use.js' +import type { SkillUse } from '../skills/use.js' import type { IntentCoreOptions } from './types.js' -import type { IntentPackage, ScanOptions, ScanResult } from '../types.js' +import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js' export const ALLOW_ALL_NOTICE = 'All skill sources allowed (intent.skills: ["*"]) — unvetted skills may be surfaced into agent guidance.' diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index a3107cd..361a0f6 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -4,7 +4,7 @@ import type { ScanScope, ScanStats, VersionConflict, -} from '../types.js' +} from '../shared/types.js' export interface IntentCoreOptions { cwd?: string diff --git a/packages/intent/src/fs-cache.ts b/packages/intent/src/discovery/fs-cache.ts similarity index 97% rename from packages/intent/src/fs-cache.ts rename to packages/intent/src/discovery/fs-cache.ts index 35b0e34..a8e5df4 100644 --- a/packages/intent/src/fs-cache.ts +++ b/packages/intent/src/discovery/fs-cache.ts @@ -3,8 +3,8 @@ import { createFsIdentityCache, findSkillFiles as findSkillFilesUncached, nodeReadFs, -} from './utils.js' -import type { ReadFs } from './utils.js' +} from '../shared/utils.js' +import type { ReadFs } from '../shared/utils.js' type PackageJsonReadResult = { packageJson: Record | null diff --git a/packages/intent/src/package-manager.ts b/packages/intent/src/discovery/package-manager.ts similarity index 97% rename from packages/intent/src/package-manager.ts rename to packages/intent/src/discovery/package-manager.ts index 6d3a7e5..97482e8 100644 --- a/packages/intent/src/package-manager.ts +++ b/packages/intent/src/discovery/package-manager.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' -import type { PackageManager } from './types.js' +import type { PackageManager } from '../shared/types.js' function readPackageManagerField(dir: string): PackageManager | null { try { diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index b606830..90516a0 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -1,13 +1,13 @@ import { existsSync } from 'node:fs' import { join, sep } from 'node:path' -import { rewriteSkillLoadPaths } from '../skill-paths.js' -import { listNodeModulesPackageDirs } from '../utils.js' +import { rewriteSkillLoadPaths } from '../skills/paths.js' +import { listNodeModulesPackageDirs } from '../shared/utils.js' import type { IntentConfig, IntentPackage, NodeModulesScanTarget, SkillEntry, -} from '../types.js' +} from '../shared/types.js' type PackageJson = Record diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/discovery/scanner.ts similarity index 99% rename from packages/intent/src/scanner.ts rename to packages/intent/src/discovery/scanner.ts index 22ed833..c7b1b3f 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -6,25 +6,22 @@ import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import semver from 'semver' -import { - createDependencyWalker, - createPackageRegistrar, -} from './discovery/index.js' +import { createDependencyWalker, createPackageRegistrar } from './index.js' import { detectGlobalNodeModules, nodeReadFs, parseFrontmatter, readScalarField, toPosixPath, -} from './utils.js' +} from '../shared/utils.js' import { createIntentFsCache } from './fs-cache.js' import { detectPackageManager } from './package-manager.js' import { findWorkspacePackages, findWorkspaceRoot, -} from './workspace-patterns.js' +} from '../setup/workspace-patterns.js' import type { IntentFsCache } from './fs-cache.js' -import type { ReadFs } from './utils.js' +import type { ReadFs } from '../shared/utils.js' import type { InstalledVariant, IntentConfig, @@ -34,7 +31,7 @@ import type { ScanScope, SkillEntry, VersionConflict, -} from './types.js' +} from '../shared/types.js' type ScanOptionsWithFsCache = ScanOptions & { fsCache?: IntentFsCache diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts index 6709af0..ac233cf 100644 --- a/packages/intent/src/discovery/walk.ts +++ b/packages/intent/src/discovery/walk.ts @@ -3,10 +3,10 @@ import { getDeps, listNestedNodeModulesPackageDirs, resolveDepDir, -} from '../utils.js' -import { findWorkspacePackages } from '../workspace-patterns.js' -import type { IntentFsCache } from '../fs-cache.js' -import type { IntentPackage } from '../types.js' +} from '../shared/utils.js' +import { findWorkspacePackages } from '../setup/workspace-patterns.js' +import type { IntentFsCache } from './fs-cache.js' +import type { IntentPackage } from '../shared/types.js' type PackageJson = Record diff --git a/packages/intent/src/hooks/adapters.ts b/packages/intent/src/hooks/adapters.ts new file mode 100644 index 0000000..472b36e --- /dev/null +++ b/packages/intent/src/hooks/adapters.ts @@ -0,0 +1,93 @@ +import { join } from 'node:path' +import type { HookAgent, HookInstallScope } from './types.js' + +type HookAdapterPaths = { + configPath: string + scriptPath: string +} + +type HookAdapterContext = { + copilotHome?: string + homeDir: string + root: string +} + +export type HookAgentAdapter = { + agent: HookAgent + configKind: 'claude-settings' | 'codex-hooks' | 'copilot-hooks' + supportedScopes: ReadonlySet + paths: ( + scope: HookInstallScope, + context: HookAdapterContext, + ) => HookAdapterPaths +} + +const HOOK_SCRIPT_DIR = '.intent/hooks' + +export const HOOK_AGENT_ADAPTERS: Record = { + claude: { + agent: 'claude', + configKind: 'claude-settings', + supportedScopes: new Set(['project', 'user']), + paths: (scope, { homeDir, root }) => { + const project = scope === 'project' + return { + configPath: project + ? join(root, '.claude', 'settings.json') + : join(homeDir, '.claude', 'settings.json'), + scriptPath: project + ? join(root, HOOK_SCRIPT_DIR, 'intent-claude-gate.mjs') + : join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-claude-gate.mjs', + ), + } + }, + }, + codex: { + agent: 'codex', + configKind: 'codex-hooks', + supportedScopes: new Set(['project', 'user']), + paths: (scope, { homeDir, root }) => { + const project = scope === 'project' + return { + configPath: project + ? join(root, '.codex', 'hooks.json') + : join(homeDir, '.codex', 'hooks.json'), + scriptPath: project + ? join(root, HOOK_SCRIPT_DIR, 'intent-codex-gate.mjs') + : join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-codex-gate.mjs', + ), + } + }, + }, + copilot: { + agent: 'copilot', + configKind: 'copilot-hooks', + supportedScopes: new Set(['user']), + paths: (_scope, { copilotHome, homeDir }) => ({ + configPath: join( + copilotHome ?? join(homeDir, '.copilot'), + 'hooks', + 'hooks.json', + ), + scriptPath: join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-copilot-gate.mjs', + ), + }), + }, +} + +export const ALL_HOOK_AGENTS: Array = ['copilot', 'claude', 'codex'] diff --git a/packages/intent/src/hooks/agents/claude.ts b/packages/intent/src/hooks/agents/claude.ts new file mode 100644 index 0000000..44bb71f --- /dev/null +++ b/packages/intent/src/hooks/agents/claude.ts @@ -0,0 +1,25 @@ +import type { HookDecision } from '../types.js' + +export type ClaudeHookOutput = { + hookSpecificOutput: { + hookEventName: 'PreToolUse' + permissionDecision: 'deny' + permissionDecisionReason: string + } +} + +export function formatClaudePreToolUseOutput( + decision: HookDecision, +): ClaudeHookOutput | undefined { + if (decision.decision === 'allow') { + return undefined + } + + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: decision.reason, + }, + } +} diff --git a/packages/intent/src/hooks/agents/codex.ts b/packages/intent/src/hooks/agents/codex.ts new file mode 100644 index 0000000..ccc13e8 --- /dev/null +++ b/packages/intent/src/hooks/agents/codex.ts @@ -0,0 +1,25 @@ +import type { HookDecision } from '../types.js' + +export type CodexHookOutput = { + hookSpecificOutput: { + hookEventName: 'PreToolUse' + permissionDecision: 'deny' + permissionDecisionReason: string + } +} + +export function formatCodexPreToolUseOutput( + decision: HookDecision, +): CodexHookOutput | undefined { + if (decision.decision === 'allow') { + return undefined + } + + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: decision.reason, + }, + } +} diff --git a/packages/intent/src/hooks/agents/copilot.ts b/packages/intent/src/hooks/agents/copilot.ts new file mode 100644 index 0000000..c223107 --- /dev/null +++ b/packages/intent/src/hooks/agents/copilot.ts @@ -0,0 +1,19 @@ +import type { HookDecision } from '../types.js' + +export type CopilotHookOutput = { + permissionDecision: 'deny' + permissionDecisionReason: string +} + +export function formatCopilotPreToolUseOutput( + decision: HookDecision, +): CopilotHookOutput | undefined { + if (decision.decision === 'allow') { + return undefined + } + + return { + permissionDecision: 'deny', + permissionDecisionReason: decision.reason, + } +} diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts new file mode 100644 index 0000000..0739f35 --- /dev/null +++ b/packages/intent/src/hooks/install.ts @@ -0,0 +1,477 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, relative } from 'node:path' +import { fail } from '../shared/cli-error.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' + +type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' + +export type HookInstallResult = { + agent: HookAgent + configPath: string | null + scope: HookInstallScope + scriptPath: string | null + status: HookInstallStatus + reason?: string +} + +export type InstallHooksOptions = { + agents?: string + copilotHome?: string + homeDir?: string + root: string + scope?: string +} + +const STATUS_MESSAGE = 'Checking Intent guidance' + +export function runInstallHooks({ + agents, + copilotHome, + homeDir = homedir(), + root, + scope, +}: InstallHooksOptions): Array { + const resolvedScope = parseScope(scope) + const resolvedAgents = parseAgents(agents) + + return resolvedAgents.map((agent) => + installAgentHook({ + agent, + copilotHome, + homeDir, + root, + scope: resolvedScope, + }), + ) +} + +export function validateHookInstallOptions({ + agents, + scope, +}: Pick): void { + parseScope(scope) + parseAgents(agents) +} + +export function buildHookRunnerScript(agent: HookAgent): string { + const editTools = [...EDIT_TOOLS_BY_AGENT[agent]].sort() + + return `#!/usr/bin/env node +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createHash } from 'node:crypto' + +const AGENT = ${JSON.stringify(agent)} +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 { + const event = readEventFromStdin() + const stateFile = stateFileForEvent(event) + const observation = observationFromEvent(event) + + if (observation) { + appendObservation(stateFile, observation) + } + + const toolName = event?.tool_name ?? event?.toolName + 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')) + } catch { + return {} + } +} + +function stateFileForEvent(event) { + const sessionId = typeof event?.session_id === 'string' ? event.session_id : 'unknown' + const cwd = typeof event?.cwd === 'string' ? event.cwd : process.cwd() + const key = createHash('sha256').update(AGENT + '\\0' + cwd + '\\0' + sessionId).digest('hex') + return join(tmpdir(), 'tanstack-intent-hooks', key + '.jsonl') +} + +function observationFromEvent(event) { + if (!event || typeof event !== 'object') return undefined + const toolName = event.tool_name ?? event.toolName + const toolInput = event.tool_input ?? event.toolArgs + if (toolName !== 'Bash') return undefined + const command = typeof toolInput === 'string' ? safeCommandFromString(toolInput) : commandFromObject(toolInput) + const parsed = parseIntentInvocation(command) + if (!parsed || typeof command !== 'string') return undefined + 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 +} + +function safeCommandFromString(value) { + try { + const command = commandFromObject(JSON.parse(value)) + return typeof command === 'string' ? command : value + } catch { + return value + } +} + +function appendObservation(stateFile, observation) { + try { + mkdirSync(dirname(stateFile), { recursive: true }) + appendFileSync(stateFile, JSON.stringify({ ts: new Date().toISOString(), ...observation }) + '\\n') + } catch { + } +} + +function hasLoad(stateFile) { + if (!existsSync(stateFile)) return false + try { + return readFileSync(stateFile, 'utf8') + .split('\\n') + .filter(Boolean) + .some((line) => { + try { + return JSON.parse(line).action === 'load' + } catch { + return false + } + }) + } catch { + return false + } +} + +function denyOutput() { + if (AGENT === 'copilot') { + return { permissionDecision: 'deny', permissionDecisionReason: GATE_DENY_REASON } + } + + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: GATE_DENY_REASON, + }, + } +} +` +} + +export function formatHookInstallResult(result: HookInstallResult): string { + if (result.status === 'skipped') { + return `Skipped Intent hooks for ${result.agent}: ${result.reason}` + } + + const target = result.configPath + ? formatPath(result.configPath) + : result.agent + switch (result.status) { + case 'created': + return `Installed Intent hooks for ${result.agent} (${result.scope}) in ${target}.` + case 'updated': + return `Updated Intent hooks for ${result.agent} (${result.scope}) in ${target}.` + case 'unchanged': + return `No changes to Intent hooks for ${result.agent} (${result.scope}); already current.` + } +} + +function installAgentHook({ + agent, + copilotHome, + homeDir, + root, + scope, +}: { + agent: HookAgent + copilotHome?: string + homeDir: string + root: string + scope: HookInstallScope +}): HookInstallResult { + const adapter = HOOK_AGENT_ADAPTERS[agent] + + if (!adapter.supportedScopes.has(scope)) { + return { + agent, + configPath: null, + reason: 'project scope is not supported; use --scope user', + scope, + scriptPath: null, + status: 'skipped', + } + } + + const { configPath, scriptPath } = adapter.paths(scope, { + copilotHome: copilotHome ?? process.env.COPILOT_HOME, + homeDir, + root, + }) + const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent)) + const configStatus = updateJsonConfig(configPath, (config) => + upsertAdapterPreToolUseHook({ + config, + configKind: adapter.configKind, + project: scope === 'project', + scriptPath, + }), + ) + + return hookInstallResult({ + agent, + configPath, + scope, + scriptPath, + scriptStatus, + configStatus, + }) +} + +function hookInstallResult({ + agent, + configPath, + configStatus, + scope, + scriptPath, + scriptStatus, +}: { + agent: HookAgent + configPath: string + configStatus: HookInstallStatus + scope: HookInstallScope + scriptPath: string + scriptStatus: HookInstallStatus +}): HookInstallResult { + return { + agent, + configPath, + scope, + scriptPath, + status: + scriptStatus === 'created' || configStatus === 'created' + ? 'created' + : scriptStatus === 'updated' || configStatus === 'updated' + ? 'updated' + : 'unchanged', + } +} + +function upsertAdapterPreToolUseHook({ + config, + configKind, + project, + scriptPath, +}: { + config: Record + configKind: (typeof HOOK_AGENT_ADAPTERS)[HookAgent]['configKind'] + project: boolean + scriptPath: string +}): Record { + switch (configKind) { + case 'claude-settings': + return upsertClaudePreToolUseHook(config, project, scriptPath) + case 'codex-hooks': + return upsertCodexPreToolUseHook(config, project, scriptPath) + case 'copilot-hooks': + return upsertCopilotPreToolUseHook(config, scriptPath) + } +} + +function upsertClaudePreToolUseHook( + config: Record, + project: boolean, + scriptPath: string, +): Record { + const hooks = objectValue(config.hooks) + hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { + matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit', + hooks: [ + { + type: 'command', + command: 'node', + args: [ + project + ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs' + : scriptPath, + ], + timeout: 10, + statusMessage: STATUS_MESSAGE, + }, + ], + }) + return { ...config, hooks } +} + +function upsertCodexPreToolUseHook( + config: Record, + project: boolean, + scriptPath: string, +): Record { + const hooks = objectValue(config.hooks) + hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { + matcher: 'Bash|apply_patch|Edit|Write', + hooks: [ + { + type: 'command', + command: project + ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' + : `node ${quoteShell(scriptPath)}`, + timeout: 10, + statusMessage: STATUS_MESSAGE, + }, + ], + }) + return { ...config, hooks } +} + +function upsertCopilotPreToolUseHook( + config: Record, + scriptPath: string, +): Record { + const hooks = objectValue(config.hooks) + hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { + command: `node ${quoteShell(scriptPath)}`, + }) + return { ...config, hooks } +} + +function upsertHookGroup( + groups: Array, + nextGroup: Record, +): Array { + return [...groups.flatMap(withoutIntentHooks), nextGroup] +} + +function withoutIntentHooks(value: unknown): Array { + if (!value || typeof value !== 'object') return [value] + + const hooks = arrayValue((value as { hooks?: unknown }).hooks) + if (hooks.length === 0) return isIntentHook(value) ? [] : [value] + + const nextHooks = hooks.filter((hook) => !isIntentHook(hook)) + if (nextHooks.length === hooks.length) return [value] + if (nextHooks.length === 0) return [] + + return [{ ...(value as Record), hooks: nextHooks }] +} + +function isIntentHook(value: unknown): boolean { + if (!value || typeof value !== 'object') return false + const serialized = JSON.stringify(value) + return serialized.includes('intent-') && serialized.includes('-gate.mjs') +} + +function updateJsonConfig( + filePath: string, + update: (config: Record) => Record, +): HookInstallStatus { + const existed = existsSync(filePath) + const current = existed ? readFileSync(filePath, 'utf8') : '' + const parsed = current.trim() ? parseJsonObject(filePath, current) : {} + const next = `${JSON.stringify(update(parsed), null, 2)}\n` + + if (current === next) { + return 'unchanged' + } + + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, next) + return existed ? 'updated' : 'created' +} + +function writeIfChanged(filePath: string, content: string): HookInstallStatus { + const existed = existsSync(filePath) + if (existed && readFileSync(filePath, 'utf8') === content) { + return 'unchanged' + } + + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, content) + return existed ? 'updated' : 'created' +} + +function parseAgents(value: string | undefined): Array { + if (!value || value === 'all') { + return ALL_HOOK_AGENTS + } + + const agents = value + .split(',') + .map((agent) => agent.trim()) + .filter(Boolean) + const invalid = agents.filter( + (agent) => !ALL_HOOK_AGENTS.includes(agent as HookAgent), + ) + + if (invalid.length > 0) { + fail( + `Unknown hook agent: ${invalid.join(', ')}. Expected copilot, claude, codex, or all.`, + ) + } + + return [...new Set(agents as Array)] +} + +function parseScope(value: string | undefined): HookInstallScope { + if (!value) return 'project' + if (value === 'project' || value === 'user') return value + fail(`Unknown hook scope: ${value}. Expected project or user.`) +} + +function parseJsonObject( + filePath: string, + content: string, +): Record { + try { + const parsed = JSON.parse(content) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch (err) { + fail( + `Failed to parse ${formatPath(filePath)}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + fail(`Failed to parse ${formatPath(filePath)}: expected a JSON object.`) +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? { ...(value as Record) } + : {} +} + +function arrayValue(value: unknown): Array { + return Array.isArray(value) ? value : [] +} + +function quoteShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function formatPath(filePath: string): string { + return relative(process.cwd(), filePath) || filePath +} diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts new file mode 100644 index 0000000..df9eb94 --- /dev/null +++ b/packages/intent/src/hooks/policy.ts @@ -0,0 +1,113 @@ +import type { + HookAgent, + HookDecision, + IntentInvocation, + IntentObservation, + 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']), + copilot: new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']), +} + +export const GATE_DENY_REASON = + "Blocked: load matching TanStack guidance before editing. Follow this repo's TanStack guidance setup, then retry the edit." + +export function parseIntentInvocation( + command: unknown, +): IntentInvocation | undefined { + 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 } +} + +export function observationFromEvent( + event: ToolEvent | undefined, +): IntentObservation | undefined { + if (!event || typeof event !== 'object') { + return undefined + } + + const toolName = event.tool_name ?? event.toolName + const toolInput = event.tool_input ?? event.toolArgs + + if (toolName !== 'Bash') { + return undefined + } + + const command = + typeof toolInput === 'string' + ? safeCommandFromString(toolInput) + : commandFromObject(toolInput) + + const parsed = parseIntentInvocation(command) + + if (!parsed || typeof command !== 'string') { + return undefined + } + + return { action: parsed.action, skillUse: parsed.skillUse, raw: command } +} + +export function gateDecision({ + agent, + hasLoaded, + toolName, +}: { + agent: HookAgent + hasLoaded: boolean + toolName: string +}): HookDecision { + if (EDIT_TOOLS_BY_AGENT[agent].has(toolName) && !hasLoaded) { + return { decision: 'deny', reason: GATE_DENY_REASON } + } + + return { decision: 'allow' } +} + +export function hasLoadFromObservations( + observations: Array | undefined>, +): boolean { + return observations.some((entry) => entry?.action === 'load') +} + +function commandFromObject(value: unknown): unknown { + return value && typeof value === 'object' + ? (value as { command?: unknown }).command + : undefined +} + +function safeCommandFromString(value: string): string { + try { + const parsed = JSON.parse(value) as unknown + const command = commandFromObject(parsed) + return typeof command === 'string' ? command : value + } catch { + return value + } +} diff --git a/packages/intent/src/hooks/types.ts b/packages/intent/src/hooks/types.ts new file mode 100644 index 0000000..db5602c --- /dev/null +++ b/packages/intent/src/hooks/types.ts @@ -0,0 +1,23 @@ +export type HookAgent = 'claude' | 'codex' | 'copilot' + +export type HookInstallScope = 'project' | 'user' + +export type IntentInvocation = { + action: 'list' | 'load' + skillUse?: string +} + +export type IntentObservation = IntentInvocation & { + raw: string +} + +export type HookDecision = + | { decision: 'allow' } + | { decision: 'deny'; reason: string } + +export type ToolEvent = { + tool_name?: unknown + toolName?: unknown + tool_input?: unknown + toolArgs?: unknown +} diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index 75b49bd..dc71567 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -1,18 +1,18 @@ -export { scanForIntents } from './scanner.js' -export { checkStaleness } from './staleness.js' -export { readIntentArtifacts } from './artifact-coverage.js' +export { scanForIntents } from './discovery/scanner.js' +export { checkStaleness } from './staleness/index.js' +export { readIntentArtifacts } from './staleness/artifact-coverage.js' export { buildStaleReviewBody, collectStaleReviewItems, createFailedStaleReviewItem, type StaleReviewItem, -} from './workflow-review.js' +} from './staleness/workflow-review.js' export { findSkillFiles, getDeps, parseFrontmatter, resolveDepDir, -} from './utils.js' +} from './shared/utils.js' export { formatSkillUse, isSkillUseParseError, @@ -20,19 +20,19 @@ export { SkillUseParseError, type SkillUse, type SkillUseParseErrorCode, -} from './skill-use.js' +} from './skills/use.js' export { isResolveSkillUseError, resolveSkillUse, ResolveSkillUseError, type ResolveSkillResult, type ResolveSkillUseErrorCode, -} from './resolver.js' -export { runEditPackageJson, runSetupGithubActions } from './setup.js' +} from './skills/resolver.js' +export { runEditPackageJson, runSetupGithubActions } from './setup/index.js' export type { EditPackageJsonResult, SetupGithubActionsResult, -} from './setup.js' +} from './setup/index.js' export type { IntentConfig, IntentArtifactCoverageIgnore, @@ -47,4 +47,4 @@ export type { StalenessReport, SkillStaleness, StalenessSignal, -} from './types.js' +} from './shared/types.js' diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index 4f922e2..7f77e28 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -1,424 +1 @@ -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - writeFileSync, -} from 'node:fs' -import { basename, join, relative } from 'node:path' -import { - findPackagesWithSkills, - findWorkspaceRoot, - readWorkspacePatterns, -} from './workspace-patterns.js' -import { resolveProjectContext } from './core/project-context.js' - -export { - findPackagesWithSkills, - findWorkspaceRoot, - readWorkspacePatterns, - resolveWorkspacePackages, -} from './workspace-patterns.js' - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface EditPackageJsonResult { - added: Array - alreadyPresent: Array -} - -export interface SetupGithubActionsResult { - workflows: Array - skipped: Array -} - -export interface MonorepoResult { - package: string - result: T -} - -interface TemplateVars { - PACKAGE_NAME: string - PACKAGE_LABEL: string - PAYLOAD_PACKAGE: string - REPO: string - DOCS_PATH: string - SRC_PATH: string - WATCH_PATHS: string -} - -function isGenericWorkspaceName(name: string, root: string): boolean { - const normalized = name.trim().toLowerCase() - return ( - normalized.length === 0 || - normalized === 'unknown' || - normalized === 'root' || - normalized === 'workspace' || - normalized === 'monorepo' || - normalized === basename(root).toLowerCase() - ) -} - -function deriveWorkspacePackageName( - root: string, - repo: string, - packageDirs: Array, -): string { - const repoName = repo.split('/').filter(Boolean).pop() || basename(root) - - for (const packageDir of packageDirs) { - const pkgJson = readPackageJson(packageDir) - const pkgName = typeof pkgJson.name === 'string' ? pkgJson.name : null - if (pkgName?.startsWith('@')) { - const scope = pkgName.split('/')[0] - return `${scope}/${repoName}` - } - } - - return repoName -} - -// --------------------------------------------------------------------------- -// Variable detection from package.json -// --------------------------------------------------------------------------- - -function readPackageJson(root: string): Record { - const pkgPath = join(root, 'package.json') - try { - return JSON.parse(readFileSync(pkgPath, 'utf8')) as Record - } catch (err: unknown) { - const isNotFound = - err && - typeof err === 'object' && - 'code' in err && - (err as NodeJS.ErrnoException).code === 'ENOENT' - if (!isNotFound) { - console.error( - `Warning: could not read ${pkgPath}: ${err instanceof Error ? err.message : err}`, - ) - } - return {} - } -} - -function detectRepo( - pkgJson: Record, - fallback: string, -): string { - const intent = pkgJson.intent as Record | undefined - if (typeof intent?.repo === 'string') { - return intent.repo - } - - if (typeof pkgJson.repository === 'string') { - return pkgJson.repository - .replace(/^git\+/, '') - .replace(/\.git$/, '') - .replace(/^https?:\/\/github\.com\//, '') - } - - if ( - pkgJson.repository && - typeof pkgJson.repository === 'object' && - typeof (pkgJson.repository as Record).url === 'string' - ) { - return ((pkgJson.repository as Record).url as string) - .replace(/^git\+/, '') - .replace(/\.git$/, '') - .replace(/^https?:\/\/github\.com\//, '') - } - - return fallback -} - -function normalizePattern(pattern: string): string { - return pattern.endsWith('**') ? pattern : pattern.replace(/\/$/, '') + '/**' -} - -function buildWatchPaths(root: string, packageDirs: Array): string { - const paths = new Set() - - if (existsSync(join(root, 'docs'))) { - paths.add('docs/**') - } - - for (const packageDir of packageDirs) { - const relDir = relative(root, packageDir).split('\\').join('/') - if (existsSync(join(packageDir, 'src'))) { - paths.add(`${relDir}/src/**`) - } - - const pkgJson = readPackageJson(packageDir) - const intent = pkgJson.intent as Record | undefined - const docs = typeof intent?.docs === 'string' ? intent.docs : 'docs/' - if (!docs.startsWith('http://') && !docs.startsWith('https://')) { - paths.add(normalizePattern(join(relDir, docs).split('\\').join('/'))) - } - } - - if (paths.size === 0) { - paths.add('packages/*/src/**') - paths.add('packages/*/docs/**') - } - - return [...paths] - .sort() - .map((path) => ` - '${path}'`) - .join('\n') -} - -function detectVars(root: string, packageDirs?: Array): TemplateVars { - const pkgJson = readPackageJson(root) - const rawName = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' - const docs = - typeof (pkgJson.intent as Record | undefined)?.docs === - 'string' - ? ((pkgJson.intent as Record).docs as string) - : 'docs/' - const isMonorepo = packageDirs !== undefined - const monorepoFallbackPkg = packageDirs?.[0] - ? readPackageJson(packageDirs[0]) - : null - const repo = detectRepo( - pkgJson, - detectRepo(monorepoFallbackPkg ?? {}, basename(root)), - ) - - let packageName = rawName - if (isMonorepo && isGenericWorkspaceName(rawName, root)) { - packageName = deriveWorkspacePackageName(root, repo, packageDirs) - } - - // Derive srcPath: monorepos use a wildcard; single packages use the short name or fall back to root src/ - const shortName = packageName.replace(/^@[^/]+\//, '') - let srcPath = isMonorepo - ? 'packages/*/src/**' - : `packages/${shortName}/src/**` - if (!isMonorepo && existsSync(join(root, 'src'))) { - srcPath = 'src/**' - } - - const docsPath = isMonorepo ? 'packages/*/docs/**' : docs - - return { - PACKAGE_NAME: packageName, - PACKAGE_LABEL: packageName, - PAYLOAD_PACKAGE: packageName, - REPO: repo, - DOCS_PATH: docsPath.endsWith('**') - ? docsPath - : docsPath.replace(/\/$/, '') + '/**', - SRC_PATH: srcPath, - WATCH_PATHS: isMonorepo - ? buildWatchPaths(root, packageDirs) - : ` - '${docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**'}'\n - '${srcPath}'`, - } -} - -// --------------------------------------------------------------------------- -// Template variable substitution -// --------------------------------------------------------------------------- - -function applyVars(content: string, vars: TemplateVars): string { - return content - .replace(/\{\{PACKAGE_NAME\}\}/g, vars.PACKAGE_NAME) - .replace(/\{\{PACKAGE_LABEL\}\}/g, vars.PACKAGE_LABEL) - .replace(/\{\{PAYLOAD_PACKAGE\}\}/g, vars.PAYLOAD_PACKAGE) - .replace(/\{\{REPO\}\}/g, vars.REPO) - .replace(/\{\{DOCS_PATH\}\}/g, vars.DOCS_PATH) - .replace(/\{\{SRC_PATH\}\}/g, vars.SRC_PATH) - .replace(/\{\{WATCH_PATHS\}\}/g, vars.WATCH_PATHS) -} - -// --------------------------------------------------------------------------- -// Copy helpers -// --------------------------------------------------------------------------- - -function copyTemplates( - srcDir: string, - destDir: string, - vars: TemplateVars, -): { copied: Array; skipped: Array } { - const copied: Array = [] - const skipped: Array = [] - - if (!existsSync(srcDir)) return { copied, skipped } - - mkdirSync(destDir, { recursive: true }) - - for (const entry of readdirSync(srcDir)) { - const srcPath = join(srcDir, entry) - const destPath = join(destDir, entry) - - if (existsSync(destPath)) { - skipped.push(destPath) - continue - } - - let content = readFileSync(srcPath, 'utf8') - if (vars.WATCH_PATHS.includes('\n')) { - content = content.replace( - /\s+- '?\{\{DOCS_PATH\}\}'?\n\s+- '?\{\{SRC_PATH\}\}'?/, - vars.WATCH_PATHS, - ) - } - const substituted = applyVars(content, vars) - writeFileSync(destPath, substituted) - copied.push(destPath) - } - - return { copied, skipped } -} - -// --------------------------------------------------------------------------- -// Command: edit-package-json -// --------------------------------------------------------------------------- - -export function runEditPackageJson(root: string): EditPackageJsonResult { - const result: EditPackageJsonResult = { added: [], alreadyPresent: [] } - const context = resolveProjectContext({ cwd: root }) - const packageRoot = context.packageRoot ?? root - const pkgPath = join(packageRoot, 'package.json') - - if (!existsSync(pkgPath)) { - console.error('No package.json found in ' + packageRoot) - process.exitCode = 1 - return result - } - - const raw = readFileSync(pkgPath, 'utf8') - let pkg: Record - try { - pkg = JSON.parse(raw) as Record - } catch (err) { - const detail = err instanceof SyntaxError ? err.message : String(err) - console.error(`Failed to parse ${pkgPath}: ${detail}`) - process.exitCode = 1 - return result - } - - // Detect indent size from existing file - const indentMatch = raw.match(/^(\s+)"/m) - const indentSize = indentMatch?.[1] ? indentMatch[1].length : 2 - - // --- keywords array --- - if (!Array.isArray(pkg.keywords)) { - pkg.keywords = [] - } - const keywords = pkg.keywords as Array - if (keywords.includes('tanstack-intent')) { - result.alreadyPresent.push('keywords: "tanstack-intent"') - } else { - keywords.push('tanstack-intent') - result.added.push('keywords: "tanstack-intent"') - } - - // --- files array --- - if (!Array.isArray(pkg.files)) { - pkg.files = [] - } - const files = pkg.files as Array - - // In monorepos, _artifacts lives at repo root, not under packages — - // the negation pattern is a no-op and shouldn't be added. - const requiredFiles = context.isMonorepo - ? ['skills'] - : ['skills', '!skills/_artifacts'] - - for (const entry of requiredFiles) { - if (files.includes(entry)) { - result.alreadyPresent.push(`files: "${entry}"`) - } else { - files.push(entry) - result.added.push(`files: "${entry}"`) - } - } - - writeFileSync(pkgPath, JSON.stringify(pkg, null, indentSize) + '\n') - - // Print results - for (const a of result.added) console.log(`✓ Added ${a}`) - for (const a of result.alreadyPresent) console.log(` Already present: ${a}`) - - return result -} - -// --------------------------------------------------------------------------- -// Monorepo-aware command runner -// --------------------------------------------------------------------------- - -/** - * When run from a monorepo root, finds all workspace packages with SKILL.md - * files and runs the given command on each. Falls back to single-package - * behavior only when no workspace config is detected. If workspace config - * exists but no packages have skills, warns and returns empty. - */ -function runForEachPackage( - root: string, - runOne: (dir: string) => T, -): Array> | T { - const isMonorepo = readWorkspacePatterns(root) !== null - const pkgsWithSkills = isMonorepo ? findPackagesWithSkills(root) : [] - - if (!isMonorepo) { - return runOne(root) - } - - if (pkgsWithSkills.length === 0) { - console.log('No workspace packages with skills found.') - return [] - } - - return pkgsWithSkills.map((pkgDir) => { - const rel = relative(root, pkgDir) || '.' - console.log(`\n── ${rel} ──`) - return { package: rel, result: runOne(pkgDir) } - }) -} - -export function runEditPackageJsonAll( - root: string, -): Array> | EditPackageJsonResult { - return runForEachPackage(root, runEditPackageJson) -} - -// --------------------------------------------------------------------------- -// Command: setup-github-actions -// --------------------------------------------------------------------------- - -export function runSetupGithubActions( - root: string, - metaDir: string, -): SetupGithubActionsResult { - const workspaceRoot = findWorkspaceRoot(root) ?? root - const packageDirs = findPackagesWithSkills(workspaceRoot) - const vars = detectVars( - workspaceRoot, - packageDirs.length > 0 ? packageDirs : undefined, - ) - const result: SetupGithubActionsResult = { workflows: [], skipped: [] } - - const srcDir = join(metaDir, 'templates', 'workflows') - const destDir = join(workspaceRoot, '.github', 'workflows') - const { copied, skipped } = copyTemplates(srcDir, destDir, vars) - result.workflows = copied - result.skipped = skipped - - for (const f of result.workflows) console.log(`✓ Copied workflow: ${f}`) - for (const f of result.skipped) console.log(` Already exists: ${f}`) - - if (result.workflows.length === 0 && result.skipped.length === 0) { - console.log('No templates directory found. Is @tanstack/intent installed?') - } else if (result.workflows.length > 0) { - console.log(`\nTemplate variables applied:`) - console.log(` Package: ${vars.PACKAGE_LABEL}`) - console.log(` Repo: ${vars.REPO}`) - console.log( - ` Mode: ${packageDirs.length > 0 ? `monorepo (${packageDirs.length} packages with skills)` : 'single package'}`, - ) - } - - return result -} +export * from './setup/index.js' diff --git a/packages/intent/src/setup/index.ts b/packages/intent/src/setup/index.ts new file mode 100644 index 0000000..450f928 --- /dev/null +++ b/packages/intent/src/setup/index.ts @@ -0,0 +1 @@ +export * from './project-setup.js' diff --git a/packages/intent/src/setup/project-setup.ts b/packages/intent/src/setup/project-setup.ts new file mode 100644 index 0000000..93718e8 --- /dev/null +++ b/packages/intent/src/setup/project-setup.ts @@ -0,0 +1,424 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'node:fs' +import { basename, join, relative } from 'node:path' +import { + findPackagesWithSkills, + findWorkspaceRoot, + readWorkspacePatterns, +} from './workspace-patterns.js' +import { resolveProjectContext } from '../core/project-context.js' + +export { + findPackagesWithSkills, + findWorkspaceRoot, + readWorkspacePatterns, + resolveWorkspacePackages, +} from './workspace-patterns.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface EditPackageJsonResult { + added: Array + alreadyPresent: Array +} + +export interface SetupGithubActionsResult { + workflows: Array + skipped: Array +} + +export interface MonorepoResult { + package: string + result: T +} + +interface TemplateVars { + PACKAGE_NAME: string + PACKAGE_LABEL: string + PAYLOAD_PACKAGE: string + REPO: string + DOCS_PATH: string + SRC_PATH: string + WATCH_PATHS: string +} + +function isGenericWorkspaceName(name: string, root: string): boolean { + const normalized = name.trim().toLowerCase() + return ( + normalized.length === 0 || + normalized === 'unknown' || + normalized === 'root' || + normalized === 'workspace' || + normalized === 'monorepo' || + normalized === basename(root).toLowerCase() + ) +} + +function deriveWorkspacePackageName( + root: string, + repo: string, + packageDirs: Array, +): string { + const repoName = repo.split('/').filter(Boolean).pop() || basename(root) + + for (const packageDir of packageDirs) { + const pkgJson = readPackageJson(packageDir) + const pkgName = typeof pkgJson.name === 'string' ? pkgJson.name : null + if (pkgName?.startsWith('@')) { + const scope = pkgName.split('/')[0] + return `${scope}/${repoName}` + } + } + + return repoName +} + +// --------------------------------------------------------------------------- +// Variable detection from package.json +// --------------------------------------------------------------------------- + +function readPackageJson(root: string): Record { + const pkgPath = join(root, 'package.json') + try { + return JSON.parse(readFileSync(pkgPath, 'utf8')) as Record + } catch (err: unknown) { + const isNotFound = + err && + typeof err === 'object' && + 'code' in err && + (err as NodeJS.ErrnoException).code === 'ENOENT' + if (!isNotFound) { + console.error( + `Warning: could not read ${pkgPath}: ${err instanceof Error ? err.message : err}`, + ) + } + return {} + } +} + +function detectRepo( + pkgJson: Record, + fallback: string, +): string { + const intent = pkgJson.intent as Record | undefined + if (typeof intent?.repo === 'string') { + return intent.repo + } + + if (typeof pkgJson.repository === 'string') { + return pkgJson.repository + .replace(/^git\+/, '') + .replace(/\.git$/, '') + .replace(/^https?:\/\/github\.com\//, '') + } + + if ( + pkgJson.repository && + typeof pkgJson.repository === 'object' && + typeof (pkgJson.repository as Record).url === 'string' + ) { + return ((pkgJson.repository as Record).url as string) + .replace(/^git\+/, '') + .replace(/\.git$/, '') + .replace(/^https?:\/\/github\.com\//, '') + } + + return fallback +} + +function normalizePattern(pattern: string): string { + return pattern.endsWith('**') ? pattern : pattern.replace(/\/$/, '') + '/**' +} + +function buildWatchPaths(root: string, packageDirs: Array): string { + const paths = new Set() + + if (existsSync(join(root, 'docs'))) { + paths.add('docs/**') + } + + for (const packageDir of packageDirs) { + const relDir = relative(root, packageDir).split('\\').join('/') + if (existsSync(join(packageDir, 'src'))) { + paths.add(`${relDir}/src/**`) + } + + const pkgJson = readPackageJson(packageDir) + const intent = pkgJson.intent as Record | undefined + const docs = typeof intent?.docs === 'string' ? intent.docs : 'docs/' + if (!docs.startsWith('http://') && !docs.startsWith('https://')) { + paths.add(normalizePattern(join(relDir, docs).split('\\').join('/'))) + } + } + + if (paths.size === 0) { + paths.add('packages/*/src/**') + paths.add('packages/*/docs/**') + } + + return [...paths] + .sort() + .map((path) => ` - '${path}'`) + .join('\n') +} + +function detectVars(root: string, packageDirs?: Array): TemplateVars { + const pkgJson = readPackageJson(root) + const rawName = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' + const docs = + typeof (pkgJson.intent as Record | undefined)?.docs === + 'string' + ? ((pkgJson.intent as Record).docs as string) + : 'docs/' + const isMonorepo = packageDirs !== undefined + const monorepoFallbackPkg = packageDirs?.[0] + ? readPackageJson(packageDirs[0]) + : null + const repo = detectRepo( + pkgJson, + detectRepo(monorepoFallbackPkg ?? {}, basename(root)), + ) + + let packageName = rawName + if (isMonorepo && isGenericWorkspaceName(rawName, root)) { + packageName = deriveWorkspacePackageName(root, repo, packageDirs) + } + + // Derive srcPath: monorepos use a wildcard; single packages use the short name or fall back to root src/ + const shortName = packageName.replace(/^@[^/]+\//, '') + let srcPath = isMonorepo + ? 'packages/*/src/**' + : `packages/${shortName}/src/**` + if (!isMonorepo && existsSync(join(root, 'src'))) { + srcPath = 'src/**' + } + + const docsPath = isMonorepo ? 'packages/*/docs/**' : docs + + return { + PACKAGE_NAME: packageName, + PACKAGE_LABEL: packageName, + PAYLOAD_PACKAGE: packageName, + REPO: repo, + DOCS_PATH: docsPath.endsWith('**') + ? docsPath + : docsPath.replace(/\/$/, '') + '/**', + SRC_PATH: srcPath, + WATCH_PATHS: isMonorepo + ? buildWatchPaths(root, packageDirs) + : ` - '${docs.endsWith('**') ? docs : docs.replace(/\/$/, '') + '/**'}'\n - '${srcPath}'`, + } +} + +// --------------------------------------------------------------------------- +// Template variable substitution +// --------------------------------------------------------------------------- + +function applyVars(content: string, vars: TemplateVars): string { + return content + .replace(/\{\{PACKAGE_NAME\}\}/g, vars.PACKAGE_NAME) + .replace(/\{\{PACKAGE_LABEL\}\}/g, vars.PACKAGE_LABEL) + .replace(/\{\{PAYLOAD_PACKAGE\}\}/g, vars.PAYLOAD_PACKAGE) + .replace(/\{\{REPO\}\}/g, vars.REPO) + .replace(/\{\{DOCS_PATH\}\}/g, vars.DOCS_PATH) + .replace(/\{\{SRC_PATH\}\}/g, vars.SRC_PATH) + .replace(/\{\{WATCH_PATHS\}\}/g, vars.WATCH_PATHS) +} + +// --------------------------------------------------------------------------- +// Copy helpers +// --------------------------------------------------------------------------- + +function copyTemplates( + srcDir: string, + destDir: string, + vars: TemplateVars, +): { copied: Array; skipped: Array } { + const copied: Array = [] + const skipped: Array = [] + + if (!existsSync(srcDir)) return { copied, skipped } + + mkdirSync(destDir, { recursive: true }) + + for (const entry of readdirSync(srcDir)) { + const srcPath = join(srcDir, entry) + const destPath = join(destDir, entry) + + if (existsSync(destPath)) { + skipped.push(destPath) + continue + } + + let content = readFileSync(srcPath, 'utf8') + if (vars.WATCH_PATHS.includes('\n')) { + content = content.replace( + /\s+- '?\{\{DOCS_PATH\}\}'?\n\s+- '?\{\{SRC_PATH\}\}'?/, + vars.WATCH_PATHS, + ) + } + const substituted = applyVars(content, vars) + writeFileSync(destPath, substituted) + copied.push(destPath) + } + + return { copied, skipped } +} + +// --------------------------------------------------------------------------- +// Command: edit-package-json +// --------------------------------------------------------------------------- + +export function runEditPackageJson(root: string): EditPackageJsonResult { + const result: EditPackageJsonResult = { added: [], alreadyPresent: [] } + const context = resolveProjectContext({ cwd: root }) + const packageRoot = context.packageRoot ?? root + const pkgPath = join(packageRoot, 'package.json') + + if (!existsSync(pkgPath)) { + console.error('No package.json found in ' + packageRoot) + process.exitCode = 1 + return result + } + + const raw = readFileSync(pkgPath, 'utf8') + let pkg: Record + try { + pkg = JSON.parse(raw) as Record + } catch (err) { + const detail = err instanceof SyntaxError ? err.message : String(err) + console.error(`Failed to parse ${pkgPath}: ${detail}`) + process.exitCode = 1 + return result + } + + // Detect indent size from existing file + const indentMatch = raw.match(/^(\s+)"/m) + const indentSize = indentMatch?.[1] ? indentMatch[1].length : 2 + + // --- keywords array --- + if (!Array.isArray(pkg.keywords)) { + pkg.keywords = [] + } + const keywords = pkg.keywords as Array + if (keywords.includes('tanstack-intent')) { + result.alreadyPresent.push('keywords: "tanstack-intent"') + } else { + keywords.push('tanstack-intent') + result.added.push('keywords: "tanstack-intent"') + } + + // --- files array --- + if (!Array.isArray(pkg.files)) { + pkg.files = [] + } + const files = pkg.files as Array + + // In monorepos, _artifacts lives at repo root, not under packages — + // the negation pattern is a no-op and shouldn't be added. + const requiredFiles = context.isMonorepo + ? ['skills'] + : ['skills', '!skills/_artifacts'] + + for (const entry of requiredFiles) { + if (files.includes(entry)) { + result.alreadyPresent.push(`files: "${entry}"`) + } else { + files.push(entry) + result.added.push(`files: "${entry}"`) + } + } + + writeFileSync(pkgPath, JSON.stringify(pkg, null, indentSize) + '\n') + + // Print results + for (const a of result.added) console.log(`✓ Added ${a}`) + for (const a of result.alreadyPresent) console.log(` Already present: ${a}`) + + return result +} + +// --------------------------------------------------------------------------- +// Monorepo-aware command runner +// --------------------------------------------------------------------------- + +/** + * When run from a monorepo root, finds all workspace packages with SKILL.md + * files and runs the given command on each. Falls back to single-package + * behavior only when no workspace config is detected. If workspace config + * exists but no packages have skills, warns and returns empty. + */ +function runForEachPackage( + root: string, + runOne: (dir: string) => T, +): Array> | T { + const isMonorepo = readWorkspacePatterns(root) !== null + const pkgsWithSkills = isMonorepo ? findPackagesWithSkills(root) : [] + + if (!isMonorepo) { + return runOne(root) + } + + if (pkgsWithSkills.length === 0) { + console.log('No workspace packages with skills found.') + return [] + } + + return pkgsWithSkills.map((pkgDir) => { + const rel = relative(root, pkgDir) || '.' + console.log(`\n── ${rel} ──`) + return { package: rel, result: runOne(pkgDir) } + }) +} + +export function runEditPackageJsonAll( + root: string, +): Array> | EditPackageJsonResult { + return runForEachPackage(root, runEditPackageJson) +} + +// --------------------------------------------------------------------------- +// Command: setup-github-actions +// --------------------------------------------------------------------------- + +export function runSetupGithubActions( + root: string, + metaDir: string, +): SetupGithubActionsResult { + const workspaceRoot = findWorkspaceRoot(root) ?? root + const packageDirs = findPackagesWithSkills(workspaceRoot) + const vars = detectVars( + workspaceRoot, + packageDirs.length > 0 ? packageDirs : undefined, + ) + const result: SetupGithubActionsResult = { workflows: [], skipped: [] } + + const srcDir = join(metaDir, 'templates', 'workflows') + const destDir = join(workspaceRoot, '.github', 'workflows') + const { copied, skipped } = copyTemplates(srcDir, destDir, vars) + result.workflows = copied + result.skipped = skipped + + for (const f of result.workflows) console.log(`✓ Copied workflow: ${f}`) + for (const f of result.skipped) console.log(` Already exists: ${f}`) + + if (result.workflows.length === 0 && result.skipped.length === 0) { + console.log('No templates directory found. Is @tanstack/intent installed?') + } else if (result.workflows.length > 0) { + console.log(`\nTemplate variables applied:`) + console.log(` Package: ${vars.PACKAGE_LABEL}`) + console.log(` Repo: ${vars.REPO}`) + console.log( + ` Mode: ${packageDirs.length > 0 ? `monorepo (${packageDirs.length} packages with skills)` : 'single package'}`, + ) + } + + return result +} diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/setup/workspace-patterns.ts similarity index 99% rename from packages/intent/src/workspace-patterns.ts rename to packages/intent/src/setup/workspace-patterns.ts index 7117565..1285775 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/setup/workspace-patterns.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' import { parse as parseYaml } from 'yaml' -import { findSkillFiles } from './utils.js' +import { findSkillFiles } from '../shared/utils.js' import type { ParseError } from 'jsonc-parser' function normalizeWorkspacePattern(pattern: string): string { diff --git a/packages/intent/src/cli-error.ts b/packages/intent/src/shared/cli-error.ts similarity index 100% rename from packages/intent/src/cli-error.ts rename to packages/intent/src/shared/cli-error.ts diff --git a/packages/intent/src/cli-output.ts b/packages/intent/src/shared/cli-output.ts similarity index 100% rename from packages/intent/src/cli-output.ts rename to packages/intent/src/shared/cli-output.ts diff --git a/packages/intent/src/command-runner.ts b/packages/intent/src/shared/command-runner.ts similarity index 90% rename from packages/intent/src/command-runner.ts rename to packages/intent/src/shared/command-runner.ts index 658abef..0bd681a 100644 --- a/packages/intent/src/command-runner.ts +++ b/packages/intent/src/shared/command-runner.ts @@ -1,4 +1,4 @@ -import { detectPackageManager } from './package-manager.js' +import { detectPackageManager } from '../discovery/package-manager.js' import type { PackageManager } from './types.js' export { detectPackageManager as detectIntentCommandPackageManager } diff --git a/packages/intent/src/display.ts b/packages/intent/src/shared/display.ts similarity index 99% rename from packages/intent/src/display.ts rename to packages/intent/src/shared/display.ts index 297f49a..1cf7be5 100644 --- a/packages/intent/src/display.ts +++ b/packages/intent/src/shared/display.ts @@ -5,7 +5,7 @@ import { formatRuntimeSkillLookupHint, isStableLoadPath, -} from './skill-paths.js' +} from '../skills/paths.js' export interface SkillDisplay { name: string diff --git a/packages/intent/src/types.ts b/packages/intent/src/shared/types.ts similarity index 100% rename from packages/intent/src/types.ts rename to packages/intent/src/shared/types.ts diff --git a/packages/intent/src/utils.ts b/packages/intent/src/shared/utils.ts similarity index 100% rename from packages/intent/src/utils.ts rename to packages/intent/src/shared/utils.ts diff --git a/packages/intent/src/skills/categories.ts b/packages/intent/src/skills/categories.ts new file mode 100644 index 0000000..09581f8 --- /dev/null +++ b/packages/intent/src/skills/categories.ts @@ -0,0 +1,23 @@ +import type { SkillEntry } from '../shared/types.js' + +export type SkillCategory = 'maintainer' | 'meta' | 'reference' | 'task' + +const MAINTAINER_TYPES = new Set(['maintainer', 'maintainer-only']) + +export function getSkillCategory( + skill: Pick, +): SkillCategory { + const type = skill.type?.trim().toLowerCase() + + if (type === 'reference') return 'reference' + if (type === 'meta') return 'meta' + if (type && MAINTAINER_TYPES.has(type)) return 'maintainer' + + return 'task' +} + +export function isGeneratedMappingSkill( + skill: Pick, +): boolean { + return getSkillCategory(skill) === 'task' +} diff --git a/packages/intent/src/skill-paths.ts b/packages/intent/src/skills/paths.ts similarity index 94% rename from packages/intent/src/skill-paths.ts rename to packages/intent/src/skills/paths.ts index 6d921fd..cc23672 100644 --- a/packages/intent/src/skill-paths.ts +++ b/packages/intent/src/skills/paths.ts @@ -1,8 +1,8 @@ import { existsSync } from 'node:fs' import { join, relative } from 'node:path' -import { toPosixPath } from './utils.js' -import type { SkillUse } from './skill-use.js' -import type { SkillEntry } from './types.js' +import { toPosixPath } from '../shared/utils.js' +import type { SkillUse } from './use.js' +import type { SkillEntry } from '../shared/types.js' const RUNTIME_SKILL_LOOKUP_COMMENT_PATTERN = /^Runtime lookup only: run `npx @tanstack\/intent@latest load [^`]+ --path`, and load its reported path for this session\. Do not copy the resolved path into this file\.$/ diff --git a/packages/intent/src/resolver.ts b/packages/intent/src/skills/resolver.ts similarity index 97% rename from packages/intent/src/resolver.ts rename to packages/intent/src/skills/resolver.ts index 121c542..93f0142 100644 --- a/packages/intent/src/resolver.ts +++ b/packages/intent/src/skills/resolver.ts @@ -1,11 +1,11 @@ -import { warningMentionsPackage } from './core/excludes.js' -import { parseSkillUse } from './skill-use.js' +import { warningMentionsPackage } from '../core/excludes.js' +import { parseSkillUse } from './use.js' import type { IntentPackage, ScanResult, SkillEntry, VersionConflict, -} from './types.js' +} from '../shared/types.js' export interface ResolveSkillResult { packageName: string diff --git a/packages/intent/src/skill-use.ts b/packages/intent/src/skills/use.ts similarity index 100% rename from packages/intent/src/skill-use.ts rename to packages/intent/src/skills/use.ts diff --git a/packages/intent/src/artifact-coverage.ts b/packages/intent/src/staleness/artifact-coverage.ts similarity index 99% rename from packages/intent/src/artifact-coverage.ts rename to packages/intent/src/staleness/artifact-coverage.ts index b1211a9..9c2f2a6 100644 --- a/packages/intent/src/artifact-coverage.ts +++ b/packages/intent/src/staleness/artifact-coverage.ts @@ -7,7 +7,7 @@ import type { IntentArtifactSet, IntentArtifactSkill, IntentArtifactWarning, -} from './types.js' +} from '../shared/types.js' type ArtifactKind = IntentArtifactFile['kind'] diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness/check.ts similarity index 99% rename from packages/intent/src/staleness.ts rename to packages/intent/src/staleness/check.ts index e62fb15..9f9bcd7 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness/check.ts @@ -7,14 +7,14 @@ import { parseFrontmatter, readScalarField, toPosixPath, -} from './utils.js' +} from '../shared/utils.js' import type { IntentArtifactSet, IntentArtifactSkill, SkillStaleness, StalenessReport, StalenessSignal, -} from './types.js' +} from '../shared/types.js' // --------------------------------------------------------------------------- // Helpers diff --git a/packages/intent/src/staleness/index.ts b/packages/intent/src/staleness/index.ts new file mode 100644 index 0000000..80bf4ad --- /dev/null +++ b/packages/intent/src/staleness/index.ts @@ -0,0 +1 @@ +export * from './check.js' diff --git a/packages/intent/src/workflow-review.ts b/packages/intent/src/staleness/workflow-review.ts similarity index 99% rename from packages/intent/src/workflow-review.ts rename to packages/intent/src/staleness/workflow-review.ts index e742ce8..bb7c1e4 100644 --- a/packages/intent/src/workflow-review.ts +++ b/packages/intent/src/staleness/workflow-review.ts @@ -1,5 +1,5 @@ import { appendFileSync, writeFileSync } from 'node:fs' -import type { StalenessReport } from './types.js' +import type { StalenessReport } from '../shared/types.js' export interface StaleReviewItem { type: string diff --git a/packages/intent/tests/artifact-coverage.test.ts b/packages/intent/tests/artifact-coverage.test.ts index c7a0945..ac234fb 100644 --- a/packages/intent/tests/artifact-coverage.test.ts +++ b/packages/intent/tests/artifact-coverage.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { readIntentArtifacts } from '../src/artifact-coverage.js' +import { readIntentArtifacts } from '../src/staleness/artifact-coverage.js' let root: string diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index baba931..21b20e9 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { INSTALL_PROMPT } from '../src/commands/install.js' +import { INSTALL_PROMPT } from '../src/commands/install/command.js' import { isMainModule, main } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) @@ -215,9 +215,18 @@ describe('cli commands', () => { it('prints the install prompt', async () => { const exitCode = await main(['install', '--print-prompt']) + const output = String(logSpy.mock.calls[0]?.[0]) expect(exitCode).toBe(0) expect(logSpy).toHaveBeenCalledWith(INSTALL_PROMPT) + expect(output).toContain('tanstackIntent:') + expect(output).toContain(' - id: "@scope/package#skill-name"') + expect(output).toContain( + ' run: "npx @tanstack/intent@latest load @scope/package#skill-name"', + ) + expect(output).toContain(' for: "describe the task or code area here"') + expect(output).not.toContain('skills:\n - when:') + expect(output).not.toContain('use: "@scope/package#skill-name"') }) it('lists excludes when none are configured', async () => { @@ -356,7 +365,8 @@ describe('cli commands', () => { expect(output).toContain('Created AGENTS.md with skill loading guidance.') expect(content).toContain('## Skill Loading') expect(content).toContain('npx @tanstack/intent@latest list') - expect(content).toContain('if one local skill clearly matches the task') + expect(content).toContain('If a listed skill matches the task') + expect(content).toContain('before changing files') expect(content).toContain('Monorepos:') expect(content).toContain('Multiple matches:') expect(content).not.toContain('--global') @@ -445,6 +455,34 @@ describe('cli commands', () => { ) }) + it('installs hooks with the hooks install command', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-install-')) + tempDirs.push(root) + process.chdir(root) + + const exitCode = await main(['hooks', 'install', '--agents', 'claude']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('Installed Intent hooks for claude (project)') + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) + }) + + it('fails cleanly for invalid hooks install options', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-bad-options-')) + tempDirs.push(root) + process.chdir(root) + + const exitCode = await main(['hooks', 'install', '--scope', 'repo']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown hook scope: repo. Expected project or user.', + ) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) + }) + it('writes install mappings with --map and is idempotent', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-map-')) const isolatedGlobalRoot = mkdtempSync( @@ -468,8 +506,11 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Created AGENTS.md with 1 mapping.') - expect(content).toContain('when: "Query data fetching patterns"') - expect(content).toContain('use: "@tanstack/query#fetching"') + expect(content).toContain('for: "Query data fetching patterns"') + expect(content).toContain('id: "@tanstack/query#fetching"') + expect(content).toContain( + 'run: "npx @tanstack/intent@latest load @tanstack/query#fetching"', + ) expect(content).not.toContain('load:') expect(content).not.toContain(root) @@ -516,7 +557,7 @@ describe('cli commands', () => { const content = readFileSync(join(root, 'AGENTS.md'), 'utf8') expect(exitCode).toBe(0) - expect(content).toContain('use: "@tanstack/query#fetching"') + expect(content).toContain('id: "@tanstack/query#fetching"') expect(content).not.toContain('@tanstack/unlisted') }) @@ -575,8 +616,45 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Generated 1 mapping for AGENTS.md.') - expect(output).toContain('when: "Global fetching skill"') - expect(output).toContain('use: "@tanstack/query#fetching"') + expect(output).toContain('for: "Global fetching skill"') + expect(output).toContain('id: "@tanstack/query#fetching"') + }) + + it('uses only global packages during install --map --global-only', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-global-only-'), + ) + const globalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-install-global-only-node-modules-'), + ) + tempDirs.push(root, globalRoot) + + writeInstalledIntentPackage(root, { + name: '@tanstack/local', + version: '1.0.0', + skillName: 'local-skill', + description: 'Local skill', + }) + const globalPkgDir = join(globalRoot, '@tanstack', 'query') + writeJson(join(globalPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(globalPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Global fetching skill', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + process.chdir(root) + + const exitCode = await main(['install', '--map', '--global-only']) + const content = readFileSync(join(root, 'AGENTS.md'), 'utf8') + + expect(exitCode).toBe(0) + expect(content).toContain('id: "@tanstack/query#fetching"') + expect(content).not.toContain('@tanstack/local#local-skill') }) it('prints the scaffold prompt', async () => { diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 9cacc37..0d55629 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -14,7 +14,7 @@ import { listIntentSkills, loadIntentSkill, resolveIntentSkill, -} from '../src/core.js' +} from '../src/core/index.js' const realTmpdir = realpathSync(tmpdir()) diff --git a/packages/intent/tests/fs-cache.test.ts b/packages/intent/tests/fs-cache.test.ts index d90f0d0..fa5e3c1 100644 --- a/packages/intent/tests/fs-cache.test.ts +++ b/packages/intent/tests/fs-cache.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createIntentFsCache } from '../src/fs-cache.js' +import { createIntentFsCache } from '../src/discovery/fs-cache.js' let root: string diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts new file mode 100644 index 0000000..55ede08 --- /dev/null +++ b/packages/intent/tests/hooks-install.test.ts @@ -0,0 +1,341 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' +import { HOOK_AGENT_ADAPTERS } from '../src/hooks/adapters.js' +import { + buildHookRunnerScript, + formatHookInstallResult, + runInstallHooks, +} from '../src/hooks/install.js' + +const tempDirs: Array = [] + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempRoot(name: string): string { + const root = mkdtempSync(join(tmpdir(), name)) + tempDirs.push(root) + return root +} + +function readJson(filePath: string): Record { + return JSON.parse(readFileSync(filePath, 'utf8')) as Record +} + +describe('hook installer', () => { + 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) + expect(HOOK_AGENT_ADAPTERS.copilot.supportedScopes.has('project')).toBe( + false, + ) + expect(HOOK_AGENT_ADAPTERS.copilot.supportedScopes.has('user')).toBe(true) + }) + + it('installs project-scoped Claude and Codex hooks and skips Copilot', () => { + const root = tempRoot('intent-hooks-project-') + + const results = runInstallHooks({ root, scope: 'project' }) + + expect(results.map((result) => result.agent)).toEqual([ + 'copilot', + 'claude', + 'codex', + ]) + expect(results.find((result) => result.agent === 'copilot')).toMatchObject({ + status: 'skipped', + reason: 'project scope is not supported; use --scope user', + }) + expect(results.find((result) => result.agent === 'claude')).toMatchObject({ + status: 'created', + scope: 'project', + }) + expect(results.find((result) => result.agent === 'codex')).toMatchObject({ + status: 'created', + scope: 'project', + }) + + const claudeConfig = readJson(join(root, '.claude', 'settings.json')) + expect(claudeConfig.hooks.PreToolUse).toHaveLength(1) + expect(claudeConfig.hooks.PreToolUse[0].matcher).toBe( + 'Bash|Write|Edit|MultiEdit|NotebookEdit', + ) + expect(claudeConfig.hooks.PreToolUse[0].hooks[0]).toMatchObject({ + command: 'node', + args: ['${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs'], + type: 'command', + }) + + const codexConfig = readJson(join(root, '.codex', 'hooks.json')) + expect(codexConfig.hooks.PreToolUse[0].matcher).toBe( + 'Bash|apply_patch|Edit|Write', + ) + expect(codexConfig.hooks.PreToolUse[0].hooks[0].command).toContain( + '.intent/hooks/intent-codex-gate.mjs', + ) + expect( + existsSync(join(root, '.intent', 'hooks', 'intent-claude-gate.mjs')), + ).toBe(true) + expect( + existsSync(join(root, '.intent', 'hooks', 'intent-codex-gate.mjs')), + ).toBe(true) + }) + + it('installs user-scoped Copilot hooks into the selected home', () => { + const root = tempRoot('intent-hooks-root-') + const homeDir = tempRoot('intent-hooks-home-') + const copilotHome = join(homeDir, '.custom-copilot') + + const [result] = runInstallHooks({ + agents: 'copilot', + copilotHome, + homeDir, + root, + scope: 'user', + }) + + expect(result).toMatchObject({ agent: 'copilot', status: 'created' }) + const config = readJson(join(copilotHome, 'hooks', 'hooks.json')) + const command = config.hooks.PreToolUse[0].command as string + + expect(command).toContain(join(homeDir, '.tanstack')) + expect(command).toContain('intent-copilot-gate.mjs') + expect( + existsSync( + join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-copilot-gate.mjs', + ), + ), + ).toBe(true) + }) + + it('updates only the Intent hook group on repeated installs', () => { + const root = tempRoot('intent-hooks-update-') + const settingsPath = join(root, '.claude', 'settings.json') + mkdirSync(join(root, '.claude'), { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo keep' }], + }, + { + matcher: 'Edit', + hooks: [ + { + type: 'command', + command: 'node old-intent-claude-gate.mjs', + }, + ], + }, + ], + }, + }, + null, + 2, + ) + '\n', + ) + + runInstallHooks({ agents: 'claude', root, scope: 'project' }) + const second = runInstallHooks({ agents: 'claude', root, scope: 'project' }) + + const config = readJson(settingsPath) + expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('echo keep') + expect(second[0]).toMatchObject({ status: 'unchanged' }) + }) + + it('preserves sibling hooks when replacing an Intent hook entry', () => { + const root = tempRoot('intent-hooks-sibling-') + const settingsPath = join(root, '.claude', 'settings.json') + mkdirSync(join(root, '.claude'), { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: 'Edit', + hooks: [ + { + type: 'command', + command: 'node old-intent-claude-gate.mjs', + }, + { type: 'command', command: 'echo keep' }, + ], + }, + ], + }, + }, + null, + 2, + ) + '\n', + ) + + runInstallHooks({ agents: 'claude', root, scope: 'project' }) + + const config = readJson(settingsPath) + expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse[0].hooks).toEqual([ + { type: 'command', command: 'echo keep' }, + ]) + expect(config.hooks.PreToolUse[1].hooks[0].args[0]).toContain( + 'intent-claude-gate.mjs', + ) + }) + + it('replaces direct Copilot Intent hook entries on reinstall', () => { + const root = tempRoot('intent-hooks-copilot-replace-root-') + const homeDir = tempRoot('intent-hooks-copilot-replace-home-') + const copilotHome = join(homeDir, '.copilot') + const hooksPath = join(copilotHome, 'hooks', 'hooks.json') + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + writeFileSync( + hooksPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { command: 'node /tmp/old-intent-copilot-gate.mjs' }, + { command: 'echo keep' }, + ], + }, + }, + null, + 2, + ) + '\n', + ) + + runInstallHooks({ + agents: 'copilot', + copilotHome, + homeDir, + root, + scope: 'user', + }) + + const config = readJson(hooksPath) + expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse[0]).toEqual({ command: 'echo keep' }) + expect(config.hooks.PreToolUse[1].command).toContain( + 'intent-copilot-gate.mjs', + ) + }) + + it('builds a runner script with command-free denial text', () => { + const script = buildHookRunnerScript('claude') + + expect(script).toContain('const AGENT = "claude"') + expect(script).toContain('permissionDecision') + expect(script).not.toMatch(/Blocked:.*intent\s+(list|load)/i) + }) + + it('runs the generated gate script through the load then edit cycle', () => { + const root = tempRoot('intent-hooks-runner-') + const scriptPath = join(root, 'intent-claude-gate.mjs') + writeFileSync(scriptPath, buildHookRunnerScript('claude')) + + const beforeLoad = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + const load = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Bash', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }) + const afterLoad = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + + expect(beforeLoad.status).toBe(0) + expect(JSON.parse(beforeLoad.stdout)).toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }) + expect(load.status).toBe(0) + expect(load.stdout).toBe('') + expect(afterLoad.status).toBe(0) + expect(afterLoad.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') + writeFileSync(scriptPath, buildHookRunnerScript('claude')) + + const echoLoad = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Bash', + tool_input: { command: 'echo intent load @tanstack/router#routing' }, + }) + const afterEcho = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + + expect(echoLoad.status).toBe(0) + expect(echoLoad.stdout).toBe('') + expect(afterEcho.status).toBe(0) + expect(JSON.parse(afterEcho.stdout)).toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }) + }) + + it('formats skipped install results', () => { + expect( + formatHookInstallResult({ + agent: 'copilot', + configPath: null, + reason: 'project scope is not supported; use --scope user', + scope: 'project', + scriptPath: null, + status: 'skipped', + }), + ).toBe( + 'Skipped Intent hooks for copilot: project scope is not supported; use --scope user', + ) + }) +}) + +function runHookScript(scriptPath: string, event: Record) { + return spawnSync(process.execPath, [scriptPath], { + encoding: 'utf8', + input: JSON.stringify(event), + }) +} diff --git a/packages/intent/tests/hooks.test.ts b/packages/intent/tests/hooks.test.ts new file mode 100644 index 0000000..b7a9e66 --- /dev/null +++ b/packages/intent/tests/hooks.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { formatClaudePreToolUseOutput } from '../src/hooks/agents/claude.js' +import { formatCodexPreToolUseOutput } from '../src/hooks/agents/codex.js' +import { formatCopilotPreToolUseOutput } from '../src/hooks/agents/copilot.js' +import { + EDIT_TOOLS_BY_AGENT, + GATE_DENY_REASON, + gateDecision, + hasLoadFromObservations, + observationFromEvent, + parseIntentInvocation, +} from '../src/hooks/policy.js' + +describe('intent hook policy', () => { + it('parses intent load and list invocations across runners', () => { + expect( + parseIntentInvocation( + 'npx @tanstack/intent@latest load @tanstack/router#routing', + ), + ).toEqual({ action: 'load', skillUse: '@tanstack/router#routing' }) + expect(parseIntentInvocation('intent list')).toEqual({ action: 'list' }) + expect( + parseIntentInvocation('cd packages/app && intent load @tanstack/x#y'), + ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) + expect( + parseIntentInvocation('npm test || intent load @tanstack/x#y'), + ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) + }) + + it('ignores non-intent commands and incomplete load commands', () => { + expect(parseIntentInvocation('npm run build')).toBeUndefined() + expect( + parseIntentInvocation('echo intent load @tanstack/router#routing'), + ).toBeUndefined() + expect( + parseIntentInvocation('# intent load @tanstack/router#routing'), + ).toBeUndefined() + expect(parseIntentInvocation('intent load')).toBeUndefined() + expect(parseIntentInvocation(undefined)).toBeUndefined() + }) + + it('observes intent commands only from Bash tool calls', () => { + expect( + observationFromEvent({ + tool_name: 'Bash', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }), + ).toEqual({ + action: 'load', + skillUse: '@tanstack/router#routing', + raw: 'intent load @tanstack/router#routing', + }) + expect( + observationFromEvent({ + toolName: 'Bash', + toolArgs: JSON.stringify({ command: 'intent list' }), + }), + ).toEqual({ action: 'list', raw: 'intent list', skillUse: undefined }) + expect( + observationFromEvent({ + tool_name: 'Edit', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }), + ).toBeUndefined() + }) + + it('denies edit tools until a load is observed', () => { + expect( + gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: false }), + ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) + expect( + gateDecision({ agent: 'claude', toolName: 'Write', hasLoaded: false }), + ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) + expect( + gateDecision({ + agent: 'codex', + toolName: 'apply_patch', + hasLoaded: false, + }), + ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) + expect( + gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: true }), + ).toEqual({ decision: 'allow' }) + expect( + gateDecision({ agent: 'codex', toolName: 'Bash', hasLoaded: false }), + ).toEqual({ decision: 'allow' }) + expect(EDIT_TOOLS_BY_AGENT.copilot.has('Write')).toBe(true) + expect(EDIT_TOOLS_BY_AGENT.claude.has('Edit')).toBe(true) + expect(EDIT_TOOLS_BY_AGENT.codex.has('apply_patch')).toBe(true) + }) + + it('detects a prior load from observation records', () => { + expect(hasLoadFromObservations([{ action: 'list' }])).toBe(false) + expect( + hasLoadFromObservations([{ action: 'list' }, { action: 'load' }]), + ).toBe(true) + }) + + it('keeps the deny reason free of parseable intent commands', () => { + expect(parseIntentInvocation(GATE_DENY_REASON)).toBeUndefined() + expect(/intent\s+(list|load)/i.test(GATE_DENY_REASON)).toBe(false) + }) +}) + +describe('intent hook agent adapters', () => { + const deny = { decision: 'deny' as const, reason: GATE_DENY_REASON } + + it('formats Copilot PreToolUse denial output', () => { + expect(formatCopilotPreToolUseOutput(deny)).toEqual({ + permissionDecision: 'deny', + permissionDecisionReason: GATE_DENY_REASON, + }) + expect(formatCopilotPreToolUseOutput({ decision: 'allow' })).toBeUndefined() + }) + + it('formats Claude PreToolUse denial output', () => { + expect(formatClaudePreToolUseOutput(deny)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: GATE_DENY_REASON, + }, + }) + expect(formatClaudePreToolUseOutput({ decision: 'allow' })).toBeUndefined() + }) + + it('formats Codex PreToolUse denial output', () => { + expect(formatCodexPreToolUseOutput(deny)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: GATE_DENY_REASON, + }, + }) + expect(formatCodexPreToolUseOutput({ decision: 'allow' })).toBeUndefined() + }) +}) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 09539e1..7127519 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -14,8 +14,12 @@ import { resolveIntentSkillsBlockTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, -} from '../src/commands/install-writer.js' -import type { IntentPackage, ScanResult, SkillEntry } from '../src/types.js' +} from '../src/commands/install/guidance.js' +import type { + IntentPackage, + ScanResult, + SkillEntry, +} from '../src/shared/types.js' const tempDirs: Array = [] @@ -82,10 +86,11 @@ function scanResult(packages: Array): ScanResult { } const exampleBlock = ` -# Skill mappings - load \`use\` with \`pnpm dlx @tanstack/intent@latest load \`. -skills: - - when: "Query data fetching" - use: "@tanstack/query#fetching" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query#fetching" + run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching" ` @@ -96,9 +101,8 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(0) expect(generated.block).toContain('## Skill Loading') expect(generated.block).toContain('npx @tanstack/intent@latest list') - expect(generated.block).toContain( - 'if one local skill clearly matches the task', - ) + expect(generated.block).toContain('If a listed skill matches the task') + expect(generated.block).toContain('before changing files') expect(generated.block).toContain('Monorepos:') expect(generated.block).toContain('Multiple matches:') expect(generated.block).not.toContain('install --map') @@ -147,14 +151,17 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(3) expect(generated.block).toBe(` -# Skill mappings - load \`use\` with \`pnpm dlx @tanstack/intent@latest load \`. -skills: - - when: "Query data fetching patterns" - use: "@tanstack/query#fetching" - - when: "Mutation patterns" - use: "@tanstack/query#mutations" - - when: "Routing patterns" - use: "@tanstack/router#routing" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query#fetching" + run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching patterns" + - id: "@tanstack/query#mutations" + run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#mutations" + for: "Mutation patterns" + - id: "@tanstack/router#routing" + run: "pnpm dlx @tanstack/intent@latest load @tanstack/router#routing" + for: "Routing patterns" `) }) @@ -181,8 +188,11 @@ skills: const generated = buildIntentSkillsBlock(result) expect(generated.mappingCount).toBe(2) - expect(generated.block).toContain('use: "@tanstack/query#global-fetching"') - expect(generated.block).toContain('use: "@tanstack/query#pnpm-fetching"') + expect(generated.block).toContain('id: "@tanstack/query#global-fetching"') + expect(generated.block).toContain('id: "@tanstack/query#pnpm-fetching"') + expect(generated.block).toContain( + 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#global-fetching"', + ) expect(generated.block).not.toContain('/home/sarah') expect(generated.block).not.toContain('node_modules/.pnpm') expect(generated.block).not.toContain('load:') @@ -217,10 +227,16 @@ skills: const generated = buildIntentSkillsBlock(result) expect(generated.mappingCount).toBe(2) - expect(generated.block).toContain('when: "Core skill"') - expect(generated.block).toContain('use: "@tanstack/query#core"') - expect(generated.block).toContain('when: "Sub-skill"') - expect(generated.block).toContain('use: "@tanstack/query#core/fetching"') + expect(generated.block).toContain('for: "Core skill"') + expect(generated.block).toContain('id: "@tanstack/query#core"') + expect(generated.block).toContain( + 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core"', + ) + expect(generated.block).toContain('for: "Sub-skill"') + expect(generated.block).toContain('id: "@tanstack/query#core/fetching"') + expect(generated.block).toContain( + 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core/fetching"', + ) expect(generated.block).not.toContain('Reference material') expect(generated.block).not.toContain('Maintainer task') expect(generated.block).not.toContain('Maintainer-only task') @@ -242,8 +258,8 @@ skills: const generated = buildIntentSkillsBlock(result) - expect(generated.block).toContain('when: "Use \\"quoted\\" names"') - expect(generated.block).toContain('use: "@tanstack/query#quotes"') + expect(generated.block).toContain('for: "Use \\"quoted\\" names"') + expect(generated.block).toContain('id: "@tanstack/query#quotes"') }) it('collapses whitespace in skill descriptions including newlines', () => { @@ -262,7 +278,7 @@ skills: const generated = buildIntentSkillsBlock(result) - expect(generated.block).toContain('when: "Line one Line two tabbed"') + expect(generated.block).toContain('for: "Line one Line two tabbed"') }) it('uses fallback when description for skills with empty descriptions', () => { @@ -281,7 +297,7 @@ skills: const generated = buildIntentSkillsBlock(result) - expect(generated.block).toContain('when: "Use @tanstack/query fetching"') + expect(generated.block).toContain('for: "Use @tanstack/query fetching"') }) }) @@ -454,10 +470,11 @@ describe('install writer verification', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - when: "Query data fetching" - use: "@tanstack/query#fetching" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query#fetching" + run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching" ` writeFileSync(agentsPath, block) @@ -488,7 +505,7 @@ skills: it('rejects missing managed block markers', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') - writeFileSync(agentsPath, 'skills: []\n') + writeFileSync(agentsPath, 'tanstackIntent: []\n') const result = verifyIntentSkillsBlockFile({ expectedBlock: exampleBlock, @@ -521,7 +538,7 @@ skills: ) }) - it('rejects legacy load paths', () => { + it('rejects legacy skills lists', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` @@ -541,20 +558,18 @@ skills: expect(result.ok).toBe(false) expect(result.errors).toContain( - 'Skill mappings must use compact `use` entries, not `load`.', - ) - expect(result.errors).toContain( - 'Each skill mapping must include a `use` field.', + 'Managed block must contain a tanstackIntent list.', ) }) - it('rejects mappings without when', () => { + it('rejects mappings without for', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - use: "@tanstack/query#fetching" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query#fetching" + run: "npx @tanstack/intent@latest load @tanstack/query#fetching" ` writeFileSync(agentsPath, block) @@ -567,17 +582,18 @@ skills: expect(result.ok).toBe(false) expect(result.errors).toContain( - 'Each skill mapping must include a non-empty `when` field.', + 'Each skill mapping must include a non-empty `for` field.', ) }) - it('rejects mappings without use', () => { + it('rejects mappings without id', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - when: "Query data fetching" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching" ` writeFileSync(agentsPath, block) @@ -590,18 +606,19 @@ skills: expect(result.ok).toBe(false) expect(result.errors).toContain( - 'Each skill mapping must include a `use` field.', + 'Each skill mapping must include an `id` field.', ) }) - it('rejects invalid use values', () => { + it('rejects invalid id values', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - when: "Query data fetching" - use: "@tanstack/query" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query" + run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching" ` writeFileSync(agentsPath, block) diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 220c867..1bedd01 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -8,7 +8,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listIntentSkills, loadIntentSkill } from '../../src/core.js' +import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js' import { main } from '../../src/cli.js' const realTmpdir = realpathSync(tmpdir()) @@ -113,9 +113,9 @@ describe('source policy — all four surfaces filter excluded and unlisted', () const output = logSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) - expect(output).toContain(`use: "${LISTED}#core"`) - expect(output).not.toContain(`use: "${UNLISTED}#core"`) - expect(output).not.toContain(`use: "${EXCLUDED}#core"`) + expect(output).toContain(`id: "${LISTED}#core"`) + expect(output).not.toContain(`id: "${UNLISTED}#core"`) + expect(output).not.toContain(`id: "${EXCLUDED}#core"`) rmSync(isolatedGlobalRoot, { recursive: true, force: true }) }) diff --git a/packages/intent/tests/parse-frontmatter.test.ts b/packages/intent/tests/parse-frontmatter.test.ts index 89e4aef..182e2c6 100644 --- a/packages/intent/tests/parse-frontmatter.test.ts +++ b/packages/intent/tests/parse-frontmatter.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { parseFrontmatter } from '../src/utils.js' +import { parseFrontmatter } from '../src/shared/utils.js' let root: string diff --git a/packages/intent/tests/read-scalar-field.test.ts b/packages/intent/tests/read-scalar-field.test.ts index c4b0215..461d81f 100644 --- a/packages/intent/tests/read-scalar-field.test.ts +++ b/packages/intent/tests/read-scalar-field.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { readScalarField } from '../src/utils.js' +import { readScalarField } from '../src/shared/utils.js' describe('readScalarField', () => { it('reads a top-level scalar (old shape)', () => { diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 78df895..1ffb28e 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest' -import { ResolveSkillUseError, resolveSkillUse } from '../src/resolver.js' +import { + ResolveSkillUseError, + resolveSkillUse, +} from '../src/skills/resolver.js' import type { IntentPackage, ScanResult, SkillEntry, VersionConflict, -} from '../src/types.js' +} from '../src/shared/types.js' function skill( name: string, diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 289e86d..970016c 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -11,7 +11,10 @@ import { createRequire } from 'node:module' import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { scanForIntents, scanIntentPackageAtRoot } from '../src/scanner.js' +import { + scanForIntents, + scanIntentPackageAtRoot, +} from '../src/discovery/scanner.js' // ── Helpers ── diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index d1aee9e..35894fa 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -13,8 +13,11 @@ import { runEditPackageJson, runEditPackageJsonAll, runSetupGithubActions, -} from '../src/setup.js' -import type { EditPackageJsonResult, MonorepoResult } from '../src/setup.js' +} from '../src/setup/index.js' +import type { + EditPackageJsonResult, + MonorepoResult, +} from '../src/setup/index.js' const repoRoot = join(import.meta.dirname, '..', '..', '..') diff --git a/packages/intent/tests/skill-categories.test.ts b/packages/intent/tests/skill-categories.test.ts new file mode 100644 index 0000000..aea896c --- /dev/null +++ b/packages/intent/tests/skill-categories.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { + getSkillCategory, + isGeneratedMappingSkill, +} from '../src/skills/categories.js' + +describe('skill categories', () => { + it('treats empty and unknown types as task skills', () => { + expect(getSkillCategory({})).toBe('task') + expect(getSkillCategory({ type: 'core' })).toBe('task') + expect(getSkillCategory({ type: ' CORE ' })).toBe('task') + }) + + it('categorizes non-task skill types', () => { + expect(getSkillCategory({ type: 'reference' })).toBe('reference') + expect(getSkillCategory({ type: 'meta' })).toBe('meta') + expect(getSkillCategory({ type: 'maintainer' })).toBe('maintainer') + expect(getSkillCategory({ type: 'maintainer-only' })).toBe('maintainer') + }) + + it('maps only task skills into generated guidance', () => { + expect(isGeneratedMappingSkill({ type: 'core' })).toBe(true) + expect(isGeneratedMappingSkill({ type: 'reference' })).toBe(false) + expect(isGeneratedMappingSkill({ type: 'meta' })).toBe(false) + expect(isGeneratedMappingSkill({ type: 'maintainer-only' })).toBe(false) + }) +}) diff --git a/packages/intent/tests/skill-paths.test.ts b/packages/intent/tests/skill-paths.test.ts index d29ea1f..162af17 100644 --- a/packages/intent/tests/skill-paths.test.ts +++ b/packages/intent/tests/skill-paths.test.ts @@ -10,8 +10,8 @@ import { isRuntimeSkillLookupComment, isStableLoadPath, rewriteSkillLoadPaths, -} from '../src/skill-paths.js' -import type { SkillEntry } from '../src/types.js' +} from '../src/skills/paths.js' +import type { SkillEntry } from '../src/shared/types.js' const tempDirs: Array = [] diff --git a/packages/intent/tests/skill-use.test.ts b/packages/intent/tests/skill-use.test.ts index da4db33..04d00d3 100644 --- a/packages/intent/tests/skill-use.test.ts +++ b/packages/intent/tests/skill-use.test.ts @@ -3,7 +3,7 @@ import { SkillUseParseError, formatSkillUse, parseSkillUse, -} from '../src/skill-use.js' +} from '../src/skills/use.js' describe('skill use helpers', () => { it('formats scoped packages and slash-named skills', () => { diff --git a/packages/intent/tests/skills.test.ts b/packages/intent/tests/skills.test.ts index 36bfbbc..16b0456 100644 --- a/packages/intent/tests/skills.test.ts +++ b/packages/intent/tests/skills.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { parse as parseYaml } from 'yaml' -import { findSkillFiles } from '../src/utils.js' +import { findSkillFiles } from '../src/shared/utils.js' // ── Types ── diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 0c565a4..9fdef84 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -17,7 +17,7 @@ import { readSkillSourcesConfig, } from '../src/core/source-policy.js' import { parseSkillSources } from '../src/core/skill-sources.js' -import type { IntentPackage, SkillEntry } from '../src/types.js' +import type { IntentPackage, SkillEntry } from '../src/shared/types.js' const realTmpdir = realpathSync(tmpdir()) diff --git a/packages/intent/tests/stale-command.test.ts b/packages/intent/tests/stale-command.test.ts index db9f559..29653ba 100644 --- a/packages/intent/tests/stale-command.test.ts +++ b/packages/intent/tests/stale-command.test.ts @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { INTENT_CHECK_SKILLS_WORKFLOW_VERSION, getCheckSkillsWorkflowAdvisories, -} from '../src/cli-support.js' +} from '../src/commands/support.js' import { runStaleCommand } from '../src/commands/stale.js' describe('runStaleCommand', () => { diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index d25fbdc..fa26657 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { checkStaleness } from '../src/staleness.js' +import { checkStaleness } from '../src/staleness/index.js' // --------------------------------------------------------------------------- // Fixture helpers diff --git a/packages/intent/tests/workflow-review.test.ts b/packages/intent/tests/workflow-review.test.ts index 648e2ee..96dedbc 100644 --- a/packages/intent/tests/workflow-review.test.ts +++ b/packages/intent/tests/workflow-review.test.ts @@ -8,8 +8,8 @@ import { createFailedStaleReviewItem, createWorkflowAdvisoryReviewItems, writeStaleReviewWorkflowFiles, -} from '../src/workflow-review.js' -import type { StalenessReport } from '../src/types.js' +} from '../src/staleness/workflow-review.js' +import type { StalenessReport } from '../src/shared/types.js' const repoRoot = join(import.meta.dirname, '..', '..', '..') diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index cfb8338..3c9c6a6 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -15,7 +15,7 @@ import { getWorkspaceInfo, readWorkspacePatterns, resolveWorkspacePackages, -} from '../src/workspace-patterns.js' +} from '../src/setup/workspace-patterns.js' const roots: Array = [] const cwdStack: Array = []