From d60b1c71a2c3e4dfde4ed4e26df1cc602768d7da Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 24 May 2026 19:40:20 +0300 Subject: [PATCH 01/10] feat(hooks): declarative provider lifecycle hooks (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a top-level `hooks:` section to capabilities.yaml, a canonical event model (sessionStart, beforeShell, afterFileEdit, …), and per- provider integrations for the four agents that ship project-local hook support today: Claude Code, Cursor, Codex, Gemini CLI. Other providers warn-and-skip without failing install. - New types: Hook, HookSource, CanonicalHookEvent, HooksIntegration, ProviderEventMapping. ProviderIntegration gains an optional `hooks` field; registry entries declare storage layout, shape, name-tag support, and the canonical→provider event map. - New hook-handlers module translates canonical hooks to provider- specific entries (Claude/Gemini matcher groups, Cursor cursor-v1 envelope, Codex TOML tables) and surgically upserts/removes only the entries it tagged with `name: capa:` / `id: `. - New install tasks `prune-orphan-hooks` and `install-hooks` run after sub-agents and before credential setup; new `cleanHooks` step in `capa clean` strips capa entries and removes materialised scripts. - Source-backed hook bodies materialise to `~/.capa/hooks//` (out of the project tree). - New SQLite `managed_hooks` table tracks (projectId, providerId, hookId, configPath, locator, scriptPath) for surgical edits. - New lockfile `hooks:` section pins github/gitlab/remote sources. - Server `/api/projects/:id` now returns each declared hook with its installed-providers metadata; web UI gains a HooksList panel and i18n strings to surface them. - Schema-side validation via `validateHooks` warns instead of failing. - Tests: hooks-validate, hook-handlers (per shape), hooks-installer (install/prune/clean), managed-hooks repo, registry hooks blocks, lockfile hook entries. - Docs: top-level README bullet, docs/README install/clean diagrams + managed-hooks abstraction, providers/README hooks matrix + per-provider sections (claude-code, cursor, codex, gemini-cli) with upstream documentation citations, capabilities-manager skill + schema reference. Closes #70. Co-authored-by: Cursor --- README.md | 1 + docs/README.md | 40 +- docs/providers/README.md | 28 + docs/providers/claude-code.md | 15 +- docs/providers/codex.md | 12 +- docs/providers/cursor.md | 14 +- docs/providers/gemini-cli.md | 12 +- skills/capabilities-manager/SKILL.md | 5 +- .../references/capabilities-schema.md | 65 ++ .../references/commands.md | 2 +- src/cli/commands/clean.ts | 12 + src/cli/commands/install-tasks/index.ts | 4 + .../commands/install-tasks/install-hooks.ts | 56 ++ .../install-tasks/prune-orphan-hooks.ts | 42 ++ .../commands/install-tasks/write-lockfile.ts | 19 +- .../utils/__tests__/hooks-installer.test.ts | 310 ++++++++++ src/cli/utils/hooks-installer.ts | 583 ++++++++++++++++++ src/db/__tests__/database.test.ts | 69 +++ src/db/database.ts | 20 + src/db/managed-hooks.ts | 95 +++ src/db/schema.ts | 19 + src/server/index.ts | 22 + src/shared/__tests__/capabilities.test.ts | 12 +- src/shared/__tests__/hooks-validate.test.ts | 74 +++ src/shared/__tests__/lockfile.test.ts | 32 + src/shared/capabilities.ts | 2 + src/shared/config.ts | 9 + src/shared/hooks-validate.ts | 205 ++++++ src/shared/lockfile.ts | 65 +- .../providers/__tests__/hook-handlers.test.ts | 139 +++++ .../providers/__tests__/registry.test.ts | 43 ++ src/shared/providers/hook-handlers.ts | 387 ++++++++++++ src/shared/providers/registry.ts | 90 +++ src/types/capabilities.ts | 8 + src/types/hooks.ts | 126 ++++ src/types/lockfile.ts | 30 + src/types/providers.ts | 63 ++ .../components/CapabilitiesSection.tsx | 16 +- .../projects/components/HooksList.tsx | 175 ++++++ web-ui/src/locales/en/projects.json | 24 +- web-ui/src/pages/ProjectDetailPage.tsx | 4 +- web-ui/src/types/api.ts | 25 + 42 files changed, 2946 insertions(+), 28 deletions(-) create mode 100644 src/cli/commands/install-tasks/install-hooks.ts create mode 100644 src/cli/commands/install-tasks/prune-orphan-hooks.ts create mode 100644 src/cli/utils/__tests__/hooks-installer.test.ts create mode 100644 src/cli/utils/hooks-installer.ts create mode 100644 src/db/managed-hooks.ts create mode 100644 src/shared/__tests__/hooks-validate.test.ts create mode 100644 src/shared/hooks-validate.ts create mode 100644 src/shared/providers/__tests__/hook-handlers.test.ts create mode 100644 src/shared/providers/hook-handlers.ts create mode 100644 src/types/hooks.ts create mode 100644 web-ui/src/features/projects/components/HooksList.tsx diff --git a/README.md b/README.md index 4c638103..de8b5183 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ The Registries tab pulls skills and plugins from external catalogs. Need a priva - One MCP server per agent. Tools load on demand, so the context window stays small. - Any CLI command can be wrapped as an MCP tool the agent (and `capa sh`) can call. - Rules go to each provider's native location: Cursor `.cursor/rules/`, Windsurf `.windsurf/rules/`, Copilot's instructions file, or a managed marker block in `AGENTS.md` / `CLAUDE.md` for providers without a rules directory. Glob scoping works. +- Lifecycle hooks for providers that support them (Claude Code, Cursor, Codex, Gemini CLI). Declare canonical events like `beforeShell` or `afterFileEdit` once and capa translates them into each provider's hook config — `.claude/settings.json`, `.cursor/hooks.json`, `.codex/config.toml`, `.gemini/settings.json` — using `capa:` tags so user-authored entries are never touched. Providers without hook support emit a warning and skip. - Sub-agents get their own filtered MCP endpoint that exposes only the tools the specialist actually needs. - Skills and plugins are browsable from `capa add` and the web UI. Add a registry with `capa registry add owner/repo@my-adapter` (GitHub/GitLab/HTTPS sources, slug auto-derived, adapter validated before install) and it shows up too. - `capabilities.lock` records resolved commit SHAs. A SHA-keyed content cache makes repeat installs near instant. diff --git a/docs/README.md b/docs/README.md index a780ac07..f1c8ac33 100644 --- a/docs/README.md +++ b/docs/README.md @@ -94,7 +94,14 @@ flowchart TD Unregister --> Subagents Subagents[install-subagents
purge stale entries,
unregister removed agents,
register current agents +
write subagent files + snippets] - Subagents --> Creds + Subagents --> PruneHooks + + PruneHooks[prune-orphan-hooks
delete capa-tagged hook entries
no longer in capabilities,
rm materialised scripts under
~/.capa/hooks//] --> InstallHooks + + InstallHooks{capabilities.hooks
set?} + InstallHooks -- yes --> WriteHooks[install-hooks
resolve sources to
~/.capa/hooks//,
upsert capa:<id> entries in
provider hooks config] + InstallHooks -- no --> Creds + WriteHooks --> Creds Creds{configureResult
needs creds?} Creds -- yes --> OpenBrowser[open-credential-setup
open web UI for missing
variables or OAuth2 connect] @@ -120,6 +127,8 @@ table below is a quick map of what each task reads/writes. | `install-agent-instructions` | `install-agent-instructions.ts` | `capabilities.agents` | `AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`, … | | `prune-orphan-rules` | `prune-orphan-rules.ts` | DB managed-files, current rule IDs | rm rule files/marker blocks | | `install-rules` | `install-rules.ts` | `capabilities.rules`, snapshot cache | per-provider rules dir **or** marker blocks in instructions file | +| `prune-orphan-hooks` | `prune-orphan-hooks.ts` | DB `managed_hooks`, current hook IDs | rm capa-tagged hook entries from provider configs, rm scripts in `~/.capa/hooks//` | +| `install-hooks` | `install-hooks.ts` | `capabilities.hooks`, snapshot cache, lockfile | provider hook config (`.claude/settings.json`, `.cursor/hooks.json`, `.codex/config.toml`, `.gemini/settings.json`), `~/.capa/hooks//` script files, `managed_hooks` rows, lock entries | | `configure-tools` | `configure-tools.ts` | merged capabilities | POSTs to `/api/projects/:id/configure`, stores `configureResult` | | `register-mcp-server` | `register-mcp-server.ts` | provider registry, mcpUrl | provider MCP config (`.cursor/mcp.json`, `.mcp.json`, …) | | `install-subagents` | `install-subagents.ts` | `capabilities.subagents`, DB sub-agents | sub-agent files, sub-agent MCP entries, instructions snippets | @@ -141,10 +150,11 @@ flowchart TD Resolve --> RmManaged[1. Remove managed files
iterate db.getManagedFiles] RmManaged --> CleanInstr[2. Clean agent instructions
strip every capa:* marker block
from AGENTS.md / CLAUDE.md / …] CleanInstr --> CleanRules[3. Clean rules
rm rule files in provider dirs
+ rule marker blocks] - CleanRules --> RmLock[4. Remove capabilities.lock] - RmLock --> UnregSub[5. Unregister sub-agents
rm MCP entries +
instruction snippets] - UnregSub --> UnregMcp[6. Unregister capa MCP
delete capa entry from each
provider's MCP config] - UnregMcp --> RmProject[7. Remove project row
db.deleteProject] + CleanRules --> CleanHooks[4. Clean hooks
rm capa:* hook entries from
provider hooks config +
rm ~/.capa/hooks/<projectId>/] + CleanHooks --> RmLock[5. Remove capabilities.lock] + RmLock --> UnregSub[6. Unregister sub-agents
rm MCP entries +
instruction snippets] + UnregSub --> UnregMcp[7. Unregister capa MCP
delete capa entry from each
provider's MCP config] + UnregMcp --> RmProject[8. Remove project row
db.deleteProject] RmProject --> Done([Cleanup complete]) ``` @@ -170,11 +180,23 @@ flowchart TD delete on the next install when entries disappear from the capabilities file. `capa clean` iterates the same table. +- **Managed-hooks table**. `managed_hooks(project_id, provider_id, hook_id, + config_path, locator, script_path)` tracks every hook entry capa wrote + into a shared provider config (`.claude/settings.json`, + `.cursor/hooks.json`, `.codex/config.toml`, `.gemini/settings.json`). The + `locator` is a JSON pointer (or TOML path) into the file so prune/clean + can edit a single entry surgically without disturbing user-authored + ones. `script_path` points at the materialised body under + `~/.capa/hooks//` for `source`-backed hooks. + `prune-orphan-hooks`, `install-hooks`, and `capa clean` all read and + mutate this table. + - **Lockfile**. `capabilities.lock` pins resolved commit SHAs for every - `github`/`gitlab` skill and plugin. The lockfile is built incrementally - during install (`ctx.lockBuilder.upsertSkill/upsertPlugin`) and pruned to the - current set of IDs at the end. `--no-cache` disables both lockfile lookups - and the on-disk snapshot cache. + `github`/`gitlab` skill, plugin, and hook source. The lockfile is built + incrementally during install (`ctx.lockBuilder.upsertSkill / upsertPlugin + / upsertHook`) and pruned to the current set of IDs at the end. + `--no-cache` disables both lockfile lookups and the on-disk snapshot + cache. --- diff --git a/docs/providers/README.md b/docs/providers/README.md index b0d87fea..732129d7 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -90,6 +90,34 @@ included for completeness but lack any project-local write paths. | **[Windsurf (`windsurf`)](./windsurf.md)** | `.windsurf/skills/` | — | — | `.windsurf/rules/*.md` (yaml: `description`, `globs`, `trigger: always_on \| model_decision`) | — | | [Zencoder (`zencoder`)](./zencoder.md) | `.zencoder/skills/` | — *(UI-managed; rules format unverified)* | — | — | — | +### Hooks integration + +Hooks are intentionally absent from the matrix above because only a small +slice of providers wire them up. The four providers below have a `hooks` +integration in `registry.ts` today — capa edits the file in-place using +`name: capa:` (or `id: ` for TOML) tags so it can update or +remove its own entries without touching user-authored ones. Every other +provider triggers a one-shot warning and skips; `capa install` never +fails because of an unsupported hook target. + +| Provider | Config file | Shape | +| --- | --- | --- | +| **[Claude Code (`claude-code`)](./claude-code.md)** | `.claude/settings.json` → `hooks` | JSON map (event → `[{ matcher, hooks: [...] }]`) | +| **[Codex (`codex`)](./codex.md)** | `.codex/config.toml` → `[hooks]` | TOML tables, ID-tagged | +| **[Cursor (`cursor`)](./cursor.md)** | `.cursor/hooks.json` (standalone) | `{ version: 1, hooks: { ... } }` envelope | +| **[Gemini CLI (`gemini-cli`)](./gemini-cli.md)** | `.gemini/settings.json` → `hooks` | JSON map (claude-style) | + +Materialised hook scripts (when the YAML uses `source:` instead of inline +`command`) live under `~/.capa/hooks//` rather than in +the project. The `managed_hooks` SQLite table tracks `(projectId, +providerId, hookId, configPath, locator, scriptPath)` so prune and clean +can edit a single entry surgically. + +See [`docs/README.md`](../README.md#installation-pipeline) for how +`prune-orphan-hooks` and `install-hooks` slot into the install pipeline, +and the per-provider pages above for citations to each provider's hooks +documentation. + ### Cross-cutting notes - **`AGENTS.md` is universal.** Every install run touches `AGENTS.md` diff --git a/docs/providers/claude-code.md b/docs/providers/claude-code.md index 825eaf5c..8809e864 100644 --- a/docs/providers/claude-code.md +++ b/docs/providers/claude-code.md @@ -15,11 +15,24 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → claude-code`] | Instructions | `CLAUDE.md` | Universal marker blocks; `AGENTS.md` also written if any other provider is active. | | Rules | `.claude/rules/.md` | YAML frontmatter — capa's `appliesTo` maps to `paths`. A file with no `paths` is loaded unconditionally. | | Sub-agents | `.claude/agents/.md` | Markdown + frontmatter (`name`, `description`, `model: inherit`). Also folds a `sub-agent:` snippet into `CLAUDE.md`. | +| Hooks | `.claude/settings.json` → `hooks` | JSON map keyed by event (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `SessionEnd`, `Stop`, `SubagentStop`, `PreCompact`, `Notification`). Capa upserts `[{ matcher, hooks: [{ name: "capa:", type, command, timeout }] }]` and only touches the entries it tagged. | | Plugin manifests | `.claude-plugin/plugin.json` (`pluginProviderId: claude`) | Parsed by `parseClaudeManifest`. Hoisted to front of plugin search order — see [plugin docs](../README.md#plugin-discovery-and-unpack). | +## Hooks event mapping + +Capa translates canonical events to Claude's hook event names: +`beforeTool → PreToolUse`, `afterTool → PostToolUse`, +`userPromptSubmit → UserPromptSubmit`, `sessionStart → SessionStart`, +`sessionEnd → SessionEnd`, `stop → Stop`, `subagentStop → SubagentStop`, +`preCompact → PreCompact`. `beforeShell` / `afterShell` re-use +`PreToolUse` / `PostToolUse` with an automatic `matcher: Bash` so the +hook only fires for shell tool invocations. + ## Sources - Memory & rules organisation: - `.claude/rules/`: +- Hooks reference: +- Hooks settings (`.claude/settings.json` → `hooks`): -Last verified: 2026-05-23 +Last verified: 2026-05-24 diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 5adbb5c1..29b5f16a 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -15,10 +15,20 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → codex`](../.. | Instructions | `AGENTS.md` | — | | Rules | folded into `AGENTS.md` | No project-local rules directory; capa writes marker blocks into the instructions file. | | Sub-agents | `.codex/agents/.toml` | TOML format; body goes into the `developer_instructions` field. | +| Hooks | `.codex/config.toml` → `[hooks]` | TOML tables. Codex does not support a `name` tag, so capa keys each entry by `id = ""` and tracks `(event, hookId)` in `managed_hooks` for surgical updates. | | Plugin manifests | — | Not declared; Codex consumes plugins via the same Claude/Cursor manifest paths handled elsewhere. | +## Hooks event mapping + +Codex's hook surface is narrower than Claude's: capa only writes +canonical events that map onto Codex's documented `[hooks]` keys +(`beforeTool`, `afterTool`, `sessionStart`, `sessionEnd`, +`userPromptSubmit`). Hooks targeting events Codex does not support are +skipped with a one-shot warning. + ## Sources - Codex repo & config docs: +- Codex hooks (`config.toml` → `[hooks]`): -Last verified: 2026-05-23 +Last verified: 2026-05-24 diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index b1e4ed16..f6854daf 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -15,10 +15,22 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → cursor`](../. | Instructions | `AGENTS.md` | — | | Rules | `.cursor/rules/.mdc` | YAML frontmatter: `description`, `globs` (from capa's `appliesTo`), `alwaysApply`. | | Sub-agents | `.cursor/agents/.md` | Markdown + frontmatter (`model`, `readonly`, `is_background`). | +| Hooks | `.cursor/hooks.json` (standalone) | `{ version: 1, hooks: { : [ { name: "capa:", command, … } ] } }` envelope. Cursor lets a hook fail-close on a non-zero exit (`failClosed: true`). | | Plugin manifests | `.cursor-plugin/plugin.json` (`pluginProviderId: cursor`) | Parsed by `parseCursorManifest` — see [plugin docs](../README.md#plugin-discovery-and-unpack). | +## Hooks event mapping + +Canonical → Cursor: `beforeShell → beforeShellExecution`, +`afterShell → afterShellExecution`, `beforeFileRead → beforeReadFile`, +`afterFileEdit → afterFileEdit`, `beforeMcpCall → beforeMCPExecution`, +`afterMcpCall → afterMCPExecution`, `userPromptSubmit → beforeSubmitPrompt`, +`stop → stop`. Cursor-specific events (e.g. `beforeShellExecution`) can +be targeted directly with `on: cursor:` to bypass the +canonical map. + ## Sources - Cursor docs: +- Cursor hooks reference: -Last verified: 2026-05-23 +Last verified: 2026-05-24 diff --git a/docs/providers/gemini-cli.md b/docs/providers/gemini-cli.md index bf149b3b..c9ad4741 100644 --- a/docs/providers/gemini-cli.md +++ b/docs/providers/gemini-cli.md @@ -15,8 +15,17 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → gemini-cli`]( | Instructions | `AGENTS.md` | Supported via configurable `context.fileName`. | | Rules | folded into `AGENTS.md` | No project-local rules directory; rules become marker blocks. | | Sub-agents | `.gemini/agents/.md` | Markdown + YAML frontmatter; `name` / `description` required. | +| Hooks | `.gemini/settings.json` → `hooks` | JSON map; Gemini reuses the Claude shape, so capa upserts `[{ matcher, hooks: [{ name: "capa:", … }] }]` and only manages its own tagged entries. | | Plugin manifests | — | Not declared. | +## Hooks event mapping + +Canonical → Gemini: `beforeTool → PreToolUse`, `afterTool → PostToolUse`, +`userPromptSubmit → UserPromptSubmit`, `sessionStart → SessionStart`, +`stop → Stop`. Gemini's hook surface tracks Claude's, so the bash-style +matcher rewrite (`beforeShell` ⇒ `PreToolUse` + `matcher: Bash`) applies +here too. + ## Caveats - Capa registers as `httpUrl` (streamable HTTP), not `url` (SSE). Don't @@ -25,5 +34,6 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → gemini-cli`]( ## Sources - Gemini CLI repo: +- Gemini CLI hooks (`.gemini/settings.json` → `hooks`): -Last verified: 2026-05-23 +Last verified: 2026-05-24 diff --git a/skills/capabilities-manager/SKILL.md b/skills/capabilities-manager/SKILL.md index 1af1414f..e0fa842a 100644 --- a/skills/capabilities-manager/SKILL.md +++ b/skills/capabilities-manager/SKILL.md @@ -26,6 +26,7 @@ Use this skill when: - User needs to define provider-specific rules (Cursor rules, Copilot rules, etc.) - User wants to add remote plugins that bundle skills, servers, and tools - User needs to configure sub-agents with scoped tools and instructions +- User wants to install lifecycle hooks (e.g. Claude Code `PreToolUse`, Cursor `beforeShellExecution`) - User wants to manage the remote source cache or bypass it ## Core Concepts @@ -42,7 +43,8 @@ The `capabilities.yaml` (or `capabilities.json`) file defines everything an agen 6. **agents**: (Optional) Manages `AGENTS.md` / `CLAUDE.md` content in the project root 7. **subagents**: (Optional) Named sub-agent configurations with filtered tool access 8. **rules**: (Optional) Provider rules installed into each provider's rules directory or instructions file -9. **plugins**: (Optional) Remote plugin packages that bundle skills, servers, and tools +9. **hooks**: (Optional) Lifecycle hooks installed into each provider's hook config (canonical events translate per provider) +10. **plugins**: (Optional) Remote plugin packages that bundle skills, servers, and tools ### Skills vs Tools @@ -86,6 +88,7 @@ Only properties that are present are applied. If a blocked phrase is detected du - **Agents**: Optional `base` (ref or type+def) and `additional` list (inline, remote, github, gitlab snippets). Managed files: `AGENTS.md` always; `CLAUDE.md` when a Claude provider is present. - **Subagents**: Named sub-agent configurations with filtered tool access, per-provider MCP endpoints and agent files. - **Rules**: Types `inline`, `remote`, `github`, `gitlab`. Each has `id`, optional `providers`, `appliesTo` (glob), `alwaysApply`, `description`. Installed as files (Cursor `.cursor/rules/`) or folded into instructions files. +- **Hooks**: A canonical event name (e.g. `beforeShell`, `afterFileEdit`, `sessionStart`) plus a `command` (or `prompt`) and optional `source` (inline / remote / github / gitlab / local). capa translates each hook to the provider-specific event name and edits the provider's hooks config (`.claude/settings.json`, `.cursor/hooks.json`, `.codex/config.toml`, `.gemini/settings.json`) with `name: capa:` tags so it can update or remove its own entries without touching user-authored ones. - **Plugins**: Remote packages (`type: github` or `type: gitlab`, with `def.repo` + optional `def.subpath`) that bundle skills, servers, and tools from a provider manifest. **Full schema and YAML examples**: See `references/capabilities-schema.md`. diff --git a/skills/capabilities-manager/references/capabilities-schema.md b/skills/capabilities-manager/references/capabilities-schema.md index abbdbbe6..cf18ac71 100644 --- a/skills/capabilities-manager/references/capabilities-schema.md +++ b/skills/capabilities-manager/references/capabilities-schema.md @@ -210,6 +210,71 @@ rules: `capa clean` removes all capa-installed rules. Rules can be scoped per-provider and support the same source types as skills. +## Hooks Section + +Lifecycle hooks installed into each provider's hook configuration. The schema is provider-agnostic — capa translates each hook to the provider-specific event name and entry shape using its provider registry. + +**Fields:** +- `id` (required): Stable identifier. Used as a name tag (`capa:`) so capa can surgically update or remove its own entries without disturbing user-authored ones. +- `description` (optional): Human-readable summary; surfaced in the local web UI. +- `on` (required): Either a canonical event name or a `:` override. + - **Canonical events**: `sessionStart`, `sessionEnd`, `userPromptSubmit`, `beforeTool`, `afterTool`, `afterToolFailure`, `beforeShell`, `afterShell`, `beforeFileRead`, `afterFileEdit`, `beforeMcpCall`, `afterMcpCall`, `subagentStart`, `subagentStop`, `preCompact`, `stop`. + - **Provider-scoped events** (e.g. `cursor:beforeShellExecution`) target a single provider verbatim and are skipped for everyone else. +- `type` (optional): `command` (default — shell command) or `prompt` (text injected into the model when supported). +- `command` / `prompt` (one of, when no `source`): Inline body for command/prompt-type hooks. +- `matcher` (optional): Provider-specific glob/pattern (e.g. Claude `Bash`, Cursor `rm -rf *`). +- `timeout` (optional): Per-hook timeout in seconds; provider may clamp. +- `failClosed` (optional): When true, hook failure aborts the action (Cursor only today). +- `sequential` (optional): Run hooks one-at-a-time instead of in parallel. +- `providers` (optional): Restrict installation to these provider ids; defaults to all active providers. +- `source` (optional): Pull the body from elsewhere instead of inlining `command` / `prompt`. Mirrors the Skill / Rule source grammar: + - `{ type: inline, content }` + - `{ type: remote, url }` + - `{ type: github, def: { repo: 'owner/repo::scripts/before.sh' } }` (or `@basename` form) + - `{ type: gitlab, def: { repo: 'group/repo::scripts/before.sh' } }` + - `{ type: local, path: ./scripts/before.sh }` — relative to the capabilities file + - `executable` (default `true`) controls whether capa chmods the materialised file. + +When `source` is set, the resolved body is materialised under `~/.capa/hooks//` (NOT inside the project) and the provider entry references that absolute path. + +```yaml +hooks: + # Canonical event with an inline command — installed for every active + # provider that maps `beforeShell`. + - id: audit-shell + description: Append shell commands to ~/.capa/audit.log + on: beforeShell + command: 'echo "$(date) $TOOL_INPUT" >> ~/.capa/audit.log' + timeout: 5 + + # Provider-scoped event — only Cursor receives this entry. + - id: block-rm-rf + on: cursor:beforeShellExecution + matcher: 'rm -rf *' + failClosed: true + command: 'echo "blocked" && exit 1' + + # Source pulled from a pinned GitHub path; capa pins the resolved SHA in + # capabilities.lock under the new `hooks:` section. + - id: lint-staged + on: afterFileEdit + source: + type: github + def: + repo: acme/dev-toolkit::hooks/lint-staged.sh:v2.0.0 +``` + +Per provider, hook entries land in: + +| Provider | Config | Format | Wrapper | +| --- | --- | --- | --- | +| `claude-code` | `.claude/settings.json` → `hooks` | JSON map keyed by event → `[{ matcher, hooks: [...] }]` | `name: capa:` | +| `cursor` | `.cursor/hooks.json` (standalone) | `{ version: 1, hooks: { ... } }` envelope | `name: capa:` | +| `codex` | `.codex/config.toml` → `[hooks]` | TOML tables | `id: ` | +| `gemini-cli` | `.gemini/settings.json` → `hooks` | JSON map (claude-style) | `name: capa:` | + +Providers without a hooks integration (most of the rest of the registry) emit a warning and skip — `capa install` never fails on unsupported hooks. `capa clean` and `capa install` (via the `prune-orphan-hooks` task) keep their own entries in sync without disturbing entries you authored by hand. + ## Plugins Section Remote plugin packages that bundle skills, servers, and tools from a provider manifest. Plugin tools and skills are NOT auto-exposed — declare the tools you want under the top-level `tools` section and the skills you want under `skills` with `type: plugin`. diff --git a/skills/capabilities-manager/references/commands.md b/skills/capabilities-manager/references/commands.md index 8153bae8..10fa5737 100644 --- a/skills/capabilities-manager/references/commands.md +++ b/skills/capabilities-manager/references/commands.md @@ -122,7 +122,7 @@ The `--plugin` and `--skill` flags are mutually exclusive. When a source matches capa clean ``` -Removes all skill directories and MCP client configurations that were installed by CAPA. If the capabilities file has an `agents` section, also removes all capa-managed blocks from `AGENTS.md` and `CLAUDE.md` (each file is deleted if it becomes entirely empty after cleaning). +Removes all skill directories and MCP client configurations that were installed by CAPA. If the capabilities file has an `agents` section, also removes all capa-managed blocks from `AGENTS.md` and `CLAUDE.md` (each file is deleted if it becomes entirely empty after cleaning). Capa-installed hook entries are stripped from each provider's hooks config (entries you authored by hand are preserved) and any materialised hook scripts under `~/.capa/hooks//` are deleted. **When to use**: Cleaning up before reinstalling or removing CAPA-managed capabilities. diff --git a/src/cli/commands/clean.ts b/src/cli/commands/clean.ts index e6535266..dc592b98 100644 --- a/src/cli/commands/clean.ts +++ b/src/cli/commands/clean.ts @@ -6,6 +6,7 @@ import { parseCapabilitiesFile } from '../../shared/capabilities'; import { unregisterMCPServer, unregisterSubAgentMCPServer } from '../utils/mcp-client-manager'; import { cleanAgentsFile, removeSubAgentInstructions } from '../utils/agents-file'; import { cleanRules } from '../utils/rules-installer'; +import { cleanHooks } from '../utils/hooks-installer'; import { getLockfilePath } from '../../shared/lockfile'; import { resolveProvidersForClean } from '../../shared/providers/resolve'; import { header, footer, info, warn, error, runTasks } from '../ui'; @@ -94,6 +95,17 @@ export async function cleanCommand(): Promise { cleanRules(projectPath, providers, ruleIds); }, }, + { + title: 'Clean hooks', + enabled: () => db.getManagedHooks(projectId).length > 0, + task: async (_, task) => { + const { removed, warnings } = cleanHooks(projectPath, projectId, db); + for (const w of warnings) deferredErrors.push(w); + task.title = removed > 0 + ? `Removed ${removed} hook entr${removed === 1 ? 'y' : 'ies'}` + : 'No hooks to clean'; + }, + }, { title: 'Remove lockfile', task: async () => { diff --git a/src/cli/commands/install-tasks/index.ts b/src/cli/commands/install-tasks/index.ts index 95d3a8f3..be8b5659 100644 --- a/src/cli/commands/install-tasks/index.ts +++ b/src/cli/commands/install-tasks/index.ts @@ -15,6 +15,8 @@ import { installRulesTask } from './install-rules'; import { configureToolsTask } from './configure-tools'; import { registerMcpServerTask } from './register-mcp-server'; import { installSubagentsTask } from './install-subagents'; +import { pruneOrphanHooksTask } from './prune-orphan-hooks'; +import { installHooksTask } from './install-hooks'; import { openCredentialSetupTask } from './open-credential-setup'; export type { InstallCtx, InstallOptions, GetRepoSnapshotFn, SkillInstallOutcome } from './context'; @@ -39,6 +41,8 @@ export function buildInstallTasks(reqCmds?: RequiredCommand[]): Task configureToolsTask(), registerMcpServerTask(), installSubagentsTask(), + pruneOrphanHooksTask(), + installHooksTask(), openCredentialSetupTask(), ); diff --git a/src/cli/commands/install-tasks/install-hooks.ts b/src/cli/commands/install-tasks/install-hooks.ts new file mode 100644 index 00000000..25ef7342 --- /dev/null +++ b/src/cli/commands/install-tasks/install-hooks.ts @@ -0,0 +1,56 @@ +import type { Task } from '../../ui'; +import { createAuthenticatedFetch, AuthenticatedFetch } from '../../../shared/authenticated-fetch'; +import { installHooks } from '../../utils/hooks-installer'; +import { validateHooks } from '../../../shared/hooks-validate'; +import type { CachePlatform } from '../../../shared/cache'; +import type { InstallCtx } from './context'; +import { getRepoSnapshot } from './helpers/repo-snapshot'; + +export function installHooksTask(): Task { + return { + title: 'Installing hooks', + enabled: (ctx) => (ctx.capabilitiesToUse.hooks ?? []).length > 0, + task: async (ctx, task) => { + const rawHooks = ctx.capabilitiesToUse.hooks ?? []; + const { valid, issues } = validateHooks(rawHooks as unknown[]); + for (const issue of issues) { + const prefix = issue.hookId ? `Hook "${issue.hookId}": ` : 'Hook: '; + ctx.warnings.push(`${prefix}${issue.message} (skipping)`); + } + if (valid.length === 0) { + task.title = 'No valid hooks to install'; + return; + } + + const providers = ctx.capabilitiesToUse.providers ?? ctx.resolvedProviders; + const repoFetchAuth = createAuthenticatedFetch(ctx.db); + task.output = `${valid.length} hook${valid.length === 1 ? '' : 's'} → ${providers.length} provider${providers.length === 1 ? '' : 's'}`; + + try { + const { installed, warnings } = await installHooks({ + projectPath: ctx.projectPath, + projectId: ctx.projectId, + capabilitiesFilePath: ctx.capabilitiesFile.path, + hooks: valid, + providers, + db: ctx.db, + authFetch: repoFetchAuth, + getRepoSnapshot: (platform: CachePlatform, repoPath: string, auth: AuthenticatedFetch, opts: any) => + getRepoSnapshot(platform, repoPath, auth, opts), + noCache: ctx.noCache, + lockBuilder: ctx.lockBuilder, + }); + for (const w of warnings) ctx.warnings.push(w); + ctx.added += installed; + task.title = installed > 0 + ? `Installed ${installed} hook entr${installed === 1 ? 'y' : 'ies'}` + : 'Hooks up to date'; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + // Warn-but-never-fail: the rest of `capa install` should still finish. + ctx.warnings.push(`Failed to install hooks: ${message}`); + task.title = 'Hooks install reported warnings'; + } + }, + }; +} diff --git a/src/cli/commands/install-tasks/prune-orphan-hooks.ts b/src/cli/commands/install-tasks/prune-orphan-hooks.ts new file mode 100644 index 00000000..4bf222fb --- /dev/null +++ b/src/cli/commands/install-tasks/prune-orphan-hooks.ts @@ -0,0 +1,42 @@ +import type { Task } from '../../ui'; +import { pruneOrphanHooks } from '../../utils/hooks-installer'; +import { validateHooks } from '../../../shared/hooks-validate'; +import type { InstallCtx } from './context'; + +/** + * Drop any `managed_hooks` entries whose hook is no longer declared in + * `capabilities.hooks` or whose provider is no longer in the active set. + * + * Runs *before* `install-hooks` so installs always converge on the + * requested state — a hook moved from `cursor` to `claude-code` results + * in a removal on cursor and an install on claude-code in the same run. + */ +export function pruneOrphanHooksTask(): Task { + return { + title: 'Pruning orphan hooks', + enabled: (ctx) => (ctx.capabilitiesToUse.providers ?? ctx.resolvedProviders).length > 0, + task: async (ctx) => { + const providers = ctx.capabilitiesToUse.providers ?? ctx.resolvedProviders; + const rawHooks = ctx.capabilitiesToUse.hooks ?? []; + // Validate at this point too so an invalid hook doesn't make the + // prune think it's still desired (and skip the orphan). + const { valid: desiredHooks } = validateHooks(rawHooks as unknown[]); + try { + const { removed, warnings } = pruneOrphanHooks( + ctx.projectPath, + ctx.projectId, + desiredHooks, + providers, + ctx.db, + ); + for (const w of warnings) ctx.warnings.push(w); + if (removed > 0) { + ctx.added += removed; + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + ctx.warnings.push(`Failed to prune orphan hooks: ${message}`); + } + }, + }; +} diff --git a/src/cli/commands/install-tasks/write-lockfile.ts b/src/cli/commands/install-tasks/write-lockfile.ts index 267371bd..4184302c 100644 --- a/src/cli/commands/install-tasks/write-lockfile.ts +++ b/src/cli/commands/install-tasks/write-lockfile.ts @@ -16,9 +16,24 @@ export function writeLockfileTask(): Task { const pluginIdsForLock = new Set( (ctx.capabilitiesToUse.resolvedPlugins ?? []).map((p) => p.id), ); - ctx.lockBuilder.pruneToIds(skillIdsForLock, pluginIdsForLock); + // Hooks with a remote/repo source are pinned in the lockfile; inline / + // local hooks travel inside the capabilities file or workspace and + // don't need pinning. + const hookIdsForLock = new Set( + (ctx.capabilitiesToUse.hooks ?? []) + .filter((h) => { + const t = (h as { source?: { type?: string } }).source?.type; + return t === 'github' || t === 'gitlab' || t === 'remote'; + }) + .map((h) => (h as { id: string }).id), + ); + ctx.lockBuilder.pruneToIds(skillIdsForLock, pluginIdsForLock, hookIdsForLock); const lockfileToSave = ctx.lockBuilder.build(); - if (lockfileToSave.skills.length === 0 && lockfileToSave.plugins.length === 0) { + if ( + lockfileToSave.skills.length === 0 && + lockfileToSave.plugins.length === 0 && + lockfileToSave.hooks.length === 0 + ) { try { const lockPath = join(ctx.projectPath, 'capabilities.lock'); if (existsSync(lockPath)) { diff --git a/src/cli/utils/__tests__/hooks-installer.test.ts b/src/cli/utils/__tests__/hooks-installer.test.ts new file mode 100644 index 00000000..2abc0d1f --- /dev/null +++ b/src/cli/utils/__tests__/hooks-installer.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { CapaDatabase } from '../../../db/database'; +import { installHooks, pruneOrphanHooks, cleanHooks } from '../hooks-installer'; +import { LockfileBuilder } from '../../../shared/lockfile'; +import type { Hook } from '../../../types/hooks'; +import type { AuthenticatedFetch } from '../../../shared/authenticated-fetch'; +import type { GetSnapshotResult } from '../../../shared/cache'; + +function makeAuthFetch(): AuthenticatedFetch { + return { + fetch: async () => new Response('echo from-remote\n', { status: 200, headers: { 'Content-Type': 'text/plain' } }), + hasAuth: () => false, + } as unknown as AuthenticatedFetch; +} + +const stubGetRepoSnapshot = async (): Promise => { + throw new Error('repo snapshot fetcher should not be invoked in inline-only tests'); +}; + +describe('hooks-installer (claude inline-config)', () => { + let tempDir: string; + let projectPath: string; + let db: CapaDatabase; + let dbPath: string; + const projectId = 'test-project'; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'capa-hooks-installer-')); + projectPath = join(tempDir, 'project'); + require('fs').mkdirSync(projectPath, { recursive: true }); + dbPath = join(tempDir, 'capa.db'); + db = new CapaDatabase(dbPath); + db.upsertProject({ id: projectId, path: projectPath }); + }); + + afterEach(() => { + db.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('writes a claude entry into .claude/settings.json and records it in managed_hooks', async () => { + const hooks: Hook[] = [ + { + id: 'audit-bash', + on: 'beforeShell', + type: 'command', + command: 'echo running', + }, + ]; + + const result = await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks, + providers: ['claude-code'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + expect(result.installed).toBe(1); + expect(result.warnings).toEqual([]); + + const settingsPath = join(projectPath, '.claude', 'settings.json'); + expect(existsSync(settingsPath)).toBe(true); + const config = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record; + const events = config.hooks.PreToolUse; + expect(Array.isArray(events)).toBe(true); + expect(events.length).toBe(1); + expect(events[0].matcher).toBe('Bash'); + expect(events[0].hooks[0].command).toBe('echo running'); + expect(events[0].hooks[0].name).toBe('capa:audit-bash'); + + const rows = db.getManagedHooks(projectId); + expect(rows).toHaveLength(1); + expect(rows[0].providerId).toBe('claude-code'); + expect(rows[0].hookId).toBe('audit-bash'); + expect(JSON.parse(rows[0].locator)).toEqual(['PreToolUse', 0, 'hooks', 0]); + }); + + it('preserves user-authored entries when capa upserts its own', async () => { + const settingsPath = join(projectPath, '.claude', 'settings.json'); + require('fs').mkdirSync(join(projectPath, '.claude'), { recursive: true }); + writeFileSync( + settingsPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'user-script' }] }, + ], + }, + permissions: ['*'], + }, + null, + 2, + ), + ); + + await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [{ id: 'capa-bash', on: 'beforeShell', command: 'echo capa' }], + providers: ['claude-code'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + + const config = JSON.parse(readFileSync(settingsPath, 'utf-8')) as any; + expect(config.permissions).toEqual(['*']); + const bashEvents = config.hooks.PreToolUse[0].hooks; + expect(bashEvents).toHaveLength(2); + const commands = bashEvents.map((h: any) => h.command); + expect(commands).toContain('user-script'); + expect(commands).toContain('echo capa'); + }); + + it('warns and skips when a provider has no hook integration', async () => { + const result = await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [{ id: 'whatever', on: 'sessionStart', command: 'echo' }], + providers: ['amp'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + expect(result.installed).toBe(0); + expect(result.warnings.some((w) => w.includes('does not support project-level hooks'))).toBe(true); + }); + + it('warns when canonical event has no mapping for provider', async () => { + const result = await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [{ id: 'pc', on: 'preCompact', command: 'echo pre-compact' }], + providers: ['cursor'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + expect(result.installed).toBe(0); + expect(result.warnings.some((w) => /no mapping/.test(w))).toBe(true); + }); + + it('records remote sources in the lockfile builder', async () => { + const lockBuilder = new LockfileBuilder(null); + await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [ + { + id: 'remote-hook', + on: 'beforeShell', + source: { type: 'remote', url: 'https://example.com/hook.sh' }, + }, + ], + providers: ['claude-code'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + lockBuilder, + }); + + const lock = lockBuilder.build(); + expect(lock.hooks).toHaveLength(1); + expect(lock.hooks[0].id).toBe('remote-hook'); + expect(lock.hooks[0].source).toBe('remote'); + expect(lock.hooks[0].url).toBe('https://example.com/hook.sh'); + expect(lock.hooks[0].bodySha256).toMatch(/^[a-f0-9]{64}$/); + }); +}); + +describe('hooks-installer (cursor standalone)', () => { + let tempDir: string; + let projectPath: string; + let db: CapaDatabase; + const projectId = 'test-project'; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'capa-hooks-installer-cur-')); + projectPath = join(tempDir, 'project'); + require('fs').mkdirSync(projectPath, { recursive: true }); + db = new CapaDatabase(join(tempDir, 'capa.db')); + db.upsertProject({ id: projectId, path: projectPath }); + }); + + afterEach(() => { + db.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('writes a cursor envelope and replaces capa-tagged entries on re-install', async () => { + const installOnce = (command: string) => + installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [{ id: 'shell-audit', on: 'beforeShell', command }], + providers: ['cursor'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + + await installOnce('echo first'); + await installOnce('echo second'); + + const path = join(projectPath, '.cursor', 'hooks.json'); + expect(existsSync(path)).toBe(true); + const config = JSON.parse(readFileSync(path, 'utf-8')) as any; + expect(config.version).toBe(1); + expect(config.hooks.beforeShellExecution).toHaveLength(1); + expect(config.hooks.beforeShellExecution[0].command).toBe('echo second'); + + const rows = db.getManagedHooks(projectId); + expect(rows).toHaveLength(1); + }); +}); + +describe('hooks-installer — pruneOrphanHooks / cleanHooks', () => { + let tempDir: string; + let projectPath: string; + let db: CapaDatabase; + const projectId = 'test-project'; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'capa-hooks-prune-')); + projectPath = join(tempDir, 'project'); + require('fs').mkdirSync(projectPath, { recursive: true }); + db = new CapaDatabase(join(tempDir, 'capa.db')); + db.upsertProject({ id: projectId, path: projectPath }); + }); + + afterEach(() => { + db.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('drops entries no longer requested but keeps current ones', async () => { + await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [ + { id: 'a', on: 'beforeShell', command: 'echo a' }, + { id: 'b', on: 'beforeShell', command: 'echo b' }, + ], + providers: ['claude-code'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + expect(db.getManagedHooks(projectId)).toHaveLength(2); + + const result = pruneOrphanHooks( + projectPath, + projectId, + [{ id: 'a', on: 'beforeShell', command: 'echo a' }], + ['claude-code'], + db, + ); + expect(result.removed).toBe(1); + const remaining = db.getManagedHooks(projectId); + expect(remaining).toHaveLength(1); + expect(remaining[0].hookId).toBe('a'); + + const settings = JSON.parse( + readFileSync(join(projectPath, '.claude', 'settings.json'), 'utf-8'), + ) as any; + const bashHooks = settings.hooks.PreToolUse[0].hooks; + expect(bashHooks).toHaveLength(1); + expect(bashHooks[0].command).toBe('echo a'); + }); + + it('cleanHooks wipes every entry and config keys for the project', async () => { + await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [{ id: 'a', on: 'beforeShell', command: 'echo a' }], + providers: ['claude-code'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + + const { removed } = cleanHooks(projectPath, projectId, db); + expect(removed).toBe(1); + expect(db.getManagedHooks(projectId)).toEqual([]); + const settings = JSON.parse( + readFileSync(join(projectPath, '.claude', 'settings.json'), 'utf-8'), + ) as any; + expect(settings.hooks).toBeUndefined(); + }); +}); diff --git a/src/cli/utils/hooks-installer.ts b/src/cli/utils/hooks-installer.ts new file mode 100644 index 00000000..a8869108 --- /dev/null +++ b/src/cli/utils/hooks-installer.ts @@ -0,0 +1,583 @@ +/** + * Install / prune lifecycle hooks across providers. + * + * `installHooks`: + * 1. Resolve each hook's body (inline / remote / github / gitlab / local) + * and materialise `command`-type bodies under + * `~/.capa/hooks//` (chmod +x by default). + * 2. For every `(provider, hook)` pair where the provider supports the + * requested event, build the shape-specific entry, upsert it into the + * provider's config file, and record the entry in `db.managed_hooks`. + * 3. Surface warnings (rather than throwing) for unsupported providers, + * missing event mappings, write failures, etc. + * + * `pruneOrphanHooks`: + * • Walks `db.managed_hooks` and removes every entry whose provider / + * hook combination is no longer requested by the current capabilities + * file. Stale config entries are removed surgically using the stored + * locator; the row is dropped from the DB. + * + * `cleanHooks`: + * • Used by `capa clean` to remove every capa-installed hook entry for + * a project regardless of the current capabilities file. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from 'fs'; +import { dirname, join, resolve as resolvePath } from 'path'; +import { createHash } from 'crypto'; +import type { CapaDatabase } from '../../db/database'; +import type { ManagedHookRow } from '../../db/managed-hooks'; +import { getProvider } from '../../shared/providers'; +import { + buildHookEntry, + getHookConfigPath, + removeHookEntryAt, + upsertHookEntry, + type HookLocator, +} from '../../shared/providers/hook-handlers'; +import type { HooksIntegration, ProviderEventMapping } from '../../types/providers'; +import type { CanonicalHookEvent, Hook, HookSource } from '../../types/hooks'; +import type { LockHookEntry } from '../../types/lockfile'; +import type { LockfileBuilder } from '../../shared/lockfile'; +import type { AuthenticatedFetch } from '../../shared/authenticated-fetch'; +import type { CachePlatform, GetSnapshotResult } from '../../shared/cache'; +import { fetchRepoFile, fetchTextFile } from '../../shared/repo-file'; +import { getHookScriptDir } from '../../shared/config'; +import { readTomlFile, writeTomlFile } from '../../shared/toml-io'; +import { taskLog } from '../ui'; + +// --------------------------------------------------------------------------- +// Public surface +// --------------------------------------------------------------------------- + +export type SnapshotResolver = ( + platform: CachePlatform, + repoPath: string, + authFetch: AuthenticatedFetch, + opts?: { version?: string; ref?: string; pinnedSha?: string; noCache?: boolean }, +) => Promise; + +export interface InstallHooksOptions { + projectPath: string; + projectId: string; + /** Path of the capabilities file (used to resolve `local` source paths). */ + capabilitiesFilePath: string; + hooks: Hook[]; + providers: string[]; + db: CapaDatabase; + authFetch: AuthenticatedFetch; + getRepoSnapshot: SnapshotResolver; + noCache?: boolean; + /** When set, hook lockfile entries are recorded for `github`/`gitlab`/`remote` sources. */ + lockBuilder?: LockfileBuilder; +} + +export interface InstallHooksResult { + /** Number of (provider, hook) entries successfully installed. */ + installed: number; + /** Non-fatal warnings collected during install. */ + warnings: string[]; +} + +/** + * Top-level orchestrator. See module doc-comment for the contract. + */ +export async function installHooks(opts: InstallHooksOptions): Promise { + const { projectPath, projectId, hooks, providers, db } = opts; + const warnings: string[] = []; + let installed = 0; + + if (hooks.length === 0) return { installed, warnings }; + + // Step 1: resolve each hook's body once. Bodies are reused across providers. + const resolvedBodies = new Map(); + for (const hook of hooks) { + try { + const body = await resolveHookBody(hook, opts); + resolvedBodies.set(hook.id, body); + + // Lockfile pinning for remote / repo-backed sources only. + if (opts.lockBuilder && hook.source && body.lockEntry) { + opts.lockBuilder.upsertHook(body.lockEntry); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Hook "${hook.id}": failed to resolve body — ${msg}`); + } + } + + // Step 2: materialise command bodies under ~/.capa/hooks//. + const hookScriptPaths = new Map(); + for (const hook of hooks) { + const body = resolvedBodies.get(hook.id); + if (!body) continue; + if ((hook.type ?? 'command') !== 'command') continue; + + // If the user inlined `command:` and provided no source, the entry value + // is the literal command string. We don't materialise a script in that + // case — the provider runs it directly. + if (!hook.source) { + hookScriptPaths.set(hook.id, hook.command ?? ''); + continue; + } + + try { + const scriptPath = materialiseHookScript({ + projectId, + hookId: hook.id, + body: body.text, + executable: hook.source.executable !== false, + }); + hookScriptPaths.set(hook.id, scriptPath); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Hook "${hook.id}": failed to write script — ${msg}`); + } + } + + // Step 3: per-provider installation. + for (const providerId of providers) { + const provider = getProvider(providerId); + if (!provider) { + warnings.push(`Hooks: unknown provider "${providerId}" — skipping`); + continue; + } + if (!provider.hooks) { + warnings.push(`Hooks: ${provider.displayName} does not support project-level hooks (skipping)`); + continue; + } + for (const hook of hooks) { + const targets = scopeHookForProvider(hook, providerId); + if (!targets) continue; + + const body = resolvedBodies.get(hook.id); + if (!body) continue; + + const eventName = resolveProviderEventName(provider.hooks, hook, providerId); + if (!eventName) { + warnings.push( + `Hook "${hook.id}": ${provider.displayName} has no mapping for "${hook.on}" — skipping`, + ); + continue; + } + const mapping = pickMapping(provider.hooks, hook.on, providerId); + if (!mapping) continue; + + try { + const runReference = (hook.type ?? 'command') === 'prompt' + ? (hook.prompt ?? body.text) + : hookScriptPaths.get(hook.id) || hook.command || ''; + + if (!runReference) { + warnings.push(`Hook "${hook.id}": empty run reference for ${provider.displayName} — skipping`); + continue; + } + + const out = buildHookEntry(provider.hooks, { + hook, + mapping, + runReference, + }); + + const { configPath, locator } = applyHookEntryToConfig({ + projectPath, + integration: provider.hooks, + output: out, + }); + + const scriptPath = body.materialised ? hookScriptPaths.get(hook.id) ?? null : null; + db.upsertManagedHook({ + projectId, + providerId, + hookId: hook.id, + configPath, + locator: JSON.stringify(locator), + scriptPath, + }); + installed++; + taskLog(` ✓ Installed hook "${hook.id}" → ${provider.displayName} (${eventName})`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Hook "${hook.id}" → ${provider.displayName}: ${msg}`); + } + } + } + + return { installed, warnings }; +} + +export interface PruneOrphanHooksResult { + removed: number; + warnings: string[]; +} + +/** + * Bring `db.managed_hooks` in sync with the current capabilities file by + * removing entries that are no longer requested (per provider). + */ +export function pruneOrphanHooks( + projectPath: string, + projectId: string, + desiredHooks: Hook[], + desiredProviders: string[], + db: CapaDatabase, +): PruneOrphanHooksResult { + const warnings: string[] = []; + let removed = 0; + + const desiredByProvider = new Map>(); + for (const providerId of desiredProviders) { + const ids = new Set(); + for (const h of desiredHooks) { + const targets = scopeHookForProvider(h, providerId); + if (targets) ids.add(h.id); + } + desiredByProvider.set(providerId, ids); + } + + const existing = db.getManagedHooks(projectId); + for (const row of existing) { + const desired = desiredByProvider.get(row.providerId); + // Provider still active and hook still declared → keep. + if (desired && desired.has(row.hookId)) continue; + + try { + removeManagedHookEntry(projectPath, row); + removed++; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Hook "${row.hookId}" on "${row.providerId}": prune failed — ${msg}`); + } finally { + db.removeManagedHook(row.projectId, row.providerId, row.hookId); + } + } + return { removed, warnings }; +} + +/** + * Remove every capa-managed hook entry for `projectId`. Used by `capa clean`. + */ +export function cleanHooks(projectPath: string, projectId: string, db: CapaDatabase): { removed: number; warnings: string[] } { + const warnings: string[] = []; + let removed = 0; + const rows = db.getManagedHooks(projectId); + for (const row of rows) { + try { + removeManagedHookEntry(projectPath, row); + removed++; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Hook "${row.hookId}" on "${row.providerId}": clean failed — ${msg}`); + } + } + db.clearManagedHooks(projectId); + return { removed, warnings }; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +interface ResolvedHookBody { + /** The resolved script body or prompt text. */ + text: string; + /** True when the body was fetched from outside (and we need a script path). */ + materialised: boolean; + /** Optional lockfile pin for remote / repo sources. */ + lockEntry?: LockHookEntry; +} + +async function resolveHookBody(hook: Hook, opts: InstallHooksOptions): Promise { + if (!hook.source) { + return { text: hook.command ?? hook.prompt ?? '', materialised: false }; + } + const source = hook.source; + let text = ''; + let lockEntry: LockHookEntry | undefined; + + switch (source.type) { + case 'inline': { + text = source.content ?? ''; + break; + } + case 'remote': { + if (!source.url) throw new Error('source.type=remote requires url'); + text = await fetchTextFile(source.url, { + authFetch: opts.authFetch, + sourceLabel: `hook "${hook.id}"`, + }); + lockEntry = { + id: hook.id, + source: 'remote', + repo: null, + url: source.url, + requestedVersion: null, + requestedRef: null, + resolvedRef: null, + resolvedVersion: null, + bodySha256: sha256(text), + }; + break; + } + case 'github': + case 'gitlab': { + if (!source.def?.repo) throw new Error(`source.type=${source.type} requires def.repo`); + const result = await fetchRepoFile(source.type, source.def.repo, opts.getRepoSnapshot, opts.authFetch, { + noCache: opts.noCache, + }); + text = result.content; + lockEntry = { + id: hook.id, + source: source.type, + repo: result.parsed.ownerRepo, + url: null, + requestedVersion: result.parsed.version ?? null, + requestedRef: result.parsed.sha ?? null, + resolvedRef: result.resolvedSha, + resolvedVersion: result.resolvedVersion ?? null, + bodySha256: sha256(text), + }; + break; + } + case 'local': { + if (!source.path) throw new Error('source.type=local requires path'); + const baseDir = dirname(opts.capabilitiesFilePath); + const fullPath = resolvePath(baseDir, source.path); + if (!existsSync(fullPath)) throw new Error(`local file does not exist: ${fullPath}`); + text = readFileSync(fullPath, 'utf-8'); + break; + } + } + + return { text, materialised: true, lockEntry }; +} + +/** + * Write the resolved hook body to `~/.capa/hooks//` and + * (optionally) chmod +x. Returns the absolute script path. + */ +function materialiseHookScript(input: { + projectId: string; + hookId: string; + body: string; + executable: boolean; +}): string { + const dir = getHookScriptDir(input.projectId); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const file = join(dir, input.hookId); + writeFileSync(file, input.body, 'utf-8'); + if (input.executable) { + try { + chmodSync(file, 0o755); + } catch { + // chmod is best-effort on platforms that don't support it. + } + } + return file; +} + +interface ApplyEntryArgs { + projectPath: string; + integration: HooksIntegration; + output: ReturnType; +} + +/** + * Read-modify-write the provider's hook config file with the given entry. + * Handles JSON / TOML, `cursor-v1` envelope, and inline-config vs + * standalone storage. + */ +function applyHookEntryToConfig(args: ApplyEntryArgs): { configPath: string; locator: HookLocator } { + const { projectPath, integration, output } = args; + const configPath = getHookConfigPath(integration, projectPath); + + if (integration.storage.kind === 'inline-config' && integration.storage.format === 'toml') { + const config = readTomlFile(configPath); + const root = ensureObject(config, integration.storage.hooksKey); + const locator = upsertHookEntry(integration, root, output); + writeTomlFile(configPath, config); + return { configPath, locator }; + } + + // JSON-based storage (standalone or inline-config). + const config = readJsonFile(configPath); + + let hooksRoot: Record; + if (integration.storage.kind === 'standalone') { + if (integration.storage.envelope === 'cursor-v1') { + if (typeof config.version !== 'number') config.version = 1; + hooksRoot = ensureObject(config, 'hooks'); + } else { + hooksRoot = config; + } + } else if (integration.storage.kind === 'inline-config') { + hooksRoot = ensureObject(config, integration.storage.hooksKey); + } else { + throw new Error(`directory-storage hooks are not yet supported (${integration.storage.kind})`); + } + + const locator = upsertHookEntry(integration, hooksRoot, output); + writeJsonFile(configPath, config); + return { configPath, locator }; +} + +/** + * Surgically delete a single (provider, hook) entry from the on-disk + * config using the locator stored in `managed_hooks`. + * + * Errors propagate to the caller — pruneOrphanHooks/cleanHooks catch them + * and surface as warnings so a malformed config never aborts the install. + */ +function removeManagedHookEntry(projectPath: string, row: ManagedHookRow): void { + const provider = getProvider(row.providerId); + if (!provider?.hooks) { + // Provider was removed from the registry — drop the script if any. + if (row.scriptPath && existsSync(row.scriptPath)) { + try { unlinkSync(row.scriptPath); } catch {} + } + return; + } + const integration = provider.hooks; + const configPath = row.configPath; + + let locator: HookLocator; + try { + locator = JSON.parse(row.locator) as HookLocator; + if (!Array.isArray(locator)) throw new Error('locator is not an array'); + } catch (err: unknown) { + throw new Error(`invalid locator JSON: ${err instanceof Error ? err.message : String(err)}`); + } + + if (!existsSync(configPath)) { + // The user already deleted the config; nothing left to remove. + if (row.scriptPath && existsSync(row.scriptPath)) { + try { unlinkSync(row.scriptPath); } catch {} + } + return; + } + + if (integration.storage.kind === 'inline-config' && integration.storage.format === 'toml') { + const config = readTomlFile(configPath); + const root = readObject(config, integration.storage.hooksKey); + if (root) { + removeHookEntryAt(integration, root, locator, row.hookId); + // If the hooks root is empty, drop it from the config too. + if (Object.keys(root).length === 0) { + delete (config as Record)[integration.storage.hooksKey]; + } + } + writeTomlFile(configPath, config); + } else { + const config = readJsonFile(configPath); + let root: Record | null; + let rootKey: string | null = null; + if (integration.storage.kind === 'standalone') { + if (integration.storage.envelope === 'cursor-v1') { + root = readObject(config, 'hooks'); + rootKey = 'hooks'; + } else { + root = config; + } + } else if (integration.storage.kind === 'inline-config') { + root = readObject(config, integration.storage.hooksKey); + rootKey = integration.storage.hooksKey; + } else { + // directory-storage hooks aren't currently installed, so prune is a no-op. + return; + } + if (root) { + removeHookEntryAt(integration, root, locator, row.hookId); + if (rootKey && Object.keys(root).length === 0) { + delete (config as Record)[rootKey]; + } + } + writeJsonFile(configPath, config); + } + + if (row.scriptPath && existsSync(row.scriptPath)) { + try { unlinkSync(row.scriptPath); } catch {} + } +} + +// --------------------------------------------------------------------------- +// Event-mapping helpers +// --------------------------------------------------------------------------- + +/** + * Returns the list of provider ids targeted by this hook's `providers` field + * (or null when the provider is not targeted). Returns an array for parity + * with possible future expansion (e.g. wildcards). + */ +function scopeHookForProvider(hook: Hook, providerId: string): string[] | null { + if (!hook.providers || hook.providers.length === 0) return [providerId]; + return hook.providers.includes(providerId) ? [providerId] : null; +} + +function pickMapping(integration: HooksIntegration, on: string, providerId: string): ProviderEventMapping | null { + // Provider-scoped event: only honor when prefix matches our provider id. + const colonIdx = on.indexOf(':'); + if (colonIdx > 0) { + const prefix = on.slice(0, colonIdx); + if (prefix.toLowerCase() !== providerId.toLowerCase()) return null; + return { event: on.slice(colonIdx + 1) }; + } + const canonical = on as CanonicalHookEvent; + return integration.eventMap[canonical] ?? null; +} + +function resolveProviderEventName( + integration: HooksIntegration, + hook: Hook, + providerId: string, +): string | null { + const m = pickMapping(integration, hook.on, providerId); + return m ? m.event : null; +} + +// --------------------------------------------------------------------------- +// Small file helpers +// --------------------------------------------------------------------------- + +function readJsonFile(path: string): Record { + if (!existsSync(path)) return {}; + try { + const raw = readFileSync(path, 'utf-8'); + if (!raw.trim()) return {}; + const parsed: unknown = JSON.parse(raw); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as Record; + } catch { + return {}; + } +} + +function writeJsonFile(path: string, data: Record): void { + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +} + +function ensureObject(obj: Record, key: string): Record { + const existing = obj[key]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + return existing as Record; + } + const next: Record = {}; + obj[key] = next; + return next; +} + +function readObject(obj: Record, key: string): Record | null { + const existing = obj[key]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + return existing as Record; + } + return null; +} + +function sha256(text: string): string { + return createHash('sha256').update(text, 'utf-8').digest('hex'); +} + +// Re-exports for tests. +export { resolveProviderEventName as _resolveProviderEventName }; diff --git a/src/db/__tests__/database.test.ts b/src/db/__tests__/database.test.ts index d1b85e99..ef9122a0 100644 --- a/src/db/__tests__/database.test.ts +++ b/src/db/__tests__/database.test.ts @@ -56,6 +56,75 @@ describe('CapaDatabase', () => { }); }); + describe('Managed hooks operations', () => { + beforeEach(() => { + db.upsertProject({ id: 'hooks-proj', path: '/hooks/path' }); + }); + + it('upserts and reads managed hook rows', () => { + db.upsertManagedHook({ + projectId: 'hooks-proj', + providerId: 'claude-code', + hookId: 'audit', + configPath: '/abs/.claude/settings.json', + locator: '["PreToolUse",0,"hooks",0]', + scriptPath: null, + }); + const rows = db.getManagedHooks('hooks-proj'); + expect(rows).toHaveLength(1); + expect(rows[0].providerId).toBe('claude-code'); + expect(rows[0].hookId).toBe('audit'); + expect(rows[0].locator).toBe('["PreToolUse",0,"hooks",0]'); + }); + + it('upsert replaces an existing (provider, hook) entry', () => { + db.upsertManagedHook({ + projectId: 'hooks-proj', + providerId: 'cursor', + hookId: 'a', + configPath: '/p1', + locator: '["x",0]', + scriptPath: '/script', + }); + db.upsertManagedHook({ + projectId: 'hooks-proj', + providerId: 'cursor', + hookId: 'a', + configPath: '/p2', + locator: '["x",1]', + scriptPath: null, + }); + const rows = db.getManagedHooks('hooks-proj'); + expect(rows).toHaveLength(1); + expect(rows[0].configPath).toBe('/p2'); + expect(rows[0].locator).toBe('["x",1]'); + expect(rows[0].scriptPath).toBeNull(); + }); + + it('removes individual entries and clears them all', () => { + db.upsertManagedHook({ + projectId: 'hooks-proj', + providerId: 'claude-code', + hookId: 'a', + configPath: '/p', + locator: '["x",0]', + scriptPath: null, + }); + db.upsertManagedHook({ + projectId: 'hooks-proj', + providerId: 'cursor', + hookId: 'b', + configPath: '/p', + locator: '["x",0]', + scriptPath: null, + }); + db.removeManagedHook('hooks-proj', 'claude-code', 'a'); + expect(db.getManagedHooks('hooks-proj')).toHaveLength(1); + db.clearManagedHooks('hooks-proj'); + expect(db.getManagedHooks('hooks-proj')).toEqual([]); + }); + }); + describe('Variable operations', () => { beforeEach(() => { db.upsertProject({ id: 'test-proj', path: '/test/path' }); diff --git a/src/db/database.ts b/src/db/database.ts index fcd1588b..307d9fc0 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -17,6 +17,7 @@ import { ProjectsRepo } from './projects'; import { SessionsRepo } from './sessions'; import { VariablesRepo } from './variables'; import { ManagedFilesRepo } from './managed-files'; +import { type ManagedHookRow, ManagedHooksRepo } from './managed-hooks'; import { OAuthTokensRepo } from './oauth-tokens'; import { OAuthFlowStateRepo } from './oauth-flow-state'; import { GitIntegrationsRepo } from './git-integrations'; @@ -31,6 +32,7 @@ export class CapaDatabase { private sessions: SessionsRepo; private variables: VariablesRepo; private managedFiles: ManagedFilesRepo; + private managedHooks: ManagedHooksRepo; private oauthTokens: OAuthTokensRepo; private oauthFlowState: OAuthFlowStateRepo; private gitIntegrations: GitIntegrationsRepo; @@ -51,6 +53,7 @@ export class CapaDatabase { this.sessions = new SessionsRepo(this.db); this.variables = new VariablesRepo(this.db); this.managedFiles = new ManagedFilesRepo(this.db); + this.managedHooks = new ManagedHooksRepo(this.db); this.oauthTokens = new OAuthTokensRepo(this.db); this.oauthFlowState = new OAuthFlowStateRepo(this.db); this.gitIntegrations = new GitIntegrationsRepo(this.db); @@ -145,6 +148,23 @@ export class CapaDatabase { return this.managedFiles.clear(projectId); } + // Managed hooks operations + upsertManagedHook(input: Omit): void { + return this.managedHooks.upsert(input); + } + + getManagedHooks(projectId: string): ManagedHookRow[] { + return this.managedHooks.getAll(projectId); + } + + removeManagedHook(projectId: string, providerId: string, hookId: string): void { + return this.managedHooks.remove(projectId, providerId, hookId); + } + + clearManagedHooks(projectId: string): void { + return this.managedHooks.clear(projectId); + } + // Tool init state operations setToolInitialized(projectId: string, toolId: string, error: string | null = null): void { return this.toolInitState.setInitialized(projectId, toolId, error); diff --git a/src/db/managed-hooks.ts b/src/db/managed-hooks.ts new file mode 100644 index 00000000..cc9c4cab --- /dev/null +++ b/src/db/managed-hooks.ts @@ -0,0 +1,95 @@ +import type { Database } from "bun:sqlite"; + +export interface ManagedHookRow { + /** capa project id (matches `projects.id`). */ + projectId: string; + /** Provider id from the registry (e.g. 'cursor', 'claude-code'). */ + providerId: string; + /** Hook id from the capabilities file (matches `Hook.id`). */ + hookId: string; + /** Absolute path of the provider's hook config file capa edited. */ + configPath: string; + /** + * JSON-encoded array describing where the entry sits inside the provider's + * hooks-root. Layout is shape-specific; see + * `shared/providers/hook-handlers.ts → HookLocator`. + */ + locator: string; + /** Absolute path of the materialised script body, when capa wrote one. */ + scriptPath: string | null; + /** Created-at timestamp (ms since epoch). */ + createdAt: number; +} + +interface ManagedHookSqlRow { + project_id: string; + provider_id: string; + hook_id: string; + config_path: string; + locator: string; + script_path: string | null; + created_at: number; +} + +function fromSql(row: ManagedHookSqlRow): ManagedHookRow { + return { + projectId: row.project_id, + providerId: row.provider_id, + hookId: row.hook_id, + configPath: row.config_path, + locator: row.locator, + scriptPath: row.script_path, + createdAt: row.created_at, + }; +} + +export class ManagedHooksRepo { + constructor(private db: Database) {} + + upsert(input: Omit): void { + const now = Date.now(); + this.db.run( + `INSERT INTO managed_hooks + (project_id, provider_id, hook_id, config_path, locator, script_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, provider_id, hook_id) DO UPDATE SET + config_path = excluded.config_path, + locator = excluded.locator, + script_path = excluded.script_path`, + [ + input.projectId, + input.providerId, + input.hookId, + input.configPath, + input.locator, + input.scriptPath, + now, + ], + ); + } + + getAll(projectId: string): ManagedHookRow[] { + const rows = this.db + .query( + `SELECT project_id, provider_id, hook_id, config_path, locator, script_path, created_at + FROM managed_hooks + WHERE project_id = ? + ORDER BY provider_id, hook_id`, + ) + .all(projectId) as ManagedHookSqlRow[]; + return rows.map(fromSql); + } + + remove(projectId: string, providerId: string, hookId: string): void { + this.db.run( + `DELETE FROM managed_hooks + WHERE project_id = ? AND provider_id = ? AND hook_id = ?`, + [projectId, providerId, hookId], + ); + } + + /** Drop every entry for the project (used by `capa clean`). */ + clear(projectId: string): void { + this.db.run("DELETE FROM managed_hooks WHERE project_id = ?", [projectId]); + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index da24a7aa..12d16a77 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -159,6 +159,25 @@ export function initSchema(db: Database): void { ) `); + // Tracks individual capa-installed hook entries (one row per + // (provider, hook) tuple). `locator` is the JSON-encoded path inside + // the provider's hooks-root that the entry occupies — see + // `shared/providers/hook-handlers.ts` for the layout per shape. + db.run(` + CREATE TABLE IF NOT EXISTS managed_hooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + hook_id TEXT NOT NULL, + config_path TEXT NOT NULL, + locator TEXT NOT NULL, + script_path TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE(project_id, provider_id, hook_id) + ) + `); + // Generic key/value store for small server-wide flags that don't deserve // their own table (e.g. "have we seeded the default registries yet"). db.run(` diff --git a/src/server/index.ts b/src/server/index.ts index 08227975..82a1327d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -591,6 +591,28 @@ class CapaServer { appliesTo: r.appliesTo || [], alwaysApply: r.alwaysApply || false, })), + hooks: (capabilities.hooks || []).map(h => ({ + id: h.id, + description: h.description || null, + on: h.on, + type: h.type || 'command', + providers: h.providers || [], + matcher: h.matcher || null, + timeout: h.timeout ?? null, + failClosed: h.failClosed ?? false, + sequential: h.sequential ?? false, + sourceType: h.source?.type || null, + command: h.command ?? null, + prompt: h.prompt ?? null, + installed: this.db + .getManagedHooks(projectId) + .filter(m => m.hookId === h.id) + .map(m => ({ + providerId: m.providerId, + configPath: m.configPath, + scriptPath: m.scriptPath, + })), + })), options: capabilities.options ? { toolExposure: capabilities.options.toolExposure || null, security: capabilities.options.security ? { diff --git a/src/shared/__tests__/capabilities.test.ts b/src/shared/__tests__/capabilities.test.ts index 71b147ad..8dc5a895 100644 --- a/src/shared/__tests__/capabilities.test.ts +++ b/src/shared/__tests__/capabilities.test.ts @@ -49,13 +49,14 @@ describe('capabilities', () => { expect(result.skills).toEqual([]); }); - it('defaults missing servers, tools, plugins, rules, and subagents to empty arrays', () => { + it('defaults missing servers, tools, plugins, rules, subagents, and hooks to empty arrays', () => { const result = normalizeCapabilities({}); expect(result.servers).toEqual([]); expect(result.tools).toEqual([]); expect(result.plugins).toEqual([]); expect(result.rules).toEqual([]); expect(result.subagents).toEqual([]); + expect(result.hooks).toEqual([]); }); it('defaults missing options to an empty object', () => { @@ -109,6 +110,13 @@ describe('capabilities', () => { }, ], rules: [{ id: 'my-rule', type: 'inline', content: 'Always be helpful.' }], + hooks: [ + { + id: 'my-hook', + on: 'sessionStart', + command: 'echo hello', + }, + ], }; const result = normalizeCapabilities(capabilities); @@ -160,6 +168,7 @@ describe('capabilities', () => { plugins: [], rules: [], subagents: [], + hooks: [], }); }); @@ -175,6 +184,7 @@ describe('capabilities', () => { plugins: [], rules: [], subagents: [], + hooks: [], }); }); diff --git a/src/shared/__tests__/hooks-validate.test.ts b/src/shared/__tests__/hooks-validate.test.ts new file mode 100644 index 00000000..01cac1ba --- /dev/null +++ b/src/shared/__tests__/hooks-validate.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'bun:test'; +import { validateHooks } from '../hooks-validate'; + +describe('validateHooks', () => { + it('accepts a minimal canonical hook', () => { + const { valid, issues } = validateHooks([ + { id: 'echo', on: 'sessionStart', command: 'echo hello' }, + ]); + expect(issues).toEqual([]); + expect(valid).toHaveLength(1); + expect(valid[0].id).toBe('echo'); + expect(valid[0].type).toBe('command'); + }); + + it('accepts provider-scoped events', () => { + const { valid, issues } = validateHooks([ + { id: 'cursor-only', on: 'cursor:beforeShellExecution', command: 'echo' }, + ]); + expect(issues).toEqual([]); + expect(valid).toHaveLength(1); + }); + + it('rejects unknown events', () => { + const { valid, issues } = validateHooks([ + { id: 'broken', on: 'totallyMadeUp', command: 'echo' }, + ]); + expect(valid).toHaveLength(0); + expect(issues).toHaveLength(1); + expect(issues[0].hookId).toBe('broken'); + }); + + it('rejects entries missing id', () => { + const { valid, issues } = validateHooks([{ on: 'sessionStart', command: 'echo' }]); + expect(valid).toHaveLength(0); + expect(issues[0].message).toContain("'id'"); + }); + + it('skips duplicates after the first', () => { + const { valid, issues } = validateHooks([ + { id: 'a', on: 'sessionStart', command: 'first' }, + { id: 'a', on: 'sessionStart', command: 'second' }, + ]); + expect(valid).toHaveLength(1); + expect(valid[0].command).toBe('first'); + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain('duplicate'); + }); + + it('requires command for command-type without a source', () => { + const { valid, issues } = validateHooks([{ id: 'noop', on: 'sessionStart' }]); + expect(valid).toHaveLength(0); + expect(issues[0].message).toContain('command'); + }); + + it('accepts a github source with def.repo', () => { + const { valid, issues } = validateHooks([ + { + id: 'remote-hook', + on: 'beforeShell', + source: { type: 'github', def: { repo: 'owner/repo::scripts/before.sh' } }, + }, + ]); + expect(issues).toEqual([]); + expect(valid[0].source?.type).toBe('github'); + }); + + it('rejects github source without def.repo', () => { + const { valid, issues } = validateHooks([ + { id: 'broken', on: 'beforeShell', source: { type: 'github' } }, + ]); + expect(valid).toHaveLength(0); + expect(issues[0].message).toContain('def.repo'); + }); +}); diff --git a/src/shared/__tests__/lockfile.test.ts b/src/shared/__tests__/lockfile.test.ts index 521c6c05..aef59cb4 100644 --- a/src/shared/__tests__/lockfile.test.ts +++ b/src/shared/__tests__/lockfile.test.ts @@ -287,5 +287,37 @@ describe('lockfile', () => { const lf = b.build(); expect(lf.skills.map((s) => s.id)).toEqual(['alpha', 'zeta']); }); + + it('upserts and prunes hook entries', () => { + const b = new LockfileBuilder(null); + b.upsertHook({ + id: 'remote-hook', + source: 'remote', + repo: null, + url: 'https://example.com/x.sh', + requestedVersion: null, + requestedRef: null, + resolvedRef: null, + resolvedVersion: null, + bodySha256: 'a'.repeat(64), + }); + b.upsertHook({ + id: 'gh-hook', + source: 'github', + repo: 'owner/repo', + url: null, + requestedVersion: 'v1.0.0', + requestedRef: null, + resolvedRef: 'b'.repeat(40), + resolvedVersion: 'v1.0.0', + bodySha256: 'b'.repeat(64), + }); + let lf = b.build(); + expect(lf.hooks.map((h) => h.id)).toEqual(['gh-hook', 'remote-hook']); + + b.pruneToIds(new Set(), new Set(), new Set(['gh-hook'])); + lf = b.build(); + expect(lf.hooks.map((h) => h.id)).toEqual(['gh-hook']); + }); }); }); diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 881127a8..ae5a01f6 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -14,6 +14,7 @@ const KNOWN_CAPABILITY_KEYS = new Set([ 'agents', 'subagents', 'rules', + 'hooks', ]); const objectEntry = z.record(z.string(), z.unknown()); @@ -30,6 +31,7 @@ const capabilitiesSchema = z agents: z.record(z.string(), z.unknown()).optional(), subagents: z.preprocess((val) => val ?? [], z.array(objectEntry)), rules: z.preprocess((val) => val ?? [], z.array(objectEntry)), + hooks: z.preprocess((val) => val ?? [], z.array(objectEntry)), }) .passthrough(); diff --git a/src/shared/config.ts b/src/shared/config.ts index 4c49e211..9e611802 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -50,6 +50,15 @@ export function getManagedRegistriesDir(): string { return join(getCapaDir(), 'registries-managed'); } +/** + * Resolved hook scripts live under `~/.capa/hooks//` so + * capa never pollutes a user's project directory with generated bodies. The + * dir is created lazily by the hooks installer. + */ +export function getHookScriptDir(projectId: string): string { + return join(getCapaDir(), 'hooks', projectId); +} + export async function ensureCapaDir(): Promise { const capaDir = getCapaDir(); if (!existsSync(capaDir)) { diff --git a/src/shared/hooks-validate.ts b/src/shared/hooks-validate.ts new file mode 100644 index 00000000..49fb448c --- /dev/null +++ b/src/shared/hooks-validate.ts @@ -0,0 +1,205 @@ +/** + * Runtime validators for the `hooks:` field of `capabilities.yaml`. + * + * The top-level capabilities parser keeps `hooks` as a loose + * `Record[]` so unknown future fields don't break older + * capa versions. The installer narrows each entry through `validateHooks()` + * — invalid hooks are skipped with a warning rather than aborting install. + */ + +import type { CanonicalHookEvent, Hook, HookSource } from "../types/hooks"; +import { CANONICAL_HOOK_EVENTS } from "../types/hooks"; + +const CANONICAL_EVENT_SET: ReadonlySet = new Set( + CANONICAL_HOOK_EVENTS, +); + +const KNOWN_SOURCE_TYPES: ReadonlySet = new Set([ + "inline", + "remote", + "github", + "gitlab", + "local", +]); + +export interface HookValidationIssue { + hookId: string | null; + message: string; +} + +export interface HookValidationResult { + valid: Hook[]; + issues: HookValidationIssue[]; +} + +function isCanonical(event: string): event is CanonicalHookEvent { + return CANONICAL_EVENT_SET.has(event); +} + +function isProviderScopedEvent(event: string): boolean { + // shape: `:` — both segments must be non-empty. + const colonIdx = event.indexOf(":"); + return colonIdx > 0 && colonIdx < event.length - 1; +} + +function validateSource( + raw: unknown, + hookId: string, +): HookSource | { error: string } { + if (raw === undefined) return { error: "missing source" }; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { error: `hook "${hookId}" source must be an object` }; + } + const obj = raw as Record; + const type = obj.type; + if ( + typeof type !== "string" || + !KNOWN_SOURCE_TYPES.has(type as HookSource["type"]) + ) { + return { + error: `hook "${hookId}" source.type must be one of inline/remote/github/gitlab/local`, + }; + } + const result: HookSource = { type: type as HookSource["type"] }; + if (typeof obj.content === "string") result.content = obj.content; + if (typeof obj.url === "string") result.url = obj.url; + if (typeof obj.path === "string") result.path = obj.path; + if (typeof obj.executable === "boolean") result.executable = obj.executable; + if (obj.def && typeof obj.def === "object" && !Array.isArray(obj.def)) { + const def = obj.def as Record; + if (typeof def.repo === "string") { + result.def = { repo: def.repo }; + } + } + switch (result.type) { + case "inline": + if (!result.content) + return { + error: `hook "${hookId}" source.type=inline requires content`, + }; + break; + case "remote": + if (!result.url) + return { error: `hook "${hookId}" source.type=remote requires url` }; + break; + case "github": + case "gitlab": + if (!result.def?.repo) + return { + error: `hook "${hookId}" source.type=${result.type} requires def.repo`, + }; + break; + case "local": + if (!result.path) + return { error: `hook "${hookId}" source.type=local requires path` }; + break; + } + return result; +} + +/** + * Narrow each raw `hooks[]` entry to a `Hook`. Invalid entries are reported + * via `issues` so the caller can surface warnings, not failures. + */ +export function validateHooks(raw: unknown[]): HookValidationResult { + const valid: Hook[] = []; + const issues: HookValidationIssue[] = []; + const seenIds = new Set(); + + for (let i = 0; i < raw.length; i++) { + const item = raw[i]; + if (!item || typeof item !== "object" || Array.isArray(item)) { + issues.push({ hookId: null, message: `hook[${i}] must be an object` }); + continue; + } + const obj = item as Record; + + if (typeof obj.id !== "string" || obj.id.length === 0) { + issues.push({ + hookId: null, + message: `hook[${i}] is missing required 'id' field`, + }); + continue; + } + if (seenIds.has(obj.id)) { + issues.push({ + hookId: obj.id, + message: `duplicate hook id "${obj.id}" — entries after the first are skipped`, + }); + continue; + } + + if (typeof obj.on !== "string" || obj.on.length === 0) { + issues.push({ + hookId: obj.id, + message: `hook "${obj.id}" requires non-empty 'on' field`, + }); + continue; + } + if (!isCanonical(obj.on) && !isProviderScopedEvent(obj.on)) { + issues.push({ + hookId: obj.id, + message: `hook "${obj.id}" event "${obj.on}" is neither canonical nor of the form :`, + }); + continue; + } + + const type: "command" | "prompt" = + obj.type === "prompt" ? "prompt" : "command"; + + let source: HookSource | undefined; + if (obj.source !== undefined) { + const result = validateSource(obj.source, obj.id); + if ("error" in result) { + issues.push({ hookId: obj.id, message: result.error }); + continue; + } + source = result; + } + + const command = typeof obj.command === "string" ? obj.command : undefined; + const prompt = typeof obj.prompt === "string" ? obj.prompt : undefined; + + if (!source) { + if (type === "command" && !command) { + issues.push({ + hookId: obj.id, + message: `hook "${obj.id}" requires 'command' or 'source' for type=command`, + }); + continue; + } + if (type === "prompt" && !prompt) { + issues.push({ + hookId: obj.id, + message: `hook "${obj.id}" requires 'prompt' or 'source' for type=prompt`, + }); + continue; + } + } + + const hook: Hook = { + id: obj.id, + on: obj.on as Hook["on"], + type, + }; + if (typeof obj.description === "string") hook.description = obj.description; + if (command) hook.command = command; + if (prompt) hook.prompt = prompt; + if (typeof obj.matcher === "string") hook.matcher = obj.matcher; + if (typeof obj.timeout === "number") hook.timeout = obj.timeout; + if (typeof obj.failClosed === "boolean") hook.failClosed = obj.failClosed; + if (typeof obj.sequential === "boolean") hook.sequential = obj.sequential; + if (Array.isArray(obj.providers)) { + const providers = obj.providers.filter( + (p): p is string => typeof p === "string", + ); + if (providers.length > 0) hook.providers = providers; + } + if (source) hook.source = source; + + seenIds.add(obj.id); + valid.push(hook); + } + + return { valid, issues }; +} diff --git a/src/shared/lockfile.ts b/src/shared/lockfile.ts index be3e0ac1..5045d982 100644 --- a/src/shared/lockfile.ts +++ b/src/shared/lockfile.ts @@ -13,6 +13,7 @@ import yaml from 'js-yaml'; import type { Lockfile, LockfileFormat, + LockHookEntry, LockPluginEntry, LockSkillEntry, LockSource, @@ -48,6 +49,7 @@ export function emptyLockfile(): Lockfile { generatedAt: new Date().toISOString(), skills: [], plugins: [], + hooks: [], }; } @@ -55,6 +57,10 @@ function isLockSource(value: unknown): value is LockSource { return value === 'github' || value === 'gitlab'; } +function isLockHookSource(value: unknown): value is LockHookEntry['source'] { + return value === 'github' || value === 'gitlab' || value === 'remote'; +} + function isNullableString(value: unknown): value is string | null { return value === null || typeof value === 'string'; } @@ -75,6 +81,23 @@ export function isValidSkillLockEntry(x: unknown): x is LockSkillEntry { ); } +export function isValidHookLockEntry(x: unknown): x is LockHookEntry { + if (!x || typeof x !== 'object') return false; + const e = x as Record; + return ( + typeof e.id === 'string' && + e.id.length > 0 && + isLockHookSource(e.source) && + isNullableString(e.repo) && + isNullableString(e.url) && + isNullableString(e.requestedVersion) && + isNullableString(e.requestedRef) && + isNullableString(e.resolvedRef) && + isNullableString(e.resolvedVersion) && + typeof e.bodySha256 === 'string' + ); +} + export function isValidPluginLockEntry(x: unknown): x is LockPluginEntry { if (!x || typeof x !== 'object') return false; const e = x as Record; @@ -129,12 +152,16 @@ function validateLockfile(parsed: unknown): Lockfile { if (!Array.isArray(obj.plugins)) { throw new Error('Lockfile.plugins is not an array'); } + // Older lockfiles predate the hooks section — treat a missing field as []. + const hooksValue = obj.hooks; + const hooksArray = Array.isArray(hooksValue) ? hooksValue : []; return { version: 1, generator: typeof obj.generator === 'string' ? obj.generator : `capa@${VERSION}`, generatedAt: typeof obj.generatedAt === 'string' ? obj.generatedAt : new Date().toISOString(), skills: filterLockEntries(obj.skills, isValidSkillLockEntry, 'skill'), plugins: filterLockEntries(obj.plugins, isValidPluginLockEntry, 'plugin'), + hooks: filterLockEntries(hooksArray, isValidHookLockEntry, 'hook'), }; } @@ -199,6 +226,7 @@ export function serializeLockfile(lockfile: Lockfile, format: LockfileFormat): s export class LockfileBuilder { private skills: Map = new Map(); private plugins: Map = new Map(); + private hooks: Map = new Map(); private generator: string; private generatedAt: string; @@ -208,6 +236,7 @@ export class LockfileBuilder { if (initial) { for (const skill of initial.skills) this.skills.set(skill.id, skill); for (const plugin of initial.plugins) this.plugins.set(plugin.id, plugin); + for (const hook of initial.hooks ?? []) this.hooks.set(hook.id, hook); } } @@ -267,30 +296,58 @@ export class LockfileBuilder { this.plugins.set(entry.id, entry); } + upsertHook(entry: LockHookEntry): void { + this.hooks.set(entry.id, entry); + } + + /** + * Look up a hook entry by id. Returns the entry only if its requested + * version/ref still matches what the capabilities file is asking for — + * otherwise we must re-resolve. + */ + findHook( + id: string, + requestedVersion: string | null, + requestedRef: string | null + ): LockHookEntry | null { + const entry = this.hooks.get(id); + if (!entry) return null; + if ((entry.requestedVersion ?? null) !== (requestedVersion ?? null)) return null; + if ((entry.requestedRef ?? null) !== (requestedRef ?? null)) return null; + return entry; + } + /** - * Drop any skills/plugins not present in the provided id sets. Called at - * the end of install to evict stale entries (e.g. user removed a skill). + * Drop any skills/plugins/hooks not present in the provided id sets. Called + * at the end of install to evict stale entries (e.g. user removed a skill). */ - pruneToIds(skillIds: Set, pluginIds: Set): void { + pruneToIds(skillIds: Set, pluginIds: Set, hookIds?: Set): void { for (const id of [...this.skills.keys()]) { if (!skillIds.has(id)) this.skills.delete(id); } for (const id of [...this.plugins.keys()]) { if (!pluginIds.has(id)) this.plugins.delete(id); } + if (hookIds) { + for (const id of [...this.hooks.keys()]) { + if (!hookIds.has(id)) this.hooks.delete(id); + } + } } build(): Lockfile { const skills = [...this.skills.values()].sort((a, b) => a.id.localeCompare(b.id)); const plugins = [...this.plugins.values()].sort((a, b) => a.id.localeCompare(b.id)); + const hooks = [...this.hooks.values()].sort((a, b) => a.id.localeCompare(b.id)); return { version: 1, generator: this.generator, generatedAt: new Date().toISOString(), skills, plugins, + hooks, }; } } -export type { LockSource, LockSkillEntry, LockPluginEntry, Lockfile }; +export type { LockSource, LockSkillEntry, LockPluginEntry, LockHookEntry, Lockfile }; diff --git a/src/shared/providers/__tests__/hook-handlers.test.ts b/src/shared/providers/__tests__/hook-handlers.test.ts new file mode 100644 index 00000000..1f5f0148 --- /dev/null +++ b/src/shared/providers/__tests__/hook-handlers.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'bun:test'; +import { + buildHookEntry, + buildNameTag, + isCapaNameTag, + upsertHookEntry, + removeHookEntryAt, +} from '../hook-handlers'; +import type { HooksIntegration } from '../../../types/providers'; +import type { Hook } from '../../../types/hooks'; + +const claudeIntegration: HooksIntegration = { + storage: { kind: 'inline-config', configPath: '.claude/settings.json', format: 'json', hooksKey: 'hooks' }, + shape: 'claude', + supportsNameTag: true, + eventMap: { + beforeTool: { event: 'PreToolUse' }, + beforeShell: { event: 'PreToolUse', matcherPrefix: 'Bash' }, + afterFileEdit: { event: 'PostToolUse', matcherPrefix: 'Edit|MultiEdit|Write' }, + }, +}; + +const cursorIntegration: HooksIntegration = { + storage: { kind: 'standalone', configPath: '.cursor/hooks.json', format: 'json', envelope: 'cursor-v1' }, + shape: 'cursor', + supportsNameTag: true, + eventMap: { + beforeShell: { event: 'beforeShellExecution' }, + }, +}; + +const codexIntegration: HooksIntegration = { + storage: { kind: 'inline-config', configPath: '.codex/config.toml', format: 'toml', hooksKey: 'hooks' }, + shape: 'codex-toml', + supportsNameTag: false, + eventMap: { + beforeShell: { event: 'PreShellExec' }, + }, +}; + +describe('hook-handlers — buildHookEntry', () => { + it('claude shape emits a name-tagged command entry', () => { + const hook: Hook = { id: 'lint', on: 'beforeShell', command: 'echo lint' }; + const out = buildHookEntry(claudeIntegration, { + hook, + runReference: '/abs/path/to/script', + mapping: { event: 'PreToolUse', matcherPrefix: 'Bash' }, + }); + expect(out.eventName).toBe('PreToolUse'); + expect(out.matcher).toBe('Bash'); + expect(out.entry.command).toBe('/abs/path/to/script'); + expect(out.entry.type).toBe('command'); + expect(out.nameTag).toBe('capa:lint'); + expect(out.entry.name).toBe('capa:lint'); + }); + + it('cursor shape stores entries as flat arrays with pattern + name', () => { + const hook: Hook = { id: 'block-rm', on: 'beforeShell', command: 'echo blocked', matcher: 'rm -rf' }; + const out = buildHookEntry(cursorIntegration, { + hook, + runReference: 'echo blocked', + mapping: { event: 'beforeShellExecution' }, + }); + expect(out.eventName).toBe('beforeShellExecution'); + expect(out.entry.pattern).toBe('rm -rf'); + expect(out.entry.name).toBe('capa:block-rm'); + }); + + it('codex-toml shape emits id without name tag', () => { + const hook: Hook = { id: 'audit-shell', on: 'beforeShell', command: 'echo' }; + const out = buildHookEntry(codexIntegration, { + hook, + runReference: '/abs/script', + mapping: { event: 'PreShellExec' }, + }); + expect(out.entry.id).toBe('audit-shell'); + expect(out.entry.command).toBe('/abs/script'); + expect(out.nameTag).toBeNull(); + }); +}); + +describe('hook-handlers — upsertHookEntry / removeHookEntryAt', () => { + it('claude: upserts under matcher and removes precisely', () => { + const root: Record = {}; + const out = buildHookEntry(claudeIntegration, { + hook: { id: 'h1', on: 'beforeShell', command: 'echo' }, + runReference: '/a', + mapping: { event: 'PreToolUse', matcherPrefix: 'Bash' }, + }); + const locator = upsertHookEntry(claudeIntegration, root, out); + expect(locator).toEqual(['PreToolUse', 0, 'hooks', 0]); + expect(((root.PreToolUse as unknown[])[0] as { matcher: string }).matcher).toBe('Bash'); + + const removed = removeHookEntryAt(claudeIntegration, root, locator, 'h1'); + expect(removed).toBe(true); + expect(root.PreToolUse).toBeUndefined(); + }); + + it('cursor: replaces existing entry with same name tag', () => { + const root: Record = {}; + const inputs: Hook = { id: 'h2', on: 'beforeShell', command: 'first' }; + const first = buildHookEntry(cursorIntegration, { + hook: inputs, + runReference: 'first', + mapping: { event: 'beforeShellExecution' }, + }); + upsertHookEntry(cursorIntegration, root, first); + + const second = buildHookEntry(cursorIntegration, { + hook: inputs, + runReference: 'second', + mapping: { event: 'beforeShellExecution' }, + }); + const locator = upsertHookEntry(cursorIntegration, root, second); + expect((root.beforeShellExecution as unknown[]).length).toBe(1); + expect(((root.beforeShellExecution as unknown[])[0] as { command: string }).command).toBe('second'); + expect(locator).toEqual(['beforeShellExecution', 0]); + }); + + it('refuses to remove an entry whose name does not match the hook id', () => { + const root: Record = { + PreToolUse: [{ matcher: '', hooks: [{ name: 'capa:other', command: 'x', type: 'command' }] }], + }; + const removed = removeHookEntryAt(claudeIntegration, root, ['PreToolUse', 0, 'hooks', 0], 'expected'); + expect(removed).toBe(false); + expect((root.PreToolUse as unknown[]).length).toBe(1); + }); +}); + +describe('hook-handlers — name tag helpers', () => { + it('build/recognise tag round-trips', () => { + const tag = buildNameTag('foo'); + expect(tag).toBe('capa:foo'); + expect(isCapaNameTag(tag)).toBe(true); + expect(isCapaNameTag(tag, 'foo')).toBe(true); + expect(isCapaNameTag(tag, 'bar')).toBe(false); + expect(isCapaNameTag('user-named')).toBe(false); + }); +}); diff --git a/src/shared/providers/__tests__/registry.test.ts b/src/shared/providers/__tests__/registry.test.ts index 38e929cb..63282e83 100644 --- a/src/shared/providers/__tests__/registry.test.ts +++ b/src/shared/providers/__tests__/registry.test.ts @@ -113,6 +113,49 @@ describe('Provider registry', () => { expect(replit?.showInUniversalList).toBe(false); }); }); + + describe('hooks integration is wired for v1 providers', () => { + it('claude-code uses inline-config json with claude shape', () => { + const p = getProvider('claude-code'); + expect(p?.hooks).toBeDefined(); + expect(p?.hooks?.shape).toBe('claude'); + expect(p?.hooks?.storage.kind).toBe('inline-config'); + if (p?.hooks?.storage.kind === 'inline-config') { + expect(p.hooks.storage.configPath).toBe('.claude/settings.json'); + expect(p.hooks.storage.format).toBe('json'); + } + expect(p?.hooks?.eventMap.beforeShell).toEqual({ event: 'PreToolUse', matcherPrefix: 'Bash' }); + }); + + it('cursor uses standalone json with cursor-v1 envelope', () => { + const p = getProvider('cursor'); + expect(p?.hooks?.shape).toBe('cursor'); + expect(p?.hooks?.storage.kind).toBe('standalone'); + if (p?.hooks?.storage.kind === 'standalone') { + expect(p.hooks.storage.envelope).toBe('cursor-v1'); + expect(p.hooks.storage.configPath).toBe('.cursor/hooks.json'); + } + }); + + it('codex uses inline-config toml with codex-toml shape', () => { + const p = getProvider('codex'); + expect(p?.hooks?.shape).toBe('codex-toml'); + expect(p?.hooks?.storage.kind).toBe('inline-config'); + if (p?.hooks?.storage.kind === 'inline-config') { + expect(p.hooks.storage.format).toBe('toml'); + expect(p.hooks.storage.configPath).toBe('.codex/config.toml'); + } + }); + + it('gemini-cli uses inline-config json with gemini shape', () => { + const p = getProvider('gemini-cli'); + expect(p?.hooks?.shape).toBe('gemini'); + expect(p?.hooks?.storage.kind).toBe('inline-config'); + if (p?.hooks?.storage.kind === 'inline-config') { + expect(p.hooks.storage.configPath).toBe('.gemini/settings.json'); + } + }); + }); }); describe('Cursor pilot integration', () => { diff --git a/src/shared/providers/hook-handlers.ts b/src/shared/providers/hook-handlers.ts new file mode 100644 index 00000000..c6264b3e --- /dev/null +++ b/src/shared/providers/hook-handlers.ts @@ -0,0 +1,387 @@ +/** + * Per-shape hook entry serialisers. + * + * Each shape ('claude', 'cursor', …) maps a (canonical event, hook payload, + * matcher) tuple to a JSON-friendly fragment that lives inside the provider's + * config file. Pure functions — no I/O, so they can be unit-tested without + * a temp project. + * + * The companion `installer/io` layer (see `cli/utils/hooks-installer.ts`) + * applies these fragments to the on-disk config and records a `locator` + * back so the next install/clean run can find and remove the same entry. + */ + +import { join } from "path"; +import type { Hook } from "../../types/hooks"; +import type { + HooksIntegration, + ProviderEventMapping, +} from "../../types/providers"; + +/** + * The bag of inputs every shape serialiser needs. + * `runReference` is either an absolute path (for `command`-type hooks + * whose body has been materialised under ~/.capa) or the literal command + * string when the user passed `command:` inline. + */ +export interface HookEntryInput { + hook: Hook; + /** Shell command to execute, or prompt text for prompt-type hooks. */ + runReference: string; + /** The mapping picked from `HooksIntegration.eventMap`. */ + mapping: ProviderEventMapping; +} + +/** + * The result of serialising one hook for one provider event. + * + * - `eventName` : the provider event name (e.g. 'PreToolUse') + * - `entry` : the JSON fragment to drop into the hooks map + * - `matcher` : the matcher under which the entry should be filed + * (claude/gemini); empty string for shapes that don't + * group by matcher + * - `nameTag` : `capa:` when the shape supports name tagging, + * else null. Used by the installer to find/replace the + * exact entry on the next run. + */ +export interface HookEntryOutput { + eventName: string; + entry: Record; + matcher: string; + nameTag: string | null; +} + +const NAME_TAG_PREFIX = "capa:"; + +export function buildNameTag(hookId: string): string { + return `${NAME_TAG_PREFIX}${hookId}`; +} + +export function isCapaNameTag(value: unknown, hookId?: string): boolean { + if (typeof value !== "string") return false; + if (!value.startsWith(NAME_TAG_PREFIX)) return false; + if (hookId && value !== buildNameTag(hookId)) return false; + return true; +} + +function idFromNameTag(tag: string): string { + return tag.startsWith(NAME_TAG_PREFIX) + ? tag.slice(NAME_TAG_PREFIX.length) + : tag; +} + +/** + * Pick a matcher value for the entry. + * + * Precedence: + * 1. user-supplied `hook.matcher` (allows fine-grained scoping) + * 2. mapping's `matcherPrefix` (fold-down helper, e.g. canonical + * `beforeShell` -> claude `PreToolUse` matcher `Bash`) + * 3. empty string (no matcher; shape decides) + */ +function resolveMatcher(input: HookEntryInput): string { + if (input.hook.matcher && input.hook.matcher.length > 0) + return input.hook.matcher; + if ("matcherPrefix" in input.mapping) return input.mapping.matcherPrefix; + return ""; +} + +// --------------------------------------------------------------------------- +// Shape serialisers +// --------------------------------------------------------------------------- + +function buildClaudeLikeEntry( + input: HookEntryInput, + supportsName: boolean, +): HookEntryOutput { + const { hook, mapping, runReference } = input; + const isPrompt = (hook.type ?? "command") === "prompt"; + const entry: Record = { + type: isPrompt ? "prompt" : "command", + [isPrompt ? "prompt" : "command"]: runReference, + }; + if (hook.timeout !== undefined) entry.timeout = hook.timeout; + const nameTag = supportsName ? buildNameTag(hook.id) : null; + if (nameTag) entry.name = nameTag; + return { + eventName: mapping.event, + entry, + matcher: resolveMatcher(input), + nameTag, + }; +} + +function buildCursorEntry(input: HookEntryInput): HookEntryOutput { + const { hook, mapping, runReference } = input; + const entry: Record = { + command: runReference, + }; + const matcher = resolveMatcher(input); + if (matcher) entry.pattern = matcher; + if (hook.timeout !== undefined) entry.timeout = hook.timeout; + if (hook.failClosed) entry.failClosed = true; + const nameTag = buildNameTag(hook.id); + entry.name = nameTag; + return { + eventName: mapping.event, + entry, + matcher: "", + nameTag, + }; +} + +function buildCodexTomlEntry(input: HookEntryInput): HookEntryOutput { + const { hook, mapping, runReference } = input; + const isPrompt = (hook.type ?? "command") === "prompt"; + const entry: Record = { + type: isPrompt ? "prompt" : "command", + [isPrompt ? "prompt" : "command"]: runReference, + id: hook.id, + }; + if (hook.timeout !== undefined) entry.timeout = hook.timeout; + return { + eventName: mapping.event, + entry, + matcher: resolveMatcher(input), + nameTag: null, + }; +} + +/** + * Build the shape-specific entry for a single (provider, hook, mapping). + * + * Throws when the shape is not handled by the installer (caller should + * convert to a warning so install never fails on unknown shape). + */ +export function buildHookEntry( + integration: HooksIntegration, + input: HookEntryInput, +): HookEntryOutput { + switch (integration.shape) { + case "claude": + case "gemini": + case "antigravity": + case "windsurf": + return buildClaudeLikeEntry(input, integration.supportsNameTag); + case "cursor": + return buildCursorEntry(input); + case "codex-toml": + return buildCodexTomlEntry(input); + default: + throw new Error( + `Unsupported hook shape: ${(integration as { shape: string }).shape}`, + ); + } +} + +// --------------------------------------------------------------------------- +// Locator / placement helpers +// --------------------------------------------------------------------------- + +/** + * Where (inside the parsed config object) capa stores its hook entry. The + * locator round-trips through `managed_hooks.locator` so a future install + * can find and remove the same entry surgically. + * + * Layout per shape (`hooksRoot` is the inline-config `hooksKey` or the bare + * standalone document): + * + * - claude/gemini : hooksRoot[] is an array of `{ matcher, hooks: [entry] }`. + * locator = ['', , 'hooks', ] + * - cursor : hooksRoot[] is an array of `entry`. + * locator = ['', ] + * - codex-toml : hooksRoot[] is an array of `entry`. + * locator = ['', ] + * + * Locators are emitted relative to the hooks root, NOT relative to the file + * — each shape has a `rootPath` helper for the absolute path, which the + * installer uses when it loads the config. + */ +export type HookLocator = (string | number)[]; + +/** + * Apply a built hook entry to the parsed hooks-root object, replacing any + * previous capa entry with the same name tag (if the shape supports it). + * + * Mutates `hooksRoot` in place and returns the locator to record in the DB. + */ +export function upsertHookEntry( + integration: HooksIntegration, + hooksRoot: Record, + output: HookEntryOutput, +): HookLocator { + switch (integration.shape) { + case "claude": + case "gemini": + case "antigravity": + case "windsurf": { + const { eventName, entry, matcher, nameTag } = output; + const events = ensureArray(hooksRoot, eventName); + // Each element is `{ matcher: string, hooks: [{ … }] }`. + let matcherIdx = events.findIndex( + (g) => isPlainObject(g) && (g.matcher ?? "") === matcher, + ); + if (matcherIdx === -1) { + events.push({ matcher, hooks: [] }); + matcherIdx = events.length - 1; + } + const group = events[matcherIdx] as { matcher: string; hooks: unknown[] }; + if (!Array.isArray(group.hooks)) group.hooks = []; + // Replace only the entry tagged for *this* hook id; other capa entries + // for sibling hooks must coexist in the same matcher group. + const existingIdx = nameTag + ? group.hooks.findIndex( + (e) => + isPlainObject(e) && isCapaNameTag(e.name, idFromNameTag(nameTag)), + ) + : -1; + if (existingIdx >= 0) { + group.hooks[existingIdx] = entry; + return [eventName, matcherIdx, "hooks", existingIdx]; + } + group.hooks.push(entry); + return [eventName, matcherIdx, "hooks", group.hooks.length - 1]; + } + case "cursor": + case "codex-toml": { + const { eventName, entry, nameTag } = output; + const events = ensureArray(hooksRoot, eventName); + const existingIdx = nameTag + ? events.findIndex( + (e) => + isPlainObject(e) && isCapaNameTag(e.name, idFromNameTag(nameTag)), + ) + : integration.shape === "codex-toml" + ? events.findIndex( + (e) => isPlainObject(e) && e.id === (entry as { id?: string }).id, + ) + : -1; + if (existingIdx >= 0) { + events[existingIdx] = entry; + return [eventName, existingIdx]; + } + events.push(entry); + return [eventName, events.length - 1]; + } + default: + throw new Error( + `Unsupported hook shape: ${(integration as { shape: string }).shape}`, + ); + } +} + +/** + * Remove the entry at `locator` from the parsed hooks root. + * + * Returns true when something was removed. Empty matcher groups and empty + * event arrays are pruned so re-installing a different set of hooks does + * not leave stale skeletons behind. + */ +export function removeHookEntryAt( + integration: HooksIntegration, + hooksRoot: Record, + locator: HookLocator, + expectedHookId: string, +): boolean { + switch (integration.shape) { + case "claude": + case "gemini": + case "antigravity": + case "windsurf": { + // ['', matcherIdx, 'hooks', entryIdx] + if (locator.length !== 4) return false; + const [eventName, matcherIdx, , entryIdx] = locator; + if ( + typeof eventName !== "string" || + typeof matcherIdx !== "number" || + typeof entryIdx !== "number" + ) { + return false; + } + const events = hooksRoot[eventName]; + if ( + !Array.isArray(events) || + matcherIdx < 0 || + matcherIdx >= events.length + ) + return false; + const group = events[matcherIdx]; + if (!isPlainObject(group) || !Array.isArray(group.hooks)) return false; + if (entryIdx < 0 || entryIdx >= group.hooks.length) return false; + const candidate = group.hooks[entryIdx]; + if ( + !isPlainObject(candidate) || + !isCapaNameTag(candidate.name, expectedHookId) + ) + return false; + group.hooks.splice(entryIdx, 1); + if (group.hooks.length === 0) events.splice(matcherIdx, 1); + if (events.length === 0) delete hooksRoot[eventName]; + return true; + } + case "cursor": + case "codex-toml": { + // ['', entryIdx] + if (locator.length !== 2) return false; + const [eventName, entryIdx] = locator; + if (typeof eventName !== "string" || typeof entryIdx !== "number") + return false; + const events = hooksRoot[eventName]; + if (!Array.isArray(events) || entryIdx < 0 || entryIdx >= events.length) + return false; + const candidate = events[entryIdx]; + if (integration.shape === "cursor") { + if ( + !isPlainObject(candidate) || + !isCapaNameTag(candidate.name, expectedHookId) + ) + return false; + } else { + if (!isPlainObject(candidate) || candidate.id !== expectedHookId) + return false; + } + events.splice(entryIdx, 1); + if (events.length === 0) delete hooksRoot[eventName]; + return true; + } + default: + return false; + } +} + +/** + * Resolve the absolute path of the hook config file for a provider whose + * hook integration uses a `standalone` or `inline-config` storage. Throws + * for `directory` storage (callers handle that branch separately). + */ +export function getHookConfigPath( + integration: HooksIntegration, + projectPath: string, +): string { + switch (integration.storage.kind) { + case "standalone": + return join(projectPath, integration.storage.configPath); + case "inline-config": + return join(projectPath, integration.storage.configPath); + case "directory": + throw new Error( + "directory-storage hooks resolve per-entry, not per-file", + ); + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function ensureArray(obj: Record, key: string): unknown[] { + const existing = obj[key]; + if (Array.isArray(existing)) return existing; + const next: unknown[] = []; + obj[key] = next; + return next; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/shared/providers/registry.ts b/src/shared/providers/registry.ts index 00730203..4d1286a8 100644 --- a/src/shared/providers/registry.ts +++ b/src/shared/providers/registry.ts @@ -95,6 +95,36 @@ export const providers: Record = { pluginManifestPaths: ['.claude-plugin/plugin.json'], pluginProviderId: 'claude', foldSubAgentsIntoInstructions: false, + hooks: { + // Claude Code reads hooks from the project-local .claude/settings.json. + // Docs: https://docs.claude.com/en/docs/claude-code/hooks + storage: { + kind: 'inline-config', + configPath: '.claude/settings.json', + format: 'json', + hooksKey: 'hooks', + }, + shape: 'claude', + supportsNameTag: true, + eventMap: { + sessionStart: { event: 'SessionStart' }, + sessionEnd: { event: 'SessionEnd' }, + userPromptSubmit: { event: 'UserPromptSubmit' }, + beforeTool: { event: 'PreToolUse' }, + afterTool: { event: 'PostToolUse' }, + beforeShell: { event: 'PreToolUse', matcherPrefix: 'Bash' }, + afterShell: { event: 'PostToolUse', matcherPrefix: 'Bash' }, + afterFileEdit: { + event: 'PostToolUse', + matcherPrefix: 'Edit|MultiEdit|Write', + }, + beforeMcpCall: { event: 'PreToolUse', matcherPrefix: 'mcp__' }, + afterMcpCall: { event: 'PostToolUse', matcherPrefix: 'mcp__' }, + subagentStop: { event: 'SubagentStop' }, + preCompact: { event: 'PreCompact' }, + stop: { event: 'Stop' }, + }, + }, }, openclaw: { id: 'openclaw', @@ -160,6 +190,23 @@ export const providers: Record = { format: 'toml', bodyField: 'developer_instructions', }, + hooks: { + // Codex reads hooks from .codex/config.toml as a [hooks] table. + // Docs: https://github.com/openai/codex/blob/main/docs/config.md + storage: { + kind: 'inline-config', + configPath: '.codex/config.toml', + format: 'toml', + hooksKey: 'hooks', + }, + shape: 'codex-toml', + supportsNameTag: false, + eventMap: { + sessionStart: { event: 'SessionStart' }, + beforeShell: { event: 'PreShellExec' }, + afterShell: { event: 'PostShellExec' }, + }, + }, }, 'command-code': { id: 'command-code', @@ -224,6 +271,27 @@ export const providers: Record = { pluginManifestPaths: ['.cursor-plugin/plugin.json'], pluginProviderId: 'cursor', purgeStaleSubAgentMcp: true, + hooks: { + // Cursor reads hooks from a dedicated .cursor/hooks.json with a + // { version: 1, hooks: { ... } } envelope. + // Docs: https://cursor.com/docs/agent/hooks + storage: { + kind: 'standalone', + configPath: '.cursor/hooks.json', + format: 'json', + envelope: 'cursor-v1', + }, + shape: 'cursor', + supportsNameTag: true, + eventMap: { + beforeShell: { event: 'beforeShellExecution' }, + beforeMcpCall: { event: 'beforeMCPExecution' }, + beforeFileRead: { event: 'beforeReadFile' }, + afterFileEdit: { event: 'afterFileEdit' }, + userPromptSubmit: { event: 'beforeSubmitPrompt' }, + stop: { event: 'stop' }, + }, + }, }, droid: { id: 'droid', @@ -272,6 +340,28 @@ export const providers: Record = { extension: '.md', format: 'markdown-frontmatter', }, + hooks: { + // Gemini CLI hooks live alongside its other settings in + // .gemini/settings.json under a top-level `hooks` key. Format and + // shape mirror Claude's claude-style entries closely enough that we + // share the same `claude` shape. + // Docs: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/configuration.md + storage: { + kind: 'inline-config', + configPath: '.gemini/settings.json', + format: 'json', + hooksKey: 'hooks', + }, + shape: 'gemini', + supportsNameTag: true, + eventMap: { + sessionStart: { event: 'SessionStart' }, + userPromptSubmit: { event: 'UserPromptSubmit' }, + beforeTool: { event: 'PreToolUse' }, + afterTool: { event: 'PostToolUse' }, + stop: { event: 'Stop' }, + }, + }, }, 'github-copilot': { id: 'github-copilot', diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index b611f32a..9408b295 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -2,6 +2,7 @@ import type { Plugin, SourcePlugin, ResolvedPluginInfo } from './plugin'; import type { Rule } from './rules'; +import type { Hook } from './hooks'; /** OAuth2 settings on MCP server definitions (plugin manifest or auto-detected). */ export interface OAuth2Config { @@ -27,6 +28,7 @@ export interface OAuth2Config { export type CapabilitiesFormat = 'json' | 'yaml'; export type { Plugin, SourcePlugin, ResolvedPluginInfo } from './plugin'; export type { Rule } from './rules'; +export type { Hook, HookSource, CanonicalHookEvent, ProviderScopedEvent } from './hooks'; /** * Tool exposure modes for MCP clients @@ -239,6 +241,12 @@ export interface Capabilities { * instructions file for providers without a dedicated rules location). */ rules?: Rule[]; + /** + * Lifecycle hooks installed across providers that support them. For each + * declared hook capa records the targeted provider config + JSON pointer + * (or TOML key path) so it can edit shared config files surgically. + */ + hooks?: Hook[]; } export interface Skill { diff --git a/src/types/hooks.ts b/src/types/hooks.ts new file mode 100644 index 00000000..8fdd3146 --- /dev/null +++ b/src/types/hooks.ts @@ -0,0 +1,126 @@ +/** + * Hook types — declarative lifecycle hooks installed across providers. + * + * Each `Hook` declared in the capabilities file is translated to one or more + * provider-specific entries by `installHooks()`. capa keeps a per-entry + * record (`managed_hooks`) so install/clean can edit shared config files + * (e.g. `.claude/settings.json`) without disturbing user-authored entries. + */ + +import type { AgentSnippetDef } from './capabilities'; + +/** + * Canonical lifecycle events. + * + * `installHooks()` translates each canonical event to the provider's native + * event name via `HooksIntegration.eventMap`. Providers that don't expose an + * equivalent event simply skip the hook for that target (warn-but-never-fail). + */ +export type CanonicalHookEvent = + | 'sessionStart' + | 'sessionEnd' + | 'userPromptSubmit' + | 'beforeTool' + | 'afterTool' + | 'afterToolFailure' + | 'beforeShell' + | 'afterShell' + | 'beforeFileRead' + | 'afterFileEdit' + | 'beforeMcpCall' + | 'afterMcpCall' + | 'subagentStart' + | 'subagentStop' + | 'preCompact' + | 'stop'; + +export const CANONICAL_HOOK_EVENTS: readonly CanonicalHookEvent[] = [ + 'sessionStart', + 'sessionEnd', + 'userPromptSubmit', + 'beforeTool', + 'afterTool', + 'afterToolFailure', + 'beforeShell', + 'afterShell', + 'beforeFileRead', + 'afterFileEdit', + 'beforeMcpCall', + 'afterMcpCall', + 'subagentStart', + 'subagentStop', + 'preCompact', + 'stop', +]; + +/** + * Source of the hook payload (script body or prompt text). + * + * - `inline` : `content` is the literal body + * - `remote` : `url` is fetched at install time + * - `github` : `def.repo` follows the same grammar as skills/snippets + * - `gitlab` : like `github` but for GitLab projects + * - `local` : `path` is read from disk (relative to the capabilities file) + * + * For `command`-type hooks the resolved body is materialised under + * `~/.capa/hooks/{projectId}/{hook-id}` so projects stay clean. The provider + * config entry then references that absolute path. For `prompt`-type hooks + * the body is sent inline (no file is materialised). + */ +export interface HookSource { + type: 'inline' | 'remote' | 'github' | 'gitlab' | 'local'; + content?: string; + url?: string; + def?: AgentSnippetDef; + path?: string; + /** When true (default) the materialised script is chmod +x. */ + executable?: boolean; +} + +/** + * Provider-namespaced event names — `"claude-code:PreToolUse"`. Used as the + * `on:` value when a hook is intentionally provider-specific (and not part of + * the canonical set). The first segment is matched case-insensitively against + * a provider id; the second segment is passed through verbatim. + */ +export type ProviderScopedEvent = `${string}:${string}`; + +/** + * A single declarative hook entry. + * + * Either `command` (default) or `type: 'prompt'` is required. Either + * `command` (for command-type) or `prompt` (for prompt-type) must be set, + * unless `source` is provided to materialise the script body from elsewhere. + */ +export interface Hook { + /** Stable id, used for managed-state tracking and `name: capa:` tags. */ + id: string; + /** Human-readable description. */ + description?: string; + + /** Canonical event name, or `:` for provider-only events. */ + on: CanonicalHookEvent | ProviderScopedEvent; + + /** `command` (shell) or `prompt` (text injected into the model). Default `command`. */ + type?: 'command' | 'prompt'; + + /** Inline shell command. Required when `type` is `command` and `source` is unset. */ + command?: string; + /** Inline prompt text. Required when `type` is `prompt` and `source` is unset. */ + prompt?: string; + + /** Provider-specific tool/glob filter (e.g. Claude `matcher`, Cursor `pattern`). */ + matcher?: string; + /** Per-hook timeout in seconds. Provider may clamp or ignore. */ + timeout?: number; + /** When true the provider should fail the action if the hook errors. */ + failClosed?: boolean; + /** When true the provider runs hooks one-at-a-time (default: parallel). */ + sequential?: boolean; + + /** When set, only install for these provider ids. Empty/omitted = all providers. */ + providers?: string[]; + + /** Optional alternate body source (overrides `command`/`prompt`). */ + source?: HookSource; +} diff --git a/src/types/lockfile.ts b/src/types/lockfile.ts index 71e2461e..0a7644f5 100644 --- a/src/types/lockfile.ts +++ b/src/types/lockfile.ts @@ -68,6 +68,34 @@ export interface LockPluginEntry { manifestVersion: string | null; } +/** + * Locked entry for a hook with a remote source (`github`/`gitlab`/`remote`). + * + * Inline and local hooks are not tracked in the lockfile because their bodies + * either travel inside the capabilities file (inline) or already live next to + * the project (local). + */ +export interface LockHookEntry { + /** Hook id from capabilities. Matches `Hook.id`. */ + id: string; + /** Source type. `remote` covers raw URLs; `github`/`gitlab` for repos. */ + source: 'github' | 'gitlab' | 'remote'; + /** "owner/repo" path for github/gitlab; null for remote. */ + repo: string | null; + /** Raw URL for remote sources; null for github/gitlab. */ + url: string | null; + /** Version requested (tag/branch); null when unset. */ + requestedVersion: string | null; + /** Commit SHA explicitly requested; null when unset. */ + requestedRef: string | null; + /** Full commit SHA actually checked out; null for remote URL fetches. */ + resolvedRef: string | null; + /** Resolved tag, when discoverable. */ + resolvedVersion: string | null; + /** SHA-256 (hex) of the resolved hook body — pin against silent rewrites. */ + bodySha256: string; +} + /** * Top-level lockfile schema. */ @@ -82,4 +110,6 @@ export interface Lockfile { skills: LockSkillEntry[]; /** Locked remote plugin entries. */ plugins: LockPluginEntry[]; + /** Locked hook source entries (only for hooks with a `source`). */ + hooks: LockHookEntry[]; } diff --git a/src/types/providers.ts b/src/types/providers.ts index 5e5d56ae..fa99b316 100644 --- a/src/types/providers.ts +++ b/src/types/providers.ts @@ -1,3 +1,5 @@ +import type { CanonicalHookEvent } from './hooks'; + /** * How to register capa's MCP server with a provider's configuration file. * All fields are pure data — no functions. @@ -57,6 +59,65 @@ export interface RulesIntegration { }; } +/** + * One canonical→provider event mapping. Most providers use a simple rename + * (`{ event: 'PreToolUse' }`), but some — e.g. Cursor — fold our `beforeShell` + * onto the same event as `beforeTool` and disambiguate with a `matcherPrefix`. + */ +export type ProviderEventMapping = + | { event: string } + | { event: string; matcherPrefix: string }; + +/** + * How a provider stores its hook configuration. + * + * - `standalone` : hook map lives in its own file + * (e.g. Cursor `.cursor/hooks.json`). + * - `inline-config` : hook map lives under a top-level key in a shared + * settings file (e.g. Claude `.claude/settings.json` → + * `hooks`, Codex `.codex/config.toml` → `hooks`). + * - `directory` : one JSON file per hook in a directory. + * + * `envelope: 'cursor-v1'` triggers the `{ version: 1, hooks: { ... } }` + * wrapper Cursor expects. Without it the file is the bare hooks map. + */ +export type HooksStorage = + | { + kind: 'standalone'; + configPath: string; + format: 'json'; + envelope?: 'cursor-v1'; + } + | { + kind: 'inline-config'; + configPath: string; + format: 'json' | 'toml'; + hooksKey: string; + } + | { kind: 'directory'; dir: string; extension: '.json' }; + +/** + * Per-provider hooks integration descriptor. + * + * Pure data — every behavior is derived from these fields by the shared + * `hooks-installer` and the per-shape entry serialisers in + * `shared/providers/hook-handlers.ts`. + */ +export interface HooksIntegration { + /** Where the provider reads its hook configuration. */ + storage: HooksStorage; + /** Canonical → provider event name (subset is fine; missing events are skipped). */ + eventMap: Partial>; + /** Selects the entry shape (claude-style array, cursor map, codex tables, …). */ + shape: 'cursor' | 'claude' | 'gemini' | 'windsurf' | 'antigravity' | 'codex-toml'; + /** + * When true the provider entry carries an opaque `name` field (used by capa + * for the `capa:` tag, allowing surgical updates without disturbing + * user-authored entries). + */ + supportsNameTag: boolean; +} + /** * Where the provider reads sub-agent definition files. */ @@ -106,6 +167,8 @@ export interface ProviderIntegration { rules?: RulesIntegration; /** Sub-agent file management. */ subagents?: SubagentsIntegration; + /** Lifecycle-hook management. Undefined when capa doesn't install hooks for this provider. */ + hooks?: HooksIntegration; /** Plugin manifest paths relative to the plugin repo root (e.g. '.cursor-plugin/plugin.json'). */ pluginManifestPaths?: string[]; /** Id used in the `PluginProvider` union (manifest enum); decouples registry id from manifest id. */ diff --git a/web-ui/src/features/projects/components/CapabilitiesSection.tsx b/web-ui/src/features/projects/components/CapabilitiesSection.tsx index 39385bf6..82ce0783 100644 --- a/web-ui/src/features/projects/components/CapabilitiesSection.tsx +++ b/web-ui/src/features/projects/components/CapabilitiesSection.tsx @@ -1,13 +1,14 @@ import { useState, useMemo, useEffect } from 'react'; import { useQueries } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import type { Skill, Tool, Server, ToolSchema, EnrichedTool, SubAgent, Rule } from '../../../types/api'; +import type { Skill, Tool, Server, ToolSchema, EnrichedTool, SubAgent, Rule, Hook } from '../../../types/api'; import { SearchInput } from '../../../components/common/SearchInput'; import { SkillsList } from './SkillsList'; import { ToolsList } from './ToolsList'; import { ServersList } from './ServersList'; import { SubagentsList } from './SubagentsList'; import { RulesList } from './RulesList'; +import { HooksList } from './HooksList'; import { TokenSavingsBar } from './TokenSavingsBar'; import { computeTokenSavings } from './tokenStats'; import { projectsApi } from '../api'; @@ -18,10 +19,11 @@ interface CapabilitiesSectionProps { servers: Server[]; subagents: SubAgent[]; rules: Rule[]; + hooks: Hook[]; projectId: string; } -export function CapabilitiesSection({ skills, tools, servers, subagents, rules, projectId }: CapabilitiesSectionProps) { +export function CapabilitiesSection({ skills, tools, servers, subagents, rules, hooks, projectId }: CapabilitiesSectionProps) { const { t } = useTranslation('projects'); const [search, setSearch] = useState(''); @@ -78,7 +80,14 @@ export function CapabilitiesSection({ skills, tools, servers, subagents, rules, return computeTokenSavings(tools as EnrichedTool[], serverToolsMap, servers.length); }, [tools, servers, serverToolsMap, serverToolQueries]); - if (skills.length === 0 && tools.length === 0 && servers.length === 0 && subagents.length === 0 && rules.length === 0) { + if ( + skills.length === 0 && + tools.length === 0 && + servers.length === 0 && + subagents.length === 0 && + rules.length === 0 && + hooks.length === 0 + ) { return null; } @@ -108,6 +117,7 @@ export function CapabilitiesSection({ skills, tools, servers, subagents, rules, {subagents.length > 0 && } {rules.length > 0 && } + {hooks.length > 0 && } ); diff --git a/web-ui/src/features/projects/components/HooksList.tsx b/web-ui/src/features/projects/components/HooksList.tsx new file mode 100644 index 00000000..65d71923 --- /dev/null +++ b/web-ui/src/features/projects/components/HooksList.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronRight, Webhook } from 'lucide-react'; +import type { Hook } from '../../../types/api'; +import { highlightText, matchesSearch } from '../../../lib/utils'; + +interface HooksListProps { + hooks: Hook[]; + search?: string; +} + +export function HooksList({ hooks, search = '' }: HooksListProps) { + const { t } = useTranslation('projects'); + + const visible = hooks.filter((hook) => + matchesSearch( + [hook.id, hook.description, hook.on, hook.type, ...hook.providers, hook.matcher], + search, + ), + ); + + return ( +
+
+ + {t('detail.hooks')} + + {visible.length} + +
+ {visible.length === 0 ? ( +
+ {search ? t('detail.noHooksMatch') : t('detail.noHooks')} +
+ ) : ( +
+ {visible.map((hook) => ( + + ))} +
+ )} +
+ ); +} + +function HookItem({ hook, search }: { hook: Hook; search: string }) { + const { t } = useTranslation('projects'); + const [expanded, setExpanded] = useState(false); + + return ( +
+ + + {expanded && ( +
+ {hook.description && ( +

+ )} + +

+
+ {t('hooks.event')}: + {hook.on} +
+ +
+ {t('hooks.providers')}: + + {hook.providers.length > 0 ? hook.providers.join(', ') : t('hooks.allProviders')} + +
+ + {hook.matcher && ( +
+ {t('hooks.matcher')}: + {hook.matcher} +
+ )} + + {hook.timeout != null && ( +
+ {t('hooks.timeout')}: + {hook.timeout}s +
+ )} + + {hook.sourceType && ( +
+ {t('hooks.source')}: + {hook.sourceType} +
+ )} + + {hook.command && ( +
+ {t('hooks.command')}: +
+                  {hook.command}
+                
+
+ )} + + {hook.prompt && ( +
+ {t('hooks.prompt')}: +
+                  {hook.prompt}
+                
+
+ )} + + {hook.installed.length > 0 && ( +
+ {t('hooks.installed')}: +
    + {hook.installed.map((entry) => ( +
  • +
    + {entry.providerId} + {' — '} + {entry.configPath} +
    + {entry.scriptPath && ( +
    + {t('hooks.script')}: {entry.scriptPath} +
    + )} +
  • + ))} +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/web-ui/src/locales/en/projects.json b/web-ui/src/locales/en/projects.json index e6e3aba8..c507498e 100644 --- a/web-ui/src/locales/en/projects.json +++ b/web-ui/src/locales/en/projects.json @@ -38,10 +38,13 @@ "noServersMatch": "No servers match your search", "subagents": "Sub-Agents", "rules": "Rules", + "hooks": "Hooks", "noSubagents": "No sub-agents configured", "noRules": "No rules configured", + "noHooks": "No hooks configured", "noSubagentsMatch": "No sub-agents match your search", - "noRulesMatch": "No rules match your search" + "noRulesMatch": "No rules match your search", + "noHooksMatch": "No hooks match your search" }, "variables": { "title": "Environment Variables", @@ -90,7 +93,9 @@ "subagent": "sub-agent", "subagents": "sub-agents", "rule": "rule", - "rules": "rules" + "rules": "rules", + "hook": "hook", + "hooks": "hooks" }, "openOnGitHub": "Open on GitHub", "openOnGitLab": "Open on GitLab", @@ -111,6 +116,21 @@ "alwaysApply": "Always apply", "allProviders": "All providers" }, + "hooks": { + "title": "Hooks", + "subtitle": "Lifecycle hooks installed in each provider's hook config", + "event": "Event", + "providers": "Providers", + "matcher": "Matcher", + "timeout": "Timeout", + "source": "Source", + "command": "Command", + "prompt": "Prompt", + "installed": "Installed entries", + "installedOn": "Installed on", + "script": "Script", + "allProviders": "All providers" + }, "providers": { "title": "Providers", "subtitle": "Configured AI coding assistant providers" diff --git a/web-ui/src/pages/ProjectDetailPage.tsx b/web-ui/src/pages/ProjectDetailPage.tsx index 80e4af33..45c28f17 100644 --- a/web-ui/src/pages/ProjectDetailPage.tsx +++ b/web-ui/src/pages/ProjectDetailPage.tsx @@ -53,7 +53,8 @@ export function ProjectDetailPage() { caps.tools.length > 0 || caps.servers.length > 0 || (caps.subagents?.length ?? 0) > 0 || - (caps.rules?.length ?? 0) > 0); + (caps.rules?.length ?? 0) > 0 || + (caps.hooks?.length ?? 0) > 0); const hasVariables = (variables?.required?.length ?? 0) > 0; const hasOAuth = (oauth2Servers?.length ?? 0) > 0; const hasProviders = (caps?.providers?.length ?? 0) > 0; @@ -157,6 +158,7 @@ export function ProjectDetailPage() { servers={caps!.servers} subagents={caps!.subagents ?? []} rules={caps!.rules ?? []} + hooks={caps!.hooks ?? []} projectId={projectId} /> )} diff --git a/web-ui/src/types/api.ts b/web-ui/src/types/api.ts index c1342a19..0a5963cd 100644 --- a/web-ui/src/types/api.ts +++ b/web-ui/src/types/api.ts @@ -90,6 +90,30 @@ export interface Rule { alwaysApply: boolean; } +export interface InstalledHook { + providerId: string; + configPath: string; + scriptPath: string | null; +} + +export interface Hook { + id: string; + description: string | null; + on: string; + type: 'command' | 'prompt'; + providers: string[]; + matcher: string | null; + timeout: number | null; + failClosed: boolean; + sequential: boolean; + /** Source type when the hook body is fetched from outside (`inline`/`remote`/`github`/`gitlab`/`local`). */ + sourceType: 'inline' | 'remote' | 'github' | 'gitlab' | 'local' | null; + command: string | null; + prompt: string | null; + /** One row per provider where capa successfully installed this hook. */ + installed: InstalledHook[]; +} + export interface RequiredCommand { cli: string; description: string | null; @@ -114,6 +138,7 @@ export interface ProjectCapabilities { providers: string[]; subagents: SubAgent[]; rules: Rule[]; + hooks: Hook[]; options: CapabilitiesOptions | null; } From 8db2ee57379a3b21efcc3cf487f59eb2751f9444 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 24 May 2026 21:16:17 +0300 Subject: [PATCH 02/10] refactor(install.sh): update shebang to bash and enhance path management - Changed the shebang from `#!/bin/sh` to `#!/usr/bin/env bash` for better compatibility. - Introduced a new variable `CAPA_MODIFIED_PROFILE` to track modifications to the shell profile. - Added functions to check if the install directory is already in the user's shell profile and to detect the appropriate shell profile based on the user's shell. - Improved the `add_to_path` function to ensure the installation directory is added to the user's PATH in a shell-appropriate manner. - Updated the installation process to always attempt to add the directory to the user's shell profile, regardless of its current presence in the session's PATH. Additionally, made minor adjustments to the capabilities schema and hook management to ensure consistency and clarity in the documentation and code structure. --- install.sh | 173 +++++++++++++++--- .../references/capabilities-schema.md | 3 +- src/cli/utils/hooks-installer.ts | 18 +- src/shared/capabilities.ts | 2 - src/shared/hooks-validate.ts | 1 - src/types/capabilities.ts | 8 +- src/types/hooks.ts | 17 +- .../projects/components/HooksList.tsx | 21 --- web-ui/src/locales/en/projects.json | 2 - 9 files changed, 176 insertions(+), 69 deletions(-) diff --git a/install.sh b/install.sh index f11ec8ba..57315c70 100644 --- a/install.sh +++ b/install.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # CAPA Installer Script for macOS and Linux # Licensed under the MIT license # @@ -8,6 +8,10 @@ set -u +# Tracks whether add_to_path actually modified a shell profile during this run. +# Used to decide whether to print the "restart your shell" reminder. +CAPA_MODIFIED_PROFILE=0 + APP_NAME="capa" GITHUB_REPO="infragate/capa" FALLBACK_VERSION="1.0.0" # Fallback version if API request fails @@ -239,44 +243,154 @@ is_in_path() { esac } -# Add directory to shell profile +# Return 0 if the given shell profile already references the install dir, +# either as the expanded absolute path or as $HOME-prefixed form. +profile_has_dir() { + local _profile="$1" + local _dir="$2" + local _rel + + [ ! -f "$_profile" ] && return 1 + + # Literal expanded path match (this is what the installer writes). + if grep -qF -- "$_dir" "$_profile"; then + return 0 + fi + + # If _dir is under $HOME, also accept $HOME / ${HOME} prefixed forms + # written by hand by the user. + if [ -n "${HOME:-}" ]; then + case "$_dir" in + "$HOME"/*) + _rel="${_dir#"$HOME"/}" + if grep -qE "\\\$\{?HOME\}?/${_rel}" "$_profile"; then + return 0 + fi + ;; + esac + fi + + return 1 +} + +# Pick the shell profile file most appropriate for the USER'S shell (not the +# shell this script is running in). We deliberately ignore $BASH_VERSION and +# $ZSH_VERSION here -- those reflect the interpreter executing install.sh +# (which is bash via our shebang), NOT the user's login shell. Trusting them +# would always pick the bash branch even for zsh users. +detect_shell_profile() { + local _shell_profile="" + local _ostype + _ostype="$(uname -s 2>/dev/null || echo unknown)" + + # Primary signal: the user's login shell. + case "${SHELL:-}" in + */zsh) + _shell_profile="${HOME}/.zshrc" + ;; + */bash) + # On macOS, login bash sessions read .bash_profile (not .bashrc). + # On Linux, interactive non-login bash reads .bashrc. + if [ "$_ostype" = "Darwin" ]; then + if [ -f "${HOME}/.bash_profile" ]; then + _shell_profile="${HOME}/.bash_profile" + elif [ -f "${HOME}/.bashrc" ]; then + _shell_profile="${HOME}/.bashrc" + else + _shell_profile="${HOME}/.bash_profile" + fi + else + if [ -f "${HOME}/.bashrc" ]; then + _shell_profile="${HOME}/.bashrc" + elif [ -f "${HOME}/.bash_profile" ]; then + _shell_profile="${HOME}/.bash_profile" + else + _shell_profile="${HOME}/.bashrc" + fi + fi + ;; + */fish) + _shell_profile="${HOME}/.config/fish/config.fish" + ;; + *) + # $SHELL is unset or unrecognized -- fall back to which rc file + # actually exists, biased toward zsh (macOS default since 10.15). + if [ -f "${HOME}/.zshrc" ]; then + _shell_profile="${HOME}/.zshrc" + elif [ "$_ostype" = "Darwin" ] && [ -f "${HOME}/.bash_profile" ]; then + _shell_profile="${HOME}/.bash_profile" + elif [ -f "${HOME}/.bashrc" ]; then + _shell_profile="${HOME}/.bashrc" + elif [ -f "${HOME}/.bash_profile" ]; then + _shell_profile="${HOME}/.bash_profile" + elif [ -f "${HOME}/.profile" ]; then + _shell_profile="${HOME}/.profile" + else + # Nothing exists -- create the canonical file for the platform. + if [ "$_ostype" = "Darwin" ]; then + _shell_profile="${HOME}/.zshrc" + else + _shell_profile="${HOME}/.profile" + fi + fi + ;; + esac + + RETVAL="$_shell_profile" +} + +# Add directory to shell profile. Sets CAPA_MODIFIED_PROFILE=1 if it actually +# writes a new export line; returns without writing if the profile already +# references the directory. +# +# IMPORTANT: this function intentionally does NOT consult the live $PATH. The +# fact that $_dir is currently in $PATH does not imply it is persistently +# configured in the user's shell profile (e.g. a parent process may have +# injected it for this session only). Always check the profile. add_to_path() { local _dir="$1" local _shell_profile + local _profile_dir if [ "1" = "$CAPA_NO_MODIFY_PATH" ]; then return 0 fi - # Detect shell profile file - if [ -n "${BASH_VERSION:-}" ]; then - if [ -f "${HOME}/.bashrc" ]; then - _shell_profile="${HOME}/.bashrc" - else - _shell_profile="${HOME}/.bash_profile" - fi - elif [ -n "${ZSH_VERSION:-}" ] || [ -f "${HOME}/.zshrc" ]; then - _shell_profile="${HOME}/.zshrc" - elif [ -f "${HOME}/.profile" ]; then - _shell_profile="${HOME}/.profile" - else - _shell_profile="${HOME}/.bash_profile" - fi + detect_shell_profile + _shell_profile="$RETVAL" - # Check if already in profile - if [ -f "$_shell_profile" ] && grep -q "export PATH=\"${_dir}:\$PATH\"" "$_shell_profile"; then + if profile_has_dir "$_shell_profile" "$_dir"; then say_verbose "PATH already configured in $_shell_profile" return 0 fi + # Ensure the parent directory exists (e.g. ~/.config/fish/ for fish users). + _profile_dir="$(dirname "$_shell_profile")" + if [ ! -d "$_profile_dir" ]; then + ensure mkdir -p "$_profile_dir" + fi + info "Adding ${_dir} to PATH in ${_shell_profile}" - - cat >> "$_shell_profile" <> "$_shell_profile" <> "$_shell_profile" </` (NOT inside the project) and the provider entry references that absolute path. +When `source` is set, the resolved body is materialised under `~/.capa/hooks//` (NOT inside the project), chmod +x, and the provider entry's `command` references that absolute path. ```yaml hooks: diff --git a/src/cli/utils/hooks-installer.ts b/src/cli/utils/hooks-installer.ts index a8869108..e39ea086 100644 --- a/src/cli/utils/hooks-installer.ts +++ b/src/cli/utils/hooks-installer.ts @@ -126,7 +126,6 @@ export async function installHooks(opts: InstallHooksOptions): Promise/` and - * (optionally) chmod +x. Returns the absolute script path. + * chmod +x. Returns the absolute script path. + * + * The materialised path is wired into the provider entry's `command` field + * verbatim, so the file MUST be executable for the hook to fire — there is + * no knob to opt out. */ function materialiseHookScript(input: { projectId: string; hookId: string; body: string; - executable: boolean; }): string { const dir = getHookScriptDir(input.projectId); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const file = join(dir, input.hookId); writeFileSync(file, input.body, 'utf-8'); - if (input.executable) { - try { - chmodSync(file, 0o755); - } catch { - // chmod is best-effort on platforms that don't support it. - } + try { + chmodSync(file, 0o755); + } catch { + // chmod is best-effort on platforms that don't support it. } return file; } diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index ae5a01f6..bc52dfc7 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -9,7 +9,6 @@ const KNOWN_CAPABILITY_KEYS = new Set([ 'servers', 'tools', 'plugins', - 'resolvedPlugins', 'options', 'agents', 'subagents', @@ -26,7 +25,6 @@ const capabilitiesSchema = z servers: z.preprocess((val) => val ?? [], z.array(objectEntry)), tools: z.preprocess((val) => val ?? [], z.array(objectEntry)), plugins: z.preprocess((val) => val ?? [], z.array(objectEntry)), - resolvedPlugins: z.array(objectEntry).optional(), options: z.preprocess((val) => val ?? {}, z.record(z.string(), z.unknown())), agents: z.record(z.string(), z.unknown()).optional(), subagents: z.preprocess((val) => val ?? [], z.array(objectEntry)), diff --git a/src/shared/hooks-validate.ts b/src/shared/hooks-validate.ts index 49fb448c..7b9856e5 100644 --- a/src/shared/hooks-validate.ts +++ b/src/shared/hooks-validate.ts @@ -64,7 +64,6 @@ function validateSource( if (typeof obj.content === "string") result.content = obj.content; if (typeof obj.url === "string") result.url = obj.url; if (typeof obj.path === "string") result.path = obj.path; - if (typeof obj.executable === "boolean") result.executable = obj.executable; if (obj.def && typeof obj.def === "object" && !Array.isArray(obj.def)) { const def = obj.def as Record; if (typeof def.repo === "string") { diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index 9408b295..d5be5410 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -28,7 +28,13 @@ export interface OAuth2Config { export type CapabilitiesFormat = 'json' | 'yaml'; export type { Plugin, SourcePlugin, ResolvedPluginInfo } from './plugin'; export type { Rule } from './rules'; -export type { Hook, HookSource, CanonicalHookEvent, ProviderScopedEvent } from './hooks'; +export type { + CanonicalHookEvent, + Hook, + HookSource, + HookSourceDef, + ProviderScopedEvent, +} from './hooks'; /** * Tool exposure modes for MCP clients diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 8fdd3146..99c37cf8 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -7,7 +7,18 @@ * (e.g. `.claude/settings.json`) without disturbing user-authored entries. */ -import type { AgentSnippetDef } from './capabilities'; +/** + * Repo locator for `source: { type: 'github' | 'gitlab' }`. + * + * Same string grammar as skills (`owner/repo`, `owner/repo@search`, + * `owner/repo::path/inside/repo`, with optional `:version` / `#sha` suffixes). + * Defined locally instead of reusing `AgentSnippetDef` so the hook subsystem + * stays decoupled from the AGENTS.md / CLAUDE.md snippet flow — they happen + * to share a shape today, but mean different things. + */ +export interface HookSourceDef { + repo: string; +} /** * Canonical lifecycle events. @@ -71,10 +82,8 @@ export interface HookSource { type: 'inline' | 'remote' | 'github' | 'gitlab' | 'local'; content?: string; url?: string; - def?: AgentSnippetDef; + def?: HookSourceDef; path?: string; - /** When true (default) the materialised script is chmod +x. */ - executable?: boolean; } /** diff --git a/web-ui/src/features/projects/components/HooksList.tsx b/web-ui/src/features/projects/components/HooksList.tsx index 65d71923..39aeebb8 100644 --- a/web-ui/src/features/projects/components/HooksList.tsx +++ b/web-ui/src/features/projects/components/HooksList.tsx @@ -146,27 +146,6 @@ function HookItem({ hook, search }: { hook: Hook; search: string }) { )} - {hook.installed.length > 0 && ( -
- {t('hooks.installed')}: -
    - {hook.installed.map((entry) => ( -
  • -
    - {entry.providerId} - {' — '} - {entry.configPath} -
    - {entry.scriptPath && ( -
    - {t('hooks.script')}: {entry.scriptPath} -
    - )} -
  • - ))} -
-
- )} )} diff --git a/web-ui/src/locales/en/projects.json b/web-ui/src/locales/en/projects.json index c507498e..a27d034e 100644 --- a/web-ui/src/locales/en/projects.json +++ b/web-ui/src/locales/en/projects.json @@ -126,9 +126,7 @@ "source": "Source", "command": "Command", "prompt": "Prompt", - "installed": "Installed entries", "installedOn": "Installed on", - "script": "Script", "allProviders": "All providers" }, "providers": { From 696be5d1d3962fb8425fb1a937e5c2b2e1339369 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 24 May 2026 21:38:13 +0300 Subject: [PATCH 03/10] fix(hooks): address Copilot review feedback on PR #72 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - validateHooks() now rejects unsafe hook ids (path separators, '..', control chars) since ids are used both as filenames under ~/.capa/hooks// and as provider tags. materialiseHookScript re-validates as defence in depth and rejects any path that escapes the hook script dir. Correctness: - resolveMatcher() now combines matcherPrefix with a user-supplied matcher (regex alternation) instead of dropping the canonical tool-family scope when the user adds their own matcher. - pruneOrphanHooks() only deletes the managed_hooks DB row after the on-disk entry was removed, so a transient unlink failure doesn't leave an orphan in the provider config forever. - readJsonFile() returns null on parse-error / wrong-shape and the installer refuses to overwrite, instead of silently clobbering a corrupted user config with {}. - Server pre-fetches managed_hooks once and groups by hookId so /api/projects/:id stays O(n) for projects with many hooks. Provider mapping accuracy: - Cursor registry now exposes the full set of canonical→provider mappings (afterShell, afterMcpCall, sessionStart, sessionEnd, beforeTool, afterTool, afterToolFailure, subagentStart, subagentStop, preCompact) to match cursor.com/docs/agent/hooks. - Gemini registry replaces the made-up PreToolUse/PostToolUse names with the real BeforeTool/AfterTool, drops the non-existent Stop mapping, and uses Gemini's actual built-in tool names (run_shell_command, read_file, write_file|replace|edit_file, mcp_*). - Codex registry replaces the made-up PreShellExec/PostShellExec with Codex's real Claude-style PreToolUse/PostToolUse + tool-name matchers (Bash, apply_patch, mcp__). - Provider docs (cursor.md, gemini-cli.md, codex.md) updated to match the registries; gemini docs now cite docs/hooks/reference.md, codex docs cite developers.openai.com/codex/hooks. Lockfile: - write-lockfile validates hooks before deriving hookIdsForLock so invalid hook entries can't keep stale lock pins alive past install. Tests: - New hooks-validate cases for path-traversal/unsafe ids and a matching positive case. - New hook-handlers cases for matcherPrefix + user matcher union. - New hooks-installer case that verifies pruneOrphanHooks keeps the DB row when on-disk removal fails (so the next run can retry). Co-authored-by: Cursor --- docs/providers/codex.md | 21 ++++-- docs/providers/cursor.md | 12 ++-- docs/providers/gemini-cli.md | 17 +++-- .../commands/install-tasks/write-lockfile.ts | 14 ++-- .../utils/__tests__/hooks-installer.test.ts | 41 +++++++++++- src/cli/utils/hooks-installer.ts | 64 +++++++++++++++--- src/server/index.ts | 23 ++++--- src/shared/__tests__/hooks-validate.test.ts | 29 ++++++++ src/shared/hooks-validate.ts | 24 +++++++ .../providers/__tests__/hook-handlers.test.ts | 21 ++++++ src/shared/providers/hook-handlers.ts | 34 +++++++--- src/shared/providers/registry.ts | 67 ++++++++++++++++--- 12 files changed, 308 insertions(+), 59 deletions(-) diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 29b5f16a..f9e7bee9 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -20,11 +20,22 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → codex`](../.. ## Hooks event mapping -Codex's hook surface is narrower than Claude's: capa only writes -canonical events that map onto Codex's documented `[hooks]` keys -(`beforeTool`, `afterTool`, `sessionStart`, `sessionEnd`, -`userPromptSubmit`). Hooks targeting events Codex does not support are -skipped with a one-shot warning. +Codex uses Claude-style event names plus a tool-name matcher; built-in +tools include `Bash` and `apply_patch`, and MCP tools follow the +`mcp__server__tool` pattern. Canonical → Codex: +`sessionStart → SessionStart`, `userPromptSubmit → UserPromptSubmit`, +`beforeTool → PreToolUse`, `afterTool → PostToolUse`, +`beforeShell → PreToolUse` + `matcher: Bash`, +`afterShell → PostToolUse` + `matcher: Bash`, +`afterFileEdit → PostToolUse` + `matcher: apply_patch`, +`beforeMcpCall → PreToolUse` + `matcher: mcp__`, +`afterMcpCall → PostToolUse` + `matcher: mcp__`, +`subagentStart → SubagentStart`, `subagentStop → SubagentStop`, +`preCompact → PreCompact`, `stop → Stop`. Codex does not expose a +`sessionEnd` or `beforeFileRead` equivalent — those canonical hooks are +skipped on Codex with a one-shot warning. Codex-specific events (e.g. +`PermissionRequest`, `PostCompact`) can be targeted directly with +`on: codex:`. ## Sources diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index f6854daf..1bba49e7 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -20,13 +20,17 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → cursor`](../. ## Hooks event mapping -Canonical → Cursor: `beforeShell → beforeShellExecution`, +Canonical → Cursor: `sessionStart → sessionStart`, `sessionEnd → sessionEnd`, +`beforeTool → preToolUse`, `afterTool → postToolUse`, +`afterToolFailure → postToolUseFailure`, `beforeShell → beforeShellExecution`, `afterShell → afterShellExecution`, `beforeFileRead → beforeReadFile`, `afterFileEdit → afterFileEdit`, `beforeMcpCall → beforeMCPExecution`, `afterMcpCall → afterMCPExecution`, `userPromptSubmit → beforeSubmitPrompt`, -`stop → stop`. Cursor-specific events (e.g. `beforeShellExecution`) can -be targeted directly with `on: cursor:` to bypass the -canonical map. +`subagentStart → subagentStart`, `subagentStop → subagentStop`, +`preCompact → preCompact`, `stop → stop`. Cursor-only events (e.g. +`afterAgentResponse`, `workspaceOpen`, `beforeTabFileRead`) can be +targeted directly with `on: cursor:` to bypass the canonical +map. ## Sources diff --git a/docs/providers/gemini-cli.md b/docs/providers/gemini-cli.md index c9ad4741..9830916b 100644 --- a/docs/providers/gemini-cli.md +++ b/docs/providers/gemini-cli.md @@ -20,11 +20,18 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → gemini-cli`]( ## Hooks event mapping -Canonical → Gemini: `beforeTool → PreToolUse`, `afterTool → PostToolUse`, -`userPromptSubmit → UserPromptSubmit`, `sessionStart → SessionStart`, -`stop → Stop`. Gemini's hook surface tracks Claude's, so the bash-style -matcher rewrite (`beforeShell` ⇒ `PreToolUse` + `matcher: Bash`) applies -here too. +Canonical → Gemini: `sessionStart → SessionStart`, `sessionEnd → SessionEnd`, +`userPromptSubmit → BeforeAgent`, `beforeTool → BeforeTool`, +`afterTool → AfterTool`, `beforeShell → BeforeTool` + `matcher: run_shell_command`, +`afterShell → AfterTool` + `matcher: run_shell_command`, +`beforeFileRead → BeforeTool` + `matcher: read_file`, +`afterFileEdit → AfterTool` + `matcher: write_file|replace|edit_file`, +`beforeMcpCall → BeforeTool` + `matcher: mcp_.*`, +`afterMcpCall → AfterTool` + `matcher: mcp_.*`, `preCompact → PreCompress`. +Gemini does not expose a `Stop` equivalent, so canonical `stop` hooks are +skipped on this provider with a one-shot warning. Gemini-only events +(e.g. `BeforeToolSelection`, `BeforeModel`, `AfterModel`, `Notification`) +can be targeted directly with `on: gemini-cli:`. ## Caveats diff --git a/src/cli/commands/install-tasks/write-lockfile.ts b/src/cli/commands/install-tasks/write-lockfile.ts index 4184302c..db510cd8 100644 --- a/src/cli/commands/install-tasks/write-lockfile.ts +++ b/src/cli/commands/install-tasks/write-lockfile.ts @@ -2,6 +2,7 @@ import { existsSync, rmSync } from 'fs'; import { join } from 'path'; import type { Task } from '../../ui'; import { saveLockfile } from '../../../shared/lockfile'; +import { validateHooks } from '../../../shared/hooks-validate'; import type { InstallCtx } from './context'; export function writeLockfileTask(): Task { @@ -18,14 +19,19 @@ export function writeLockfileTask(): Task { ); // Hooks with a remote/repo source are pinned in the lockfile; inline / // local hooks travel inside the capabilities file or workspace and - // don't need pinning. + // don't need pinning. Validate the raw hooks first so an invalid + // entry (missing id, unsafe id, etc.) doesn't keep a stale lock pin + // alive — install-hooks will skip it anyway, and pruneToIds must + // mirror that decision. + const rawHooks = (ctx.capabilitiesToUse.hooks ?? []) as unknown[]; + const { valid: validHooks } = validateHooks(rawHooks); const hookIdsForLock = new Set( - (ctx.capabilitiesToUse.hooks ?? []) + validHooks .filter((h) => { - const t = (h as { source?: { type?: string } }).source?.type; + const t = h.source?.type; return t === 'github' || t === 'gitlab' || t === 'remote'; }) - .map((h) => (h as { id: string }).id), + .map((h) => h.id), ); ctx.lockBuilder.pruneToIds(skillIdsForLock, pluginIdsForLock, hookIdsForLock); const lockfileToSave = ctx.lockBuilder.build(); diff --git a/src/cli/utils/__tests__/hooks-installer.test.ts b/src/cli/utils/__tests__/hooks-installer.test.ts index 2abc0d1f..5e590d53 100644 --- a/src/cli/utils/__tests__/hooks-installer.test.ts +++ b/src/cli/utils/__tests__/hooks-installer.test.ts @@ -142,12 +142,15 @@ describe('hooks-installer (claude inline-config)', () => { }); it('warns when canonical event has no mapping for provider', async () => { + // Codex's hook surface has no `beforeFileRead` equivalent (it routes + // file ops through PostToolUse + apply_patch instead), so it's a + // stable choice for "canonical event with no mapping for provider". const result = await installHooks({ projectPath, projectId, capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), - hooks: [{ id: 'pc', on: 'preCompact', command: 'echo pre-compact' }], - providers: ['cursor'], + hooks: [{ id: 'br', on: 'beforeFileRead', command: 'echo read' }], + providers: ['codex'], db, authFetch: makeAuthFetch(), getRepoSnapshot: stubGetRepoSnapshot, @@ -287,6 +290,40 @@ describe('hooks-installer — pruneOrphanHooks / cleanHooks', () => { expect(bashHooks[0].command).toBe('echo a'); }); + it('keeps the managed_hooks row when on-disk prune fails so future runs can retry', async () => { + // Install a hook, then corrupt the locator so removeManagedHookEntry + // throws. The DB row should survive the failed prune (warn-but-retry) + // instead of leaving an orphan entry in the provider config forever. + await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [{ id: 'orphan', on: 'beforeShell', command: 'echo orphan' }], + providers: ['claude-code'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + expect(db.getManagedHooks(projectId)).toHaveLength(1); + + // Corrupt the stored locator so JSON.parse-then-validate throws. + db.upsertManagedHook({ + projectId, + providerId: 'claude-code', + hookId: 'orphan', + configPath: join(projectPath, '.claude', 'settings.json'), + locator: '"not-an-array"', + scriptPath: null, + }); + expect(db.getManagedHooks(projectId)[0].locator).toBe('"not-an-array"'); + + const result = pruneOrphanHooks(projectPath, projectId, [], ['claude-code'], db); + expect(result.removed).toBe(0); + expect(result.warnings.some((w) => /prune failed/.test(w))).toBe(true); + // DB row preserved so the next run can retry — no orphan in config. + expect(db.getManagedHooks(projectId)).toHaveLength(1); + }); + it('cleanHooks wipes every entry and config keys for the project', async () => { await installHooks({ projectPath, diff --git a/src/cli/utils/hooks-installer.ts b/src/cli/utils/hooks-installer.ts index e39ea086..e014f12d 100644 --- a/src/cli/utils/hooks-installer.ts +++ b/src/cli/utils/hooks-installer.ts @@ -23,7 +23,7 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from 'fs'; -import { dirname, join, resolve as resolvePath } from 'path'; +import { dirname, join, resolve as resolvePath, sep as pathSep } from 'path'; import { createHash } from 'crypto'; import type { CapaDatabase } from '../../db/database'; import type { ManagedHookRow } from '../../db/managed-hooks'; @@ -43,6 +43,7 @@ import type { AuthenticatedFetch } from '../../shared/authenticated-fetch'; import type { CachePlatform, GetSnapshotResult } from '../../shared/cache'; import { fetchRepoFile, fetchTextFile } from '../../shared/repo-file'; import { getHookScriptDir } from '../../shared/config'; +import { isSafeHookId } from '../../shared/hooks-validate'; import { readTomlFile, writeTomlFile } from '../../shared/toml-io'; import { taskLog } from '../ui'; @@ -243,11 +244,13 @@ export function pruneOrphanHooks( try { removeManagedHookEntry(projectPath, row); removed++; + // Only drop the DB locator after the on-disk entry was removed; if + // unlinking failed we keep the row so a future install/clean can + // retry instead of leaving the provider config dirty forever. + db.removeManagedHook(row.projectId, row.providerId, row.hookId); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); warnings.push(`Hook "${row.hookId}" on "${row.providerId}": prune failed — ${msg}`); - } finally { - db.removeManagedHook(row.projectId, row.providerId, row.hookId); } } return { removed, warnings }; @@ -364,9 +367,23 @@ function materialiseHookScript(input: { hookId: string; body: string; }): string { + // Defence in depth: validateHooks() should have already rejected unsafe + // ids, but installHooks() is also reachable from callers that pass a raw + // Hook[]. Refuse to write outside the project's hook script dir. + if (!isSafeHookId(input.hookId)) { + throw new Error( + `unsafe hook id "${input.hookId}" — refusing to materialise script`, + ); + } const dir = getHookScriptDir(input.projectId); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const file = join(dir, input.hookId); + // Sanity check: the resolved path must be a direct child of `dir`. + const resolvedFile = resolvePath(file); + const resolvedDir = resolvePath(dir); + if (!resolvedFile.startsWith(resolvedDir + pathSep)) { + throw new Error(`hook script path escapes hook script dir: ${resolvedFile}`); + } writeFileSync(file, input.body, 'utf-8'); try { chmodSync(file, 0o755); @@ -401,6 +418,11 @@ function applyHookEntryToConfig(args: ApplyEntryArgs): { configPath: string; loc // JSON-based storage (standalone or inline-config). const config = readJsonFile(configPath); + if (config === null) { + throw new Error( + `existing config at ${configPath} is not a valid JSON object — refusing to overwrite. Fix the file by hand and re-run capa install.`, + ); + } let hooksRoot: Record; if (integration.storage.kind === 'standalone') { @@ -469,6 +491,11 @@ function removeManagedHookEntry(projectPath: string, row: ManagedHookRow): void writeTomlFile(configPath, config); } else { const config = readJsonFile(configPath); + if (config === null) { + throw new Error( + `existing config at ${configPath} is not a valid JSON object — refusing to rewrite. Fix the file by hand and re-run.`, + ); + } let root: Record | null; let rootKey: string | null = null; if (integration.storage.kind === 'standalone') { @@ -538,17 +565,34 @@ function resolveProviderEventName( // Small file helpers // --------------------------------------------------------------------------- -function readJsonFile(path: string): Record { +/** + * Read a JSON config file as a plain object. + * + * - Missing / empty file → `{}` (fresh start, safe to write). + * - Valid JSON object → the parsed object. + * - Corrupted JSON or wrong shape → `null`. Callers must NOT overwrite + * in this case, otherwise a transient parse error / hand-edit could + * silently wipe the user's provider config (issues #5). + */ +function readJsonFile(path: string): Record | null { if (!existsSync(path)) return {}; + let raw: string; + try { + raw = readFileSync(path, 'utf-8'); + } catch { + return null; + } + if (!raw.trim()) return {}; + let parsed: unknown; try { - const raw = readFileSync(path, 'utf-8'); - if (!raw.trim()) return {}; - const parsed: unknown = JSON.parse(raw); - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; - return parsed as Record; + parsed = JSON.parse(raw); } catch { - return {}; + return null; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; } + return parsed as Record; } function writeJsonFile(path: string, data: Record): void { diff --git a/src/server/index.ts b/src/server/index.ts index 82a1327d..328137f2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -526,7 +526,19 @@ class CapaServer { } const capabilities = this.sessionManager.getProjectCapabilities(projectId); - + + // Pre-fetch managed hooks once and group by hookId so the per-hook + // map below stays O(n) instead of issuing one DB query per hook. + const installedHooksByHookId = new Map(); + if (capabilities && (capabilities.hooks || []).length > 0) { + for (const m of this.db.getManagedHooks(projectId)) { + const list = installedHooksByHookId.get(m.hookId); + const entry = { providerId: m.providerId, configPath: m.configPath, scriptPath: m.scriptPath }; + if (list) list.push(entry); + else installedHooksByHookId.set(m.hookId, [entry]); + } + } + const projectDetails = { id: project.id, path: project.path, @@ -604,14 +616,7 @@ class CapaServer { sourceType: h.source?.type || null, command: h.command ?? null, prompt: h.prompt ?? null, - installed: this.db - .getManagedHooks(projectId) - .filter(m => m.hookId === h.id) - .map(m => ({ - providerId: m.providerId, - configPath: m.configPath, - scriptPath: m.scriptPath, - })), + installed: installedHooksByHookId.get(h.id) ?? [], })), options: capabilities.options ? { toolExposure: capabilities.options.toolExposure || null, diff --git a/src/shared/__tests__/hooks-validate.test.ts b/src/shared/__tests__/hooks-validate.test.ts index 01cac1ba..ba753a97 100644 --- a/src/shared/__tests__/hooks-validate.test.ts +++ b/src/shared/__tests__/hooks-validate.test.ts @@ -35,6 +35,35 @@ describe('validateHooks', () => { expect(issues[0].message).toContain("'id'"); }); + it('rejects path-traversal hook ids', () => { + const cases = [ + '../escape', + 'foo/bar', + 'foo\\bar', + 'a..b', + '.hidden', + '-leading-dash', + 'has spaces', + 'has;semicolon', + 'with$shell', + ]; + for (const id of cases) { + const { valid, issues } = validateHooks([{ id, on: 'sessionStart', command: 'echo' }]); + expect(valid).toHaveLength(0); + expect(issues).toHaveLength(1); + expect(issues[0].message).toMatch(/unsafe characters/); + } + }); + + it('accepts safe hook ids', () => { + const cases = ['ok', 'audit-shell', 'lint_staged', 'a.b.c', 'block-rm-rf', 'hook123']; + for (const id of cases) { + const { valid, issues } = validateHooks([{ id, on: 'sessionStart', command: 'echo' }]); + expect(issues).toEqual([]); + expect(valid).toHaveLength(1); + } + }); + it('skips duplicates after the first', () => { const { valid, issues } = validateHooks([ { id: 'a', on: 'sessionStart', command: 'first' }, diff --git a/src/shared/hooks-validate.ts b/src/shared/hooks-validate.ts index 7b9856e5..9beec265 100644 --- a/src/shared/hooks-validate.ts +++ b/src/shared/hooks-validate.ts @@ -22,6 +22,23 @@ const KNOWN_SOURCE_TYPES: ReadonlySet = new Set([ "local", ]); +/** + * Hook ids are used both as filenames (`~/.capa/hooks//`) + * and as provider-config tags (`capa:`). The strict allow-list keeps a + * crafted id from escaping the hook script directory or producing weird + * provider entries. Stay in sync with `isSafeHookId()` in hooks-installer. + */ +const SAFE_HOOK_ID_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,62})$/; + +export function isSafeHookId(id: string): boolean { + if (!SAFE_HOOK_ID_RE.test(id)) return false; + // Defence in depth: reject `..`, `/`, `\`, and explicit traversal patterns + // even if they happen to slip past the regex on a future edit. + if (id.includes("..")) return false; + if (id.includes("/") || id.includes("\\")) return false; + return true; +} + export interface HookValidationIssue { hookId: string | null; message: string; @@ -120,6 +137,13 @@ export function validateHooks(raw: unknown[]): HookValidationResult { }); continue; } + if (!isSafeHookId(obj.id)) { + issues.push({ + hookId: obj.id, + message: `hook "${obj.id}" id contains unsafe characters; allowed: [a-zA-Z0-9._-], must start with alphanumeric, no '..', '/', or '\\'`, + }); + continue; + } if (seenIds.has(obj.id)) { issues.push({ hookId: obj.id, diff --git a/src/shared/providers/__tests__/hook-handlers.test.ts b/src/shared/providers/__tests__/hook-handlers.test.ts index 1f5f0148..4e1042b3 100644 --- a/src/shared/providers/__tests__/hook-handlers.test.ts +++ b/src/shared/providers/__tests__/hook-handlers.test.ts @@ -66,6 +66,27 @@ describe('hook-handlers — buildHookEntry', () => { expect(out.entry.name).toBe('capa:block-rm'); }); + it('claude shape unions matcherPrefix with user matcher (preserves canonical scope)', () => { + const hook: Hook = { id: 'edit-ts', on: 'afterFileEdit', command: 'echo', matcher: 'src/.+\\.ts' }; + const out = buildHookEntry(claudeIntegration, { + hook, + runReference: '/abs/script', + mapping: { event: 'PostToolUse', matcherPrefix: 'Edit|MultiEdit|Write' }, + }); + expect(out.eventName).toBe('PostToolUse'); + expect(out.matcher).toBe('Edit|MultiEdit|Write|src/.+\\.ts'); + }); + + it('claude shape skips union when user matcher equals prefix', () => { + const hook: Hook = { id: 'shell', on: 'beforeShell', command: 'echo', matcher: 'Bash' }; + const out = buildHookEntry(claudeIntegration, { + hook, + runReference: '/abs/script', + mapping: { event: 'PreToolUse', matcherPrefix: 'Bash' }, + }); + expect(out.matcher).toBe('Bash'); + }); + it('codex-toml shape emits id without name tag', () => { const hook: Hook = { id: 'audit-shell', on: 'beforeShell', command: 'echo' }; const out = buildHookEntry(codexIntegration, { diff --git a/src/shared/providers/hook-handlers.ts b/src/shared/providers/hook-handlers.ts index c6264b3e..ec7f3bd8 100644 --- a/src/shared/providers/hook-handlers.ts +++ b/src/shared/providers/hook-handlers.ts @@ -73,17 +73,33 @@ function idFromNameTag(tag: string): string { /** * Pick a matcher value for the entry. * - * Precedence: - * 1. user-supplied `hook.matcher` (allows fine-grained scoping) - * 2. mapping's `matcherPrefix` (fold-down helper, e.g. canonical - * `beforeShell` -> claude `PreToolUse` matcher `Bash`) - * 3. empty string (no matcher; shape decides) + * `matcherPrefix` is the canonical event's implicit tool-family scope (e.g. + * `beforeShell -> PreToolUse` is implicitly scoped to `Bash`). It MUST be + * preserved when emitted, otherwise the canonical event silently widens to + * every tool and breaks the documented contract. + * + * Behaviour: + * - prefix only -> use prefix + * - user matcher only -> use user matcher (no canonical scope to fold in) + * - both -> union them as a regex alternation + * `(prefix)|(userMatcher)` so the canonical + * tool family is still covered while the user's + * extra tool names also fire. The user can bypass + * this fold-in by switching to the provider-scoped + * event form (e.g. `on: claude-code:PreToolUse`). + * - neither -> empty (shape decides) */ function resolveMatcher(input: HookEntryInput): string { - if (input.hook.matcher && input.hook.matcher.length > 0) - return input.hook.matcher; - if ("matcherPrefix" in input.mapping) return input.mapping.matcherPrefix; - return ""; + const userMatcher = + input.hook.matcher && input.hook.matcher.length > 0 + ? input.hook.matcher + : ""; + const prefix = + "matcherPrefix" in input.mapping ? input.mapping.matcherPrefix : ""; + if (prefix && userMatcher) { + return prefix === userMatcher ? prefix : `${prefix}|${userMatcher}`; + } + return userMatcher || prefix; } // --------------------------------------------------------------------------- diff --git a/src/shared/providers/registry.ts b/src/shared/providers/registry.ts index 4d1286a8..bc2253a6 100644 --- a/src/shared/providers/registry.ts +++ b/src/shared/providers/registry.ts @@ -192,7 +192,11 @@ export const providers: Record = { }, hooks: { // Codex reads hooks from .codex/config.toml as a [hooks] table. - // Docs: https://github.com/openai/codex/blob/main/docs/config.md + // Codex uses Claude-style event names (PreToolUse/PostToolUse) and a + // tool-name matcher; built-in tools include `Bash`, `apply_patch`, and + // MCP tools follow the `mcp__server__tool` naming pattern. Events + // without a matcher are: UserPromptSubmit, Stop. + // Docs: https://developers.openai.com/codex/hooks storage: { kind: 'inline-config', configPath: '.codex/config.toml', @@ -203,8 +207,18 @@ export const providers: Record = { supportsNameTag: false, eventMap: { sessionStart: { event: 'SessionStart' }, - beforeShell: { event: 'PreShellExec' }, - afterShell: { event: 'PostShellExec' }, + userPromptSubmit: { event: 'UserPromptSubmit' }, + beforeTool: { event: 'PreToolUse' }, + afterTool: { event: 'PostToolUse' }, + beforeShell: { event: 'PreToolUse', matcherPrefix: 'Bash' }, + afterShell: { event: 'PostToolUse', matcherPrefix: 'Bash' }, + afterFileEdit: { event: 'PostToolUse', matcherPrefix: 'apply_patch' }, + beforeMcpCall: { event: 'PreToolUse', matcherPrefix: 'mcp__' }, + afterMcpCall: { event: 'PostToolUse', matcherPrefix: 'mcp__' }, + subagentStart: { event: 'SubagentStart' }, + subagentStop: { event: 'SubagentStop' }, + preCompact: { event: 'PreCompact' }, + stop: { event: 'Stop' }, }, }, }, @@ -283,12 +297,27 @@ export const providers: Record = { }, shape: 'cursor', supportsNameTag: true, + // Cursor's canonical/provider event surface (see + // https://cursor.com/docs/agent/hooks). We map every canonical event + // Cursor has a documented equivalent for; provider-only events (e.g. + // `afterAgentResponse`, `workspaceOpen`, `beforeTabFileRead`) can be + // targeted directly via `on: cursor:`. eventMap: { + sessionStart: { event: 'sessionStart' }, + sessionEnd: { event: 'sessionEnd' }, + beforeTool: { event: 'preToolUse' }, + afterTool: { event: 'postToolUse' }, + afterToolFailure: { event: 'postToolUseFailure' }, beforeShell: { event: 'beforeShellExecution' }, + afterShell: { event: 'afterShellExecution' }, beforeMcpCall: { event: 'beforeMCPExecution' }, + afterMcpCall: { event: 'afterMCPExecution' }, beforeFileRead: { event: 'beforeReadFile' }, afterFileEdit: { event: 'afterFileEdit' }, userPromptSubmit: { event: 'beforeSubmitPrompt' }, + subagentStart: { event: 'subagentStart' }, + subagentStop: { event: 'subagentStop' }, + preCompact: { event: 'preCompact' }, stop: { event: 'stop' }, }, }, @@ -342,10 +371,16 @@ export const providers: Record = { }, hooks: { // Gemini CLI hooks live alongside its other settings in - // .gemini/settings.json under a top-level `hooks` key. Format and - // shape mirror Claude's claude-style entries closely enough that we - // share the same `claude` shape. - // Docs: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/configuration.md + // .gemini/settings.json under a top-level `hooks` key. The shape + // (matcher group + nested `hooks` array) mirrors Claude's, but the + // event names and matcher conventions are Gemini-specific: + // - tool events are BeforeTool/AfterTool (not Pre/PostToolUse). + // - matcher is a regex over the tool name, where built-in tools + // are snake_case (run_shell_command, read_file, write_file, …) + // and MCP tools follow `mcp__`. + // Events without a Gemini equivalent (`stop`) are simply omitted — + // hooks targeting them are skipped with a one-shot warning. + // Docs: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md storage: { kind: 'inline-config', configPath: '.gemini/settings.json', @@ -356,10 +391,20 @@ export const providers: Record = { supportsNameTag: true, eventMap: { sessionStart: { event: 'SessionStart' }, - userPromptSubmit: { event: 'UserPromptSubmit' }, - beforeTool: { event: 'PreToolUse' }, - afterTool: { event: 'PostToolUse' }, - stop: { event: 'Stop' }, + sessionEnd: { event: 'SessionEnd' }, + userPromptSubmit: { event: 'BeforeAgent' }, + beforeTool: { event: 'BeforeTool' }, + afterTool: { event: 'AfterTool' }, + beforeShell: { event: 'BeforeTool', matcherPrefix: 'run_shell_command' }, + afterShell: { event: 'AfterTool', matcherPrefix: 'run_shell_command' }, + beforeFileRead: { event: 'BeforeTool', matcherPrefix: 'read_file' }, + afterFileEdit: { + event: 'AfterTool', + matcherPrefix: 'write_file|replace|edit_file', + }, + beforeMcpCall: { event: 'BeforeTool', matcherPrefix: 'mcp_.*' }, + afterMcpCall: { event: 'AfterTool', matcherPrefix: 'mcp_.*' }, + preCompact: { event: 'PreCompress' }, }, }, }, From f16c038ca6522012b25da12359a6cda2ea5bd42d Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 24 May 2026 21:53:03 +0300 Subject: [PATCH 04/10] fix(hooks): reference local-source scripts in place instead of copying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For `source: { type: local, path: ... }`, the script already lives in the project, is version-controlled, and the user is responsible for it. Copying it to ~/.capa/hooks// on every install was wasteful: it duplicated the file, required re-running `capa install` after every edit, and forced capa to chmod +x a file it didn't own. Now capa resolves the relative path against the capabilities-file directory and writes that absolute path verbatim into the provider entry's `command` field. No copy under ~/.capa, no chmod, edits take effect immediately, and `managed_hooks.scriptPath` stays NULL so prune/clean never touches the user's file. Inline / remote / github / gitlab sources still materialise to ~/.capa/hooks// as before — they have nowhere else to live. Refactors `ResolvedHookBody.materialised` (which was misleading for local sources, where the body did come from outside the YAML but is already on disk) into the clearer `needsMaterialisation` flag, with an optional `localPath` set only for `source.type=local`. Updates docs/README.md, docs/providers/README.md, and the capabilities-manager schema reference to spell out the new behavior. Adds a hooks-installer test that asserts the user's script path lands in the provider config and `scriptPath` stays NULL in `managed_hooks`. Co-authored-by: Cursor --- docs/README.md | 8 +-- docs/providers/README.md | 15 ++++-- .../references/capabilities-schema.md | 4 +- .../utils/__tests__/hooks-installer.test.ts | 39 ++++++++++++++ src/cli/utils/hooks-installer.ts | 54 ++++++++++++++----- 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/docs/README.md b/docs/README.md index f1c8ac33..96b379e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -187,9 +187,11 @@ flowchart TD `locator` is a JSON pointer (or TOML path) into the file so prune/clean can edit a single entry surgically without disturbing user-authored ones. `script_path` points at the materialised body under - `~/.capa/hooks//` for `source`-backed hooks. - `prune-orphan-hooks`, `install-hooks`, and `capa clean` all read and - mutate this table. + `~/.capa/hooks//` for `inline` / `remote` / + `github` / `gitlab` sources, and is `NULL` for inline-command hooks + and `source: { type: local }` hooks (which reference the user's file + in place and must never be deleted on clean). `prune-orphan-hooks`, + `install-hooks`, and `capa clean` all read and mutate this table. - **Lockfile**. `capabilities.lock` pins resolved commit SHAs for every `github`/`gitlab` skill, plugin, and hook source. The lockfile is built diff --git a/docs/providers/README.md b/docs/providers/README.md index 732129d7..e9637ade 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -107,11 +107,16 @@ fails because of an unsupported hook target. | **[Cursor (`cursor`)](./cursor.md)** | `.cursor/hooks.json` (standalone) | `{ version: 1, hooks: { ... } }` envelope | | **[Gemini CLI (`gemini-cli`)](./gemini-cli.md)** | `.gemini/settings.json` → `hooks` | JSON map (claude-style) | -Materialised hook scripts (when the YAML uses `source:` instead of inline -`command`) live under `~/.capa/hooks//` rather than in -the project. The `managed_hooks` SQLite table tracks `(projectId, -providerId, hookId, configPath, locator, scriptPath)` so prune and clean -can edit a single entry surgically. +Materialised hook scripts (when the YAML uses `source: { type: inline / +remote / github / gitlab }`) live under `~/.capa/hooks//` +rather than in the project. `source: { type: local }` is special: the +script already exists in the project, so capa references it in place via +its absolute path — no copy under `~/.capa`, `chmod` is the user's +responsibility, edits take effect without re-running `capa install`, and +`capa clean` never deletes it. The `managed_hooks` SQLite table tracks +`(projectId, providerId, hookId, configPath, locator, scriptPath)` so +prune and clean can edit a single entry surgically; `scriptPath` is null +for inline-command hooks and for `local`-source hooks. See [`docs/README.md`](../README.md#installation-pipeline) for how `prune-orphan-hooks` and `install-hooks` slot into the install pipeline, diff --git a/skills/capabilities-manager/references/capabilities-schema.md b/skills/capabilities-manager/references/capabilities-schema.md index 7b6643f0..ad890a10 100644 --- a/skills/capabilities-manager/references/capabilities-schema.md +++ b/skills/capabilities-manager/references/capabilities-schema.md @@ -234,7 +234,9 @@ Lifecycle hooks installed into each provider's hook configuration. The schema is - `{ type: gitlab, def: { repo: 'group/repo::scripts/before.sh' } }` - `{ type: local, path: ./scripts/before.sh }` — relative to the capabilities file -When `source` is set, the resolved body is materialised under `~/.capa/hooks//` (NOT inside the project), chmod +x, and the provider entry's `command` references that absolute path. +When `source` is `inline` / `remote` / `github` / `gitlab`, capa fetches the body and materialises it under `~/.capa/hooks//` (NOT inside the project), chmod +x, and the provider entry's `command` references that absolute path. + +`source.type: local` is special: the script already exists in the project and is version-controlled, so capa references the user's file in place via its absolute path — no copy is made under `~/.capa`, `chmod` is the user's responsibility, and edits to the script take effect immediately without re-running `capa install`. `capa clean` will never delete a local-source script. ```yaml hooks: diff --git a/src/cli/utils/__tests__/hooks-installer.test.ts b/src/cli/utils/__tests__/hooks-installer.test.ts index 5e590d53..8c8e70b8 100644 --- a/src/cli/utils/__tests__/hooks-installer.test.ts +++ b/src/cli/utils/__tests__/hooks-installer.test.ts @@ -159,6 +159,45 @@ describe('hooks-installer (claude inline-config)', () => { expect(result.warnings.some((w) => /no mapping/.test(w))).toBe(true); }); + it('source.type=local references the user\'s script directly (no copy under ~/.capa)', async () => { + // Place a script alongside the (synthetic) capabilities file and point + // the hook at it via a relative path. capa should embed the absolute + // path of THAT script in the provider config, not a path under ~/.capa. + require('fs').mkdirSync(join(projectPath, 'scripts'), { recursive: true }); + const scriptPath = join(projectPath, 'scripts', 'lint.sh'); + writeFileSync(scriptPath, '#!/bin/sh\necho lint\n', 'utf-8'); + + const result = await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [ + { + id: 'lint-after-edit', + on: 'afterFileEdit', + source: { type: 'local', path: 'scripts/lint.sh' }, + }, + ], + providers: ['claude-code'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + expect(result.installed).toBe(1); + + const settings = JSON.parse( + readFileSync(join(projectPath, '.claude', 'settings.json'), 'utf-8'), + ) as any; + const entry = settings.hooks.PostToolUse[0].hooks[0]; + expect(entry.command).toBe(scriptPath); + + // No copy in ~/.capa — managed_hooks.scriptPath stays null so clean + // never tries to unlink the user's file. + const rows = db.getManagedHooks(projectId); + expect(rows).toHaveLength(1); + expect(rows[0].scriptPath).toBeNull(); + }); + it('records remote sources in the lockfile builder', async () => { const lockBuilder = new LockfileBuilder(null); await installHooks({ diff --git a/src/cli/utils/hooks-installer.ts b/src/cli/utils/hooks-installer.ts index e014f12d..5cb298e1 100644 --- a/src/cli/utils/hooks-installer.ts +++ b/src/cli/utils/hooks-installer.ts @@ -107,21 +107,30 @@ export async function installHooks(opts: InstallHooksOptions): Promise/. + // Step 2: figure out the run reference for each command-type hook. + // + // - inline `command:` (no source) -> use the literal command string; + // - `source.type=local` -> reference the user's script in place + // via its absolute path (no copy); + // - inline / remote / github / gitlab -> materialise to + // ~/.capa/hooks// + // and reference that absolute path. const hookScriptPaths = new Map(); for (const hook of hooks) { const body = resolvedBodies.get(hook.id); if (!body) continue; if ((hook.type ?? 'command') !== 'command') continue; - // If the user inlined `command:` and provided no source, the entry value - // is the literal command string. We don't materialise a script in that - // case — the provider runs it directly. if (!hook.source) { hookScriptPaths.set(hook.id, hook.command ?? ''); continue; } + if (body.localPath) { + hookScriptPaths.set(hook.id, body.localPath); + continue; + } + try { const scriptPath = materialiseHookScript({ projectId, @@ -185,7 +194,12 @@ export async function installHooks(opts: InstallHooksOptions): Promise/. False for inline `command:` (no source) + * and for `local` sources (we reuse the user's file). + */ + needsMaterialisation: boolean; + /** + * Set for `source.type='local'`: the absolute path of the user's script. + * The provider entry's `command` is set to this path verbatim, so the + * user's file becomes the live hook script — no copy in ~/.capa, no + * scriptPath tracked in `managed_hooks`, no chmod, and edits to the + * file take effect immediately without re-running `capa install`. + */ + localPath?: string; /** Optional lockfile pin for remote / repo sources. */ lockEntry?: LockHookEntry; } async function resolveHookBody(hook: Hook, opts: InstallHooksOptions): Promise { if (!hook.source) { - return { text: hook.command ?? hook.prompt ?? '', materialised: false }; + return { text: hook.command ?? hook.prompt ?? '', needsMaterialisation: false }; } const source = hook.source; let text = ''; @@ -342,16 +372,16 @@ async function resolveHookBody(hook: Hook, opts: InstallHooksOptions): Promise Date: Sun, 24 May 2026 21:55:02 +0300 Subject: [PATCH 05/10] docs(hooks): clarify template-vars vs runtime stdin in schema example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit-shell example used \$TOOL_INPUT which is doubly misleading: 1. \${TOOL_INPUT} (with braces) collides with capa's project variable resolver and prompts for a value at install time. 2. No provider exports tool input as an env var — every supported provider hands the hook a JSON payload on stdin instead. Replace the placeholder with a date-only one-liner, add an explicit warning about \${...} vs shell vars, and show the real pattern: a local-source script that reads stdin and parses the relevant field with jq. Co-authored-by: Cursor --- .../references/capabilities-schema.md | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/skills/capabilities-manager/references/capabilities-schema.md b/skills/capabilities-manager/references/capabilities-schema.md index ad890a10..c164f085 100644 --- a/skills/capabilities-manager/references/capabilities-schema.md +++ b/skills/capabilities-manager/references/capabilities-schema.md @@ -238,14 +238,29 @@ When `source` is `inline` / `remote` / `github` / `gitlab`, capa fetches the bod `source.type: local` is special: the script already exists in the project and is version-controlled, so capa references the user's file in place via its absolute path — no copy is made under `~/.capa`, `chmod` is the user's responsibility, and edits to the script take effect immediately without re-running `capa install`. `capa clean` will never delete a local-source script. +> **Important — capa template variables vs shell variables.** capa +> resolves `${VarName}` placeholders (curly-braced) anywhere in the +> capabilities file at install time, prompting for any value the project +> doesn't already have stored. That syntax has nothing to do with the +> environment your hook script runs under — providers do **not** export +> the tool input as an env var; instead each fired hook receives a JSON +> payload on **stdin** (shape varies per provider). For non-trivial +> hooks that need to inspect the tool input, use `source: { type: local +> }` and have the script read stdin (e.g. `jq -r '.tool_input.command'` +> for Claude / Gemini / Codex, or `jq -r '.command'` for Cursor). Don't +> embed `${...}` in inline commands unless you actually want capa to +> substitute a project variable at install time. + ```yaml hooks: # Canonical event with an inline command — installed for every active - # provider that maps `beforeShell`. + # provider that maps `beforeShell`. The command is a shell one-liner + # that runs at the moment the agent fires the hook; capa does NOT + # interpolate anything inside single quotes here. - id: audit-shell - description: Append shell commands to ~/.capa/audit.log + description: Timestamp every shell invocation on: beforeShell - command: 'echo "$(date) $TOOL_INPUT" >> ~/.capa/audit.log' + command: 'date >> ~/.capa/audit.log' timeout: 5 # Provider-scoped event — only Cursor receives this entry. @@ -255,6 +270,17 @@ hooks: failClosed: true command: 'echo "blocked" && exit 1' + # When you actually need the tool input, use a local script that reads + # stdin. The script lives in the project (no copy under ~/.capa). + - id: log-bash-commands + on: beforeShell + source: { type: local, path: scripts/log-bash.sh } + # scripts/log-bash.sh: + # #!/usr/bin/env bash + # set -euo pipefail + # cmd=$(jq -r '.tool_input.command // .command // empty') + # printf '%s %s\n' "$(date -u +%FT%TZ)" "$cmd" >> "$HOME/.capa/audit.log" + # Source pulled from a pinned GitHub path; capa pins the resolved SHA in # capabilities.lock under the new `hooks:` section. - id: lint-staged From b7d335aa184f7aef3b625becc6b42643d3d1dab8 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 24 May 2026 22:47:06 +0300 Subject: [PATCH 06/10] refactor(hooks): update Codex integration to use matcher-grouped Claude shape Codex now shares the matcher-grouped layout with Claude, transitioning from a TOML table structure to a nested array of tables format. This change allows for a `name = "capa:"` field to be appended for surgical updates, while Codex's deserializer ignores unknown fields, ensuring compatibility with user-authored entries. Documentation and tests have been updated to reflect these changes, including adjustments to the provider registry and hook handlers to support the new structure. --- docs/providers/README.md | 2 +- docs/providers/codex.md | 31 +++++++++- .../references/capabilities-schema.md | 2 +- .../providers/__tests__/hook-handlers.test.ts | 56 ++++++++++++++++--- .../providers/__tests__/registry.test.ts | 9 ++- src/shared/providers/hook-handlers.ts | 54 +++++------------- src/shared/providers/registry.ts | 32 ++++++++--- src/types/providers.ts | 13 ++++- 8 files changed, 136 insertions(+), 63 deletions(-) diff --git a/docs/providers/README.md b/docs/providers/README.md index e9637ade..b970bde6 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -103,7 +103,7 @@ fails because of an unsupported hook target. | Provider | Config file | Shape | | --- | --- | --- | | **[Claude Code (`claude-code`)](./claude-code.md)** | `.claude/settings.json` → `hooks` | JSON map (event → `[{ matcher, hooks: [...] }]`) | -| **[Codex (`codex`)](./codex.md)** | `.codex/config.toml` → `[hooks]` | TOML tables, ID-tagged | +| **[Codex (`codex`)](./codex.md)** | `.codex/config.toml` → `[hooks]` | Matcher-grouped Claude-style envelope, serialised as TOML, `name: capa:` tag | | **[Cursor (`cursor`)](./cursor.md)** | `.cursor/hooks.json` (standalone) | `{ version: 1, hooks: { ... } }` envelope | | **[Gemini CLI (`gemini-cli`)](./gemini-cli.md)** | `.gemini/settings.json` → `hooks` | JSON map (claude-style) | diff --git a/docs/providers/codex.md b/docs/providers/codex.md index f9e7bee9..9270f150 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -15,7 +15,7 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → codex`](../.. | Instructions | `AGENTS.md` | — | | Rules | folded into `AGENTS.md` | No project-local rules directory; capa writes marker blocks into the instructions file. | | Sub-agents | `.codex/agents/.toml` | TOML format; body goes into the `developer_instructions` field. | -| Hooks | `.codex/config.toml` → `[hooks]` | TOML tables. Codex does not support a `name` tag, so capa keys each entry by `id = ""` and tracks `(event, hookId)` in `managed_hooks` for surgical updates. | +| Hooks | `.codex/config.toml` → `[hooks]` | Matcher-grouped Claude-style layout (`[[hooks.]]` + nested `[[hooks..hooks]]`), serialised as TOML. Capa appends an opaque `name = "capa:"` field on entries it owns; Codex's TOML deserialiser ignores unknown fields, so the tag round-trips cleanly and capa uses it for surgical updates without disturbing user-authored entries. | | Plugin manifests | — | Not declared; Codex consumes plugins via the same Claude/Cursor manifest paths handled elsewhere. | ## Hooks event mapping @@ -37,9 +37,36 @@ skipped on Codex with a one-shot warning. Codex-specific events (e.g. `PermissionRequest`, `PostCompact`) can be targeted directly with `on: codex:`. +## TOML layout + +Codex's hook config uses the same matcher-grouped envelope Claude uses, +serialised as TOML's nested array of tables. A capa-managed +`beforeShell` hook lands as: + +```toml +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "/abs/path/to/script" +name = "capa:audit-shell" +timeout = 5 +``` + +The `name` field is capa's opaque entry tag (`capa:`). Codex's +deserialiser (see +[`codex-rs/config/src/hook_config.rs`](https://github.com/openai/codex/blob/main/codex-rs/config/src/hook_config.rs)) +does not use `#[serde(deny_unknown_fields)]` on `MatcherGroup` or +`HookHandlerConfig`, so the tag is silently ignored at runtime but +preserved across writes — capa relies on it to find and update or +remove its own entries without touching user-authored siblings in the +same matcher group. + ## Sources - Codex repo & config docs: -- Codex hooks (`config.toml` → `[hooks]`): +- Codex hooks guide: +- Codex hooks deserialiser: Last verified: 2026-05-24 diff --git a/skills/capabilities-manager/references/capabilities-schema.md b/skills/capabilities-manager/references/capabilities-schema.md index c164f085..26afd1cd 100644 --- a/skills/capabilities-manager/references/capabilities-schema.md +++ b/skills/capabilities-manager/references/capabilities-schema.md @@ -297,7 +297,7 @@ Per provider, hook entries land in: | --- | --- | --- | --- | | `claude-code` | `.claude/settings.json` → `hooks` | JSON map keyed by event → `[{ matcher, hooks: [...] }]` | `name: capa:` | | `cursor` | `.cursor/hooks.json` (standalone) | `{ version: 1, hooks: { ... } }` envelope | `name: capa:` | -| `codex` | `.codex/config.toml` → `[hooks]` | TOML tables | `id: ` | +| `codex` | `.codex/config.toml` → `[hooks]` | TOML's nested array-of-tables (`[[hooks.]] matcher = "..."` + `[[hooks..hooks]] type = "command" command = "..."`) — same matcher-grouped envelope as Claude | `name: capa:` (Codex's serde deserialiser ignores unknown fields, so the tag round-trips safely) | | `gemini-cli` | `.gemini/settings.json` → `hooks` | JSON map (claude-style) | `name: capa:` | Providers without a hooks integration (most of the rest of the registry) emit a warning and skip — `capa install` never fails on unsupported hooks. `capa clean` and `capa install` (via the `prune-orphan-hooks` task) keep their own entries in sync without disturbing entries you authored by hand. diff --git a/src/shared/providers/__tests__/hook-handlers.test.ts b/src/shared/providers/__tests__/hook-handlers.test.ts index 4e1042b3..b959415e 100644 --- a/src/shared/providers/__tests__/hook-handlers.test.ts +++ b/src/shared/providers/__tests__/hook-handlers.test.ts @@ -29,12 +29,16 @@ const cursorIntegration: HooksIntegration = { }, }; +// Codex shares the matcher-grouped Claude shape — the only difference is +// TOML serialisation (handled by `storage.format`). Capa appends a +// `name = "capa:"` field for surgical updates; Codex's TOML +// deserialiser doesn't use deny_unknown_fields, so the field is ignored. const codexIntegration: HooksIntegration = { storage: { kind: 'inline-config', configPath: '.codex/config.toml', format: 'toml', hooksKey: 'hooks' }, - shape: 'codex-toml', - supportsNameTag: false, + shape: 'claude', + supportsNameTag: true, eventMap: { - beforeShell: { event: 'PreShellExec' }, + beforeShell: { event: 'PreToolUse', matcherPrefix: 'Bash' }, }, }; @@ -87,16 +91,54 @@ describe('hook-handlers — buildHookEntry', () => { expect(out.matcher).toBe('Bash'); }); - it('codex-toml shape emits id without name tag', () => { + it('codex (claude shape) emits a matcher-grouped name-tagged command entry', () => { const hook: Hook = { id: 'audit-shell', on: 'beforeShell', command: 'echo' }; const out = buildHookEntry(codexIntegration, { hook, runReference: '/abs/script', - mapping: { event: 'PreShellExec' }, + mapping: { event: 'PreToolUse', matcherPrefix: 'Bash' }, }); - expect(out.entry.id).toBe('audit-shell'); + expect(out.eventName).toBe('PreToolUse'); + expect(out.matcher).toBe('Bash'); expect(out.entry.command).toBe('/abs/script'); - expect(out.nameTag).toBeNull(); + expect(out.entry.type).toBe('command'); + expect(out.entry.name).toBe('capa:audit-shell'); + expect(out.nameTag).toBe('capa:audit-shell'); + // Critically, the entry must NOT carry a flat `id` field — the old + // codex-toml shape did, and Codex's parser silently ignored it. Now + // that we live in the matcher-grouped layout the entry is found via + // the `name` tag like every other claude-style provider. + expect(out.entry.id).toBeUndefined(); + }); + + it('codex (claude shape) upserts under matcher and serialises to nested TOML layout', () => { + const root: Record = {}; + const hook: Hook = { id: 'audit-shell', on: 'beforeShell', command: 'echo' }; + const out = buildHookEntry(codexIntegration, { + hook, + runReference: '/abs/script', + mapping: { event: 'PreToolUse', matcherPrefix: 'Bash' }, + }); + const locator = upsertHookEntry(codexIntegration, root, out); + expect(locator).toEqual(['PreToolUse', 0, 'hooks', 0]); + // The shape — matcher group at the outer level, hook entry nested + // inside `hooks` — is what TOML round-trips as + // `[[hooks.PreToolUse]] matcher = "Bash"` / + // `[[hooks.PreToolUse.hooks]] type = "command" command = "..."`. + expect(((root.PreToolUse as unknown[])[0] as { matcher: string }).matcher).toBe('Bash'); + const inner = ((root.PreToolUse as unknown[])[0] as { hooks: unknown[] }).hooks; + expect(inner.length).toBe(1); + expect((inner[0] as { name: string }).name).toBe('capa:audit-shell'); + // Re-installing the same hook id replaces in place rather than appending. + const second = buildHookEntry(codexIntegration, { + hook, + runReference: '/abs/script-v2', + mapping: { event: 'PreToolUse', matcherPrefix: 'Bash' }, + }); + upsertHookEntry(codexIntegration, root, second); + const innerAfter = ((root.PreToolUse as unknown[])[0] as { hooks: unknown[] }).hooks; + expect(innerAfter.length).toBe(1); + expect((innerAfter[0] as { command: string }).command).toBe('/abs/script-v2'); }); }); diff --git a/src/shared/providers/__tests__/registry.test.ts b/src/shared/providers/__tests__/registry.test.ts index 63282e83..33d0b261 100644 --- a/src/shared/providers/__tests__/registry.test.ts +++ b/src/shared/providers/__tests__/registry.test.ts @@ -137,9 +137,14 @@ describe('Provider registry', () => { } }); - it('codex uses inline-config toml with codex-toml shape', () => { + it('codex uses inline-config toml with the (matcher-grouped) claude shape', () => { + // Codex's TOML hook layout is structurally identical to Claude's + // JSON layout (`{ matcher, hooks: [{ type, command, ... }] }` per + // event), so capa serialises through the same `claude` shape; the + // storage `format` is what makes it land as TOML on disk. const p = getProvider('codex'); - expect(p?.hooks?.shape).toBe('codex-toml'); + expect(p?.hooks?.shape).toBe('claude'); + expect(p?.hooks?.supportsNameTag).toBe(true); expect(p?.hooks?.storage.kind).toBe('inline-config'); if (p?.hooks?.storage.kind === 'inline-config') { expect(p.hooks.storage.format).toBe('toml'); diff --git a/src/shared/providers/hook-handlers.ts b/src/shared/providers/hook-handlers.ts index ec7f3bd8..d2b4c94d 100644 --- a/src/shared/providers/hook-handlers.ts +++ b/src/shared/providers/hook-handlers.ts @@ -146,23 +146,6 @@ function buildCursorEntry(input: HookEntryInput): HookEntryOutput { }; } -function buildCodexTomlEntry(input: HookEntryInput): HookEntryOutput { - const { hook, mapping, runReference } = input; - const isPrompt = (hook.type ?? "command") === "prompt"; - const entry: Record = { - type: isPrompt ? "prompt" : "command", - [isPrompt ? "prompt" : "command"]: runReference, - id: hook.id, - }; - if (hook.timeout !== undefined) entry.timeout = hook.timeout; - return { - eventName: mapping.event, - entry, - matcher: resolveMatcher(input), - nameTag: null, - }; -} - /** * Build the shape-specific entry for a single (provider, hook, mapping). * @@ -181,8 +164,6 @@ export function buildHookEntry( return buildClaudeLikeEntry(input, integration.supportsNameTag); case "cursor": return buildCursorEntry(input); - case "codex-toml": - return buildCodexTomlEntry(input); default: throw new Error( `Unsupported hook shape: ${(integration as { shape: string }).shape}`, @@ -202,12 +183,14 @@ export function buildHookEntry( * Layout per shape (`hooksRoot` is the inline-config `hooksKey` or the bare * standalone document): * - * - claude/gemini : hooksRoot[] is an array of `{ matcher, hooks: [entry] }`. + * - claude/gemini/codex/windsurf/antigravity : + * hooksRoot[] is an array of `{ matcher, hooks: [entry] }`. + * Codex serialises this as TOML's nested array of tables + * (`[[hooks.PreToolUse]]` + `[[hooks.PreToolUse.hooks]]`), + * every other provider as JSON; the layout is the same. * locator = ['', , 'hooks', ] * - cursor : hooksRoot[] is an array of `entry`. * locator = ['', ] - * - codex-toml : hooksRoot[] is an array of `entry`. - * locator = ['', ] * * Locators are emitted relative to the hooks root, NOT relative to the file * — each shape has a `rootPath` helper for the absolute path, which the @@ -258,8 +241,7 @@ export function upsertHookEntry( group.hooks.push(entry); return [eventName, matcherIdx, "hooks", group.hooks.length - 1]; } - case "cursor": - case "codex-toml": { + case "cursor": { const { eventName, entry, nameTag } = output; const events = ensureArray(hooksRoot, eventName); const existingIdx = nameTag @@ -267,11 +249,7 @@ export function upsertHookEntry( (e) => isPlainObject(e) && isCapaNameTag(e.name, idFromNameTag(nameTag)), ) - : integration.shape === "codex-toml" - ? events.findIndex( - (e) => isPlainObject(e) && e.id === (entry as { id?: string }).id, - ) - : -1; + : -1; if (existingIdx >= 0) { events[existingIdx] = entry; return [eventName, existingIdx]; @@ -335,8 +313,7 @@ export function removeHookEntryAt( if (events.length === 0) delete hooksRoot[eventName]; return true; } - case "cursor": - case "codex-toml": { + case "cursor": { // ['', entryIdx] if (locator.length !== 2) return false; const [eventName, entryIdx] = locator; @@ -346,16 +323,11 @@ export function removeHookEntryAt( if (!Array.isArray(events) || entryIdx < 0 || entryIdx >= events.length) return false; const candidate = events[entryIdx]; - if (integration.shape === "cursor") { - if ( - !isPlainObject(candidate) || - !isCapaNameTag(candidate.name, expectedHookId) - ) - return false; - } else { - if (!isPlainObject(candidate) || candidate.id !== expectedHookId) - return false; - } + if ( + !isPlainObject(candidate) || + !isCapaNameTag(candidate.name, expectedHookId) + ) + return false; events.splice(entryIdx, 1); if (events.length === 0) delete hooksRoot[eventName]; return true; diff --git a/src/shared/providers/registry.ts b/src/shared/providers/registry.ts index bc2253a6..213df369 100644 --- a/src/shared/providers/registry.ts +++ b/src/shared/providers/registry.ts @@ -191,11 +191,29 @@ export const providers: Record = { bodyField: 'developer_instructions', }, hooks: { - // Codex reads hooks from .codex/config.toml as a [hooks] table. - // Codex uses Claude-style event names (PreToolUse/PostToolUse) and a - // tool-name matcher; built-in tools include `Bash`, `apply_patch`, and - // MCP tools follow the `mcp__server__tool` naming pattern. Events - // without a matcher are: UserPromptSubmit, Stop. + // Codex reads hooks from .codex/config.toml as a [hooks] table whose + // shape is *structurally identical* to Claude's `.claude/settings.json` + // hook map: a top-level event key holds an array of matcher groups + // (`{ matcher, hooks: [...] }`), and each handler in the inner `hooks` + // array is a `{ type, command, ... }` table. Capa therefore reuses the + // `claude` shape handler — the only difference is TOML vs JSON + // serialisation (handled by `storage.format`). + // + // Codex's TOML form (per `codex-rs/config/src/hook_config.rs`) is: + // [[hooks.PreToolUse]] + // matcher = "Bash" + // [[hooks.PreToolUse.hooks]] + // type = "command" + // command = "..." + // + // Identification: `MatcherGroup` and `HookHandlerConfig` do NOT use + // `#[serde(deny_unknown_fields)]`, so capa appends an opaque + // `name = "capa:"` field on entries it owns. Codex ignores it but + // round-trips it through writes; capa uses it for surgical updates. + // + // Built-in matcher tool names are `Bash`, `apply_patch`, and + // `mcp__server__tool` for MCP. Events without a matcher + // (UserPromptSubmit, Stop, …) accept an empty/omitted matcher. // Docs: https://developers.openai.com/codex/hooks storage: { kind: 'inline-config', @@ -203,8 +221,8 @@ export const providers: Record = { format: 'toml', hooksKey: 'hooks', }, - shape: 'codex-toml', - supportsNameTag: false, + shape: 'claude', + supportsNameTag: true, eventMap: { sessionStart: { event: 'SessionStart' }, userPromptSubmit: { event: 'UserPromptSubmit' }, diff --git a/src/types/providers.ts b/src/types/providers.ts index fa99b316..184bb862 100644 --- a/src/types/providers.ts +++ b/src/types/providers.ts @@ -108,8 +108,17 @@ export interface HooksIntegration { storage: HooksStorage; /** Canonical → provider event name (subset is fine; missing events are skipped). */ eventMap: Partial>; - /** Selects the entry shape (claude-style array, cursor map, codex tables, …). */ - shape: 'cursor' | 'claude' | 'gemini' | 'windsurf' | 'antigravity' | 'codex-toml'; + /** + * Selects the entry shape used to serialise hook entries. `claude` covers + * every provider that uses the matcher-grouped layout (Claude Code, Gemini + * CLI, Codex, Windsurf, Antigravity); the variant labels exist only as + * future-proofing in case a provider diverges (e.g. extra fields). The + * `cursor` shape is a flat array with `pattern`+`name` per entry. + * + * Codex serialises through the same `claude` shape; the storage format is + * what makes it land as TOML rather than JSON. + */ + shape: 'cursor' | 'claude' | 'gemini' | 'windsurf' | 'antigravity'; /** * When true the provider entry carries an opaque `name` field (used by capa * for the `capa:` tag, allowing surgical updates without disturbing From b076bad0ac05d938dbd76d31447d4e33811eadc8 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 24 May 2026 23:08:27 +0300 Subject: [PATCH 07/10] fix(hooks): address Copilot review feedback on PR #72 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveMatcher now wraps each side in a non-capturing group so `(?:prefix)|(?:userMatcher)` matches the documented format and composes safely when either side already contains `|` (e.g. the registry's `Edit|MultiEdit|Write` prefix). Avoids capture-group number shifts that could collide with user backreferences. - resolveHookBody returns `needsMaterialisation: false` for prompt-type hooks; only command bodies actually get written to `~/.capa/hooks//`. - install.sh `profile_has_dir` uses `grep -F` for the `$HOME` and `${HOME}` forms instead of embedding the raw path into a `grep -E` pattern; previously a relative path like `.local/bin` was treated as a regex (the `.` metacharacter) and caused false matches that skipped the PATH update. - docs/providers/README.md no longer claims TOML providers use an `id: ` tag; Codex uses the same `name = "capa:"` field as the JSON Claude-style shapes. Tests: - hook-handlers: assert the new `(?:…)|(?:…)` matcher format - full suite still green (970 pass / 0 fail) Co-authored-by: Cursor --- docs/providers/README.md | 9 +++++---- install.sh | 10 +++++++++- src/cli/utils/hooks-installer.ts | 6 +++++- .../providers/__tests__/hook-handlers.test.ts | 6 +++++- src/shared/providers/hook-handlers.ts | 20 +++++++++++++------ 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/providers/README.md b/docs/providers/README.md index b970bde6..ebb49b95 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -95,10 +95,11 @@ included for completeness but lack any project-local write paths. Hooks are intentionally absent from the matrix above because only a small slice of providers wire them up. The four providers below have a `hooks` integration in `registry.ts` today — capa edits the file in-place using -`name: capa:` (or `id: ` for TOML) tags so it can update or -remove its own entries without touching user-authored ones. Every other -provider triggers a one-shot warning and skips; `capa install` never -fails because of an unsupported hook target. +the `name = "capa:"` tag (TOML providers like Codex use the same +field — appended as an opaque key Codex's deserialiser ignores) so it +can update or remove its own entries without touching user-authored +ones. Every other provider triggers a one-shot warning and skips; `capa +install` never fails because of an unsupported hook target. | Provider | Config file | Shape | | --- | --- | --- | diff --git a/install.sh b/install.sh index 57315c70..1a451e41 100644 --- a/install.sh +++ b/install.sh @@ -259,11 +259,19 @@ profile_has_dir() { # If _dir is under $HOME, also accept $HOME / ${HOME} prefixed forms # written by hand by the user. + # + # NOTE: use grep -F for both forms — `_rel` is a path that very often + # contains regex metacharacters (e.g. `.local/bin`), and embedding it + # into a `grep -E` pattern caused false matches that skipped writing + # the PATH update (e.g. `.local/bin` matching `xlocal/bin`). if [ -n "${HOME:-}" ]; then case "$_dir" in "$HOME"/*) _rel="${_dir#"$HOME"/}" - if grep -qE "\\\$\{?HOME\}?/${_rel}" "$_profile"; then + if grep -qF -- "\$HOME/${_rel}" "$_profile"; then + return 0 + fi + if grep -qF -- "\${HOME}/${_rel}" "$_profile"; then return 0 fi ;; diff --git a/src/cli/utils/hooks-installer.ts b/src/cli/utils/hooks-installer.ts index 5cb298e1..6a6085df 100644 --- a/src/cli/utils/hooks-installer.ts +++ b/src/cli/utils/hooks-installer.ts @@ -320,6 +320,10 @@ interface ResolvedHookBody { } async function resolveHookBody(hook: Hook, opts: InstallHooksOptions): Promise { + // Prompt hooks are inlined into the provider entry, never written to + // ~/.capa/hooks//. Only command-type bodies are materialised. + const isCommand = (hook.type ?? 'command') === 'command'; + if (!hook.source) { return { text: hook.command ?? hook.prompt ?? '', needsMaterialisation: false }; } @@ -381,7 +385,7 @@ async function resolveHookBody(hook: Hook, opts: InstallHooksOptions): Promise { mapping: { event: 'PostToolUse', matcherPrefix: 'Edit|MultiEdit|Write' }, }); expect(out.eventName).toBe('PostToolUse'); - expect(out.matcher).toBe('Edit|MultiEdit|Write|src/.+\\.ts'); + // Each side wrapped in a non-capturing group so the registry's existing + // alternation (`Edit|MultiEdit|Write`) composes as a top-level + // alternation with the user matcher — and so we don't shift any capture + // group numbers the user might be referencing. + expect(out.matcher).toBe('(?:Edit|MultiEdit|Write)|(?:src/.+\\.ts)'); }); it('claude shape skips union when user matcher equals prefix', () => { diff --git a/src/shared/providers/hook-handlers.ts b/src/shared/providers/hook-handlers.ts index d2b4c94d..b0a2ab84 100644 --- a/src/shared/providers/hook-handlers.ts +++ b/src/shared/providers/hook-handlers.ts @@ -82,11 +82,19 @@ function idFromNameTag(tag: string): string { * - prefix only -> use prefix * - user matcher only -> use user matcher (no canonical scope to fold in) * - both -> union them as a regex alternation - * `(prefix)|(userMatcher)` so the canonical - * tool family is still covered while the user's - * extra tool names also fire. The user can bypass - * this fold-in by switching to the provider-scoped - * event form (e.g. `on: claude-code:PreToolUse`). + * `(?:prefix)|(?:userMatcher)` so the + * canonical tool family is still covered while + * the user's extra tool names also fire. Each + * side is wrapped in a non-capturing group so + * alternations or anchors inside either the + * registry prefix (e.g. `Edit|MultiEdit|Write`) + * or a user matcher compose as a top-level + * alternation, and so we don't introduce new + * numbered capture groups that could collide + * with backreferences in the user matcher. + * The user can bypass this fold-in by switching + * to the provider-scoped event form (e.g. + * `on: claude-code:PreToolUse`). * - neither -> empty (shape decides) */ function resolveMatcher(input: HookEntryInput): string { @@ -97,7 +105,7 @@ function resolveMatcher(input: HookEntryInput): string { const prefix = "matcherPrefix" in input.mapping ? input.mapping.matcherPrefix : ""; if (prefix && userMatcher) { - return prefix === userMatcher ? prefix : `${prefix}|${userMatcher}`; + return prefix === userMatcher ? prefix : `(?:${prefix})|(?:${userMatcher})`; } return userMatcher || prefix; } From 7028c5406c290c71d6ec195df5ff7bed6063861d Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Fri, 29 May 2026 03:01:15 +0300 Subject: [PATCH 08/10] fix(hooks): honor prompt-type hooks for Cursor and local sources Address Copilot review feedback on PR #72: - buildCursorEntry() now honors hook.type. Cursor supports prompt-based, LLM-evaluated hooks (`{ type: "prompt", prompt: ... }`) in addition to command hooks; previously every Cursor entry serialised as a `command`, making prompt-type hooks unusable on that provider. - resolveHookBody() reads the file contents for prompt-type `local` sources instead of returning empty text. Command-type local sources are still referenced in place (the provider entry points at the user's script verbatim); prompt-type local sources have no script to execute, so the file *is* the prompt text and is inlined like inline/remote sources. Previously prompt + local yielded an empty run reference and the hook was silently skipped. Tests: - hook-handlers: cursor prompt-entry branch + assert command entries omit a `type` discriminator. - hooks-installer: prompt-type local source inlines the file contents and leaves managed_hooks.scriptPath null. Docs: cursor.md notes command + prompt hook support. Co-authored-by: Cursor --- docs/providers/cursor.md | 2 +- .../utils/__tests__/hooks-installer.test.ts | 43 +++++++++++++++++++ src/cli/utils/hooks-installer.ts | 15 +++++-- .../providers/__tests__/hook-handlers.test.ts | 27 ++++++++++++ src/shared/providers/hook-handlers.ts | 12 ++++-- 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index 1bba49e7..708909b1 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -15,7 +15,7 @@ Source-of-truth definition: [`src/shared/providers/registry.ts → cursor`](../. | Instructions | `AGENTS.md` | — | | Rules | `.cursor/rules/.mdc` | YAML frontmatter: `description`, `globs` (from capa's `appliesTo`), `alwaysApply`. | | Sub-agents | `.cursor/agents/.md` | Markdown + frontmatter (`model`, `readonly`, `is_background`). | -| Hooks | `.cursor/hooks.json` (standalone) | `{ version: 1, hooks: { : [ { name: "capa:", command, … } ] } }` envelope. Cursor lets a hook fail-close on a non-zero exit (`failClosed: true`). | +| Hooks | `.cursor/hooks.json` (standalone) | `{ version: 1, hooks: { : [ { name: "capa:", command, … } ] } }` envelope. Supports both command-based hooks (`command`) and prompt-based, LLM-evaluated hooks (`type: "prompt"` + `prompt`). Cursor lets a hook fail-close on a non-zero exit (`failClosed: true`). | | Plugin manifests | `.cursor-plugin/plugin.json` (`pluginProviderId: cursor`) | Parsed by `parseCursorManifest` — see [plugin docs](../README.md#plugin-discovery-and-unpack). | ## Hooks event mapping diff --git a/src/cli/utils/__tests__/hooks-installer.test.ts b/src/cli/utils/__tests__/hooks-installer.test.ts index 8c8e70b8..83ddc1b9 100644 --- a/src/cli/utils/__tests__/hooks-installer.test.ts +++ b/src/cli/utils/__tests__/hooks-installer.test.ts @@ -198,6 +198,49 @@ describe('hooks-installer (claude inline-config)', () => { expect(rows[0].scriptPath).toBeNull(); }); + it('prompt-type local source reads the file contents as the prompt text', async () => { + // For a command hook, `source.type=local` references the script in + // place. For a PROMPT hook there's no script to execute — the file *is* + // the prompt text, so capa must read it inline (like inline/remote + // sources) instead of leaving the run reference empty and skipping it. + require('fs').mkdirSync(join(projectPath, 'prompts'), { recursive: true }); + const promptPath = join(projectPath, 'prompts', 'guard.txt'); + writeFileSync(promptPath, 'Only allow read-only commands.\n', 'utf-8'); + + const result = await installHooks({ + projectPath, + projectId, + capabilitiesFilePath: join(projectPath, 'capabilities.yaml'), + hooks: [ + { + id: 'shell-guard', + on: 'beforeShell', + type: 'prompt', + source: { type: 'local', path: 'prompts/guard.txt' }, + }, + ], + providers: ['claude-code'], + db, + authFetch: makeAuthFetch(), + getRepoSnapshot: stubGetRepoSnapshot, + }); + expect(result.installed).toBe(1); + expect(result.warnings).toEqual([]); + + const settings = JSON.parse( + readFileSync(join(projectPath, '.claude', 'settings.json'), 'utf-8'), + ) as any; + const entry = settings.hooks.PreToolUse[0].hooks[0]; + expect(entry.type).toBe('prompt'); + expect(entry.prompt).toBe('Only allow read-only commands.\n'); + + // The prompt is inlined into the provider config; nothing is + // materialised under ~/.capa, so scriptPath stays null. + const rows = db.getManagedHooks(projectId); + expect(rows).toHaveLength(1); + expect(rows[0].scriptPath).toBeNull(); + }); + it('records remote sources in the lockfile builder', async () => { const lockBuilder = new LockfileBuilder(null); await installHooks({ diff --git a/src/cli/utils/hooks-installer.ts b/src/cli/utils/hooks-installer.ts index 6a6085df..f1411b42 100644 --- a/src/cli/utils/hooks-installer.ts +++ b/src/cli/utils/hooks-installer.ts @@ -296,9 +296,10 @@ export function cleanHooks(projectPath: string, projectId: string, db: CapaDatab interface ResolvedHookBody { /** - * Resolved body text. For `local` sources this is left empty — the script - * already exists on disk at `localPath` and we reference it directly - * instead of copying it into ~/.capa. + * Resolved body text. For command-type `local` sources this is left empty + * — the script already exists on disk at `localPath` and we reference it + * directly instead of copying it into ~/.capa. For prompt-type `local` + * sources the file *is* the prompt text, so it's read in full here. */ text: string; /** @@ -376,11 +377,17 @@ async function resolveHookBody(hook: Hook, opts: InstallHooksOptions): Promise { mapping: { event: 'beforeShellExecution' }, }); expect(out.eventName).toBe('beforeShellExecution'); + expect(out.entry.command).toBe('echo blocked'); + expect(out.entry.type).toBeUndefined(); expect(out.entry.pattern).toBe('rm -rf'); expect(out.entry.name).toBe('capa:block-rm'); }); + it('cursor shape emits a prompt entry for type: prompt hooks', () => { + // Cursor's prompt-based hooks use `{ type: "prompt", prompt: }` + // (LLM-evaluated) rather than a `command`. See cursor.com/docs/agent/hooks. + const hook: Hook = { + id: 'safe-shell', + on: 'beforeShell', + type: 'prompt', + prompt: 'Only allow read-only commands.', + matcher: 'rm -rf', + timeout: 10, + }; + const out = buildHookEntry(cursorIntegration, { + hook, + runReference: 'Only allow read-only commands.', + mapping: { event: 'beforeShellExecution' }, + }); + expect(out.eventName).toBe('beforeShellExecution'); + expect(out.entry.type).toBe('prompt'); + expect(out.entry.prompt).toBe('Only allow read-only commands.'); + expect(out.entry.command).toBeUndefined(); + expect(out.entry.pattern).toBe('rm -rf'); + expect(out.entry.timeout).toBe(10); + expect(out.entry.name).toBe('capa:safe-shell'); + }); + it('claude shape unions matcherPrefix with user matcher (preserves canonical scope)', () => { const hook: Hook = { id: 'edit-ts', on: 'afterFileEdit', command: 'echo', matcher: 'src/.+\\.ts' }; const out = buildHookEntry(claudeIntegration, { diff --git a/src/shared/providers/hook-handlers.ts b/src/shared/providers/hook-handlers.ts index b0a2ab84..91e90ef0 100644 --- a/src/shared/providers/hook-handlers.ts +++ b/src/shared/providers/hook-handlers.ts @@ -137,9 +137,15 @@ function buildClaudeLikeEntry( function buildCursorEntry(input: HookEntryInput): HookEntryOutput { const { hook, mapping, runReference } = input; - const entry: Record = { - command: runReference, - }; + // Cursor supports two execution types: command-based (default) and + // prompt-based (LLM-evaluated). A prompt entry uses `{ type: "prompt", + // prompt: }`; a command entry uses a bare `{ command: }` + // (no `type` field needed — command is Cursor's default). + // Docs: https://cursor.com/docs/agent/hooks + const isPrompt = (hook.type ?? "command") === "prompt"; + const entry: Record = isPrompt + ? { type: "prompt", prompt: runReference } + : { command: runReference }; const matcher = resolveMatcher(input); if (matcher) entry.pattern = matcher; if (hook.timeout !== undefined) entry.timeout = hook.timeout; From f13aa544645605ccdcad87ae4099bc31ef4d4463 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Fri, 29 May 2026 03:50:49 +0300 Subject: [PATCH 09/10] feat(mcp): add entryType to MCP integration for transport type specification - Introduced an optional `entryType` field in the MCP integration interface to specify the transport type for providers that require it. - Updated the `buildMcpEntry` function to include the `type` in the returned entry when applicable. - Modified the MCP client manager and registry to support the new `entryType` field, ensuring existing configurations are preserved. - Enhanced tests to validate the correct handling of the `entryType` field across various providers. This change improves the flexibility and clarity of MCP server configurations. --- .../__tests__/mcp-client-manager.test.ts | 1 + src/cli/utils/mcp-client-manager.ts | 1 + .../providers/__tests__/registry.test.ts | 29 ++++++++++++++----- src/shared/providers/handlers.ts | 5 +++- src/shared/providers/registry.ts | 15 ++++++++-- src/types/providers.ts | 2 ++ 6 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/cli/utils/__tests__/mcp-client-manager.test.ts b/src/cli/utils/__tests__/mcp-client-manager.test.ts index 7c68da90..3b3d3264 100644 --- a/src/cli/utils/__tests__/mcp-client-manager.test.ts +++ b/src/cli/utils/__tests__/mcp-client-manager.test.ts @@ -153,6 +153,7 @@ describe('mcp-client-manager', () => { expect(config.mcpServers).toBeDefined(); expect(config.mcpServers['capa']).toBeDefined(); expect(config.mcpServers['capa'].url).toBe(mcpUrl); + expect(config.mcpServers['capa'].type).toBe('http'); }); it('should preserve existing servers in claude-code config', async () => { diff --git a/src/cli/utils/mcp-client-manager.ts b/src/cli/utils/mcp-client-manager.ts index 8f52f232..5b041e45 100644 --- a/src/cli/utils/mcp-client-manager.ts +++ b/src/cli/utils/mcp-client-manager.ts @@ -6,6 +6,7 @@ import { getMcpConfigPath, buildMcpEntry } from '../../shared/providers/handlers import { taskLog } from '../ui'; interface McpServerEntry { + type?: string; url?: string; command?: string; args?: string[]; diff --git a/src/shared/providers/__tests__/registry.test.ts b/src/shared/providers/__tests__/registry.test.ts index 33d0b261..5d423e15 100644 --- a/src/shared/providers/__tests__/registry.test.ts +++ b/src/shared/providers/__tests__/registry.test.ts @@ -170,6 +170,7 @@ describe('Cursor pilot integration', () => { expect(cursor.mcp!.format).toBe('json'); expect(cursor.mcp!.serversKey).toBe('mcpServers'); expect(cursor.mcp!.serverKey).toBe('capa'); + expect(cursor.mcp!.entryType).toBeUndefined(); expect(cursor.mcp!.supportsSubAgentEntries).toBe(false); }); @@ -208,6 +209,7 @@ describe('Claude Code pilot integration', () => { expect(claude.mcp!.format).toBe('json'); expect(claude.mcp!.serversKey).toBe('mcpServers'); expect(claude.mcp!.serverKey).toBe('capa'); + expect(claude.mcp!.entryType).toBe('http'); expect(claude.mcp!.supportsSubAgentEntries).toBe(true); }); @@ -299,6 +301,12 @@ describe('Codex pilot integration', () => { expect(entry).toEqual({ url: 'http://localhost:3000/mcp' }); }); + it('MCP buildMcpEntry tags the transport type when the provider requires it', () => { + const claude = getProvider('claude-code')!; + const entry = buildMcpEntry(claude.mcp!, 'http://localhost:3000/mcp'); + expect(entry).toEqual({ type: 'http', url: 'http://localhost:3000/mcp' }); + }); + it('buildSubAgentFile produces valid TOML', () => { const codex = getProvider('codex')!; const subAgent = { @@ -729,6 +737,7 @@ describe('GitHub Copilot integration', () => { expect(copilot.mcp!.serversKey).toBe('servers'); expect(copilot.mcp!.serverKey).toBe('capa'); expect(copilot.mcp!.entryUrlKey).toBe('url'); + expect(copilot.mcp!.entryType).toBe('http'); expect(copilot.mcp!.supportsSubAgentEntries).toBe(false); }); @@ -764,6 +773,7 @@ describe('OpenCode integration', () => { expect(oc.mcp!.format).toBe('json'); expect(oc.mcp!.serversKey).toBe('mcp'); expect(oc.mcp!.serverKey).toBe('capa'); + expect(oc.mcp!.entryType).toBe('remote'); expect(oc.mcp!.supportsSubAgentEntries).toBe(true); }); @@ -812,6 +822,7 @@ describe('Roo Code integration', () => { expect(roo.mcp!.format).toBe('json'); expect(roo.mcp!.serversKey).toBe('mcpServers'); expect(roo.mcp!.serverKey).toBe('capa'); + expect(roo.mcp!.entryType).toBe('streamable-http'); expect(roo.mcp!.supportsSubAgentEntries).toBe(true); }); @@ -875,6 +886,7 @@ interface ExpectedIntegration { format: 'json' | 'toml'; serversKey: string; entryUrlKey: string; + entryType?: string; supportsSubAgentEntries?: boolean; }; instructions?: string; @@ -895,11 +907,11 @@ interface ExpectedIntegration { const expandedIntegrations: Record = { // Tier 1 — full project-local integrations crush: { - mcp: { configPath: '.crush.json', format: 'json', serversKey: 'mcp', entryUrlKey: 'url' }, + mcp: { configPath: '.crush.json', format: 'json', serversKey: 'mcp', entryUrlKey: 'url', entryType: 'http' }, instructions: 'AGENTS.md', }, droid: { - mcp: { configPath: '.factory/mcp.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url' }, + mcp: { configPath: '.factory/mcp.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url', entryType: 'http' }, instructions: 'AGENTS.md', subagents: { dir: '.factory/droids', extension: '.md', format: 'markdown-frontmatter' }, // Plugin manifest format (`.factory-plugin/plugin.json`) is documented but @@ -916,11 +928,11 @@ const expandedIntegrations: Record = { subagents: { dir: '.junie/agents', extension: '.md', format: 'markdown-frontmatter' }, }, 'iflow-cli': { - mcp: { configPath: '.iflow/settings.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url' }, + mcp: { configPath: '.iflow/settings.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'httpUrl' }, instructions: 'AGENTS.md', }, kilo: { - mcp: { configPath: '.kilocode/mcp.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url' }, + mcp: { configPath: '.kilocode/mcp.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url', entryType: 'streamable-http' }, instructions: 'AGENTS.md', rules: { dir: '.kilo/rules', extension: '.md', frontmatter: 'none' }, subagents: { dir: '.kilo/agent', extension: '.md', format: 'markdown-frontmatter' }, @@ -931,13 +943,13 @@ const expandedIntegrations: Record = { rules: { dir: '.kiro/steering', extension: '.md', frontmatter: 'none' }, }, kode: { - mcp: { configPath: '.mcp.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url' }, + mcp: { configPath: '.mcp.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url', entryType: 'http' }, instructions: 'AGENTS.md', subagents: { dir: '.kode/agents', extension: '.md', format: 'markdown-frontmatter' }, // `.kode-plugin/plugin.json` is the new manifest path; no capa parser yet. }, neovate: { - mcp: { configPath: '.neovate/config.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url' }, + mcp: { configPath: '.neovate/config.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url', entryType: 'http' }, }, pochi: { mcp: { configPath: '.pochi/config.jsonc', format: 'json', serversKey: 'mcp', entryUrlKey: 'url' }, @@ -950,7 +962,7 @@ const expandedIntegrations: Record = { subagents: { dir: '.qoder/agents', extension: '.md', format: 'markdown-frontmatter' }, }, 'qwen-code': { - mcp: { configPath: '.qwen/settings.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url' }, + mcp: { configPath: '.qwen/settings.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'httpUrl' }, instructions: 'AGENTS.md', subagents: { dir: '.qwen/agents', extension: '.md', format: 'markdown-frontmatter' }, }, @@ -965,7 +977,7 @@ const expandedIntegrations: Record = { rules: { dir: '.trae/rules', extension: '.md', frontmatter: 'none' }, }, codebuddy: { - mcp: { configPath: '.mcp.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url' }, + mcp: { configPath: '.mcp.json', format: 'json', serversKey: 'mcpServers', entryUrlKey: 'url', entryType: 'http' }, instructions: 'CODEBUDDY.md', }, @@ -1021,6 +1033,7 @@ describe('Expanded provider integrations', () => { expect(provider!.mcp!.serversKey).toBe(exp.mcp!.serversKey); expect(provider!.mcp!.entryUrlKey).toBe(exp.mcp!.entryUrlKey); expect(provider!.mcp!.serverKey).toBe('capa'); + expect(provider!.mcp!.entryType).toBe(exp.mcp!.entryType); }); } diff --git a/src/shared/providers/handlers.ts b/src/shared/providers/handlers.ts index 4f13e9db..ddc900e4 100644 --- a/src/shared/providers/handlers.ts +++ b/src/shared/providers/handlers.ts @@ -10,7 +10,10 @@ import type { Rule } from '../../types/rules'; * e.g. { url: 'http://...' } */ export function buildMcpEntry(mcp: McpIntegration, url: string): Record { - return { [mcp.entryUrlKey]: url }; + const entry: Record = {}; + if (mcp.entryType) entry.type = mcp.entryType; + entry[mcp.entryUrlKey] = url; + return entry; } /** diff --git a/src/shared/providers/registry.ts b/src/shared/providers/registry.ts index 213df369..c777ddb4 100644 --- a/src/shared/providers/registry.ts +++ b/src/shared/providers/registry.ts @@ -76,6 +76,7 @@ export const providers: Record = { serversKey: 'mcpServers', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'http', supportsSubAgentEntries: true, defaultMcpFallbackPath: '.mcp.json', }, @@ -163,6 +164,7 @@ export const providers: Record = { serversKey: 'mcpServers', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'http', supportsSubAgentEntries: true, }, instructions: { filename: 'CODEBUDDY.md' }, @@ -268,6 +270,7 @@ export const providers: Record = { serversKey: 'mcp', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'http', supportsSubAgentEntries: true, }, instructions: { filename: 'AGENTS.md' }, @@ -352,6 +355,7 @@ export const providers: Record = { serversKey: 'mcpServers', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'http', supportsSubAgentEntries: true, }, instructions: { filename: 'AGENTS.md' }, @@ -439,6 +443,7 @@ export const providers: Record = { serversKey: 'servers', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'http', supportsSubAgentEntries: false, }, instructions: { filename: '.github/copilot-instructions.md' }, @@ -469,6 +474,7 @@ export const providers: Record = { skillsDir: '.junie/skills', globalSkillsDir: join(home, '.junie/skills'), detectInstalled: async () => existsSync(join(home, '.junie')), + // Junie's native remote-HTTP schema is undocumented; this bare { url } entry is unverified. mcp: { configPath: '.junie/mcp/mcp.json', format: 'json', @@ -495,7 +501,7 @@ export const providers: Record = { format: 'json', serversKey: 'mcpServers', serverKey: 'capa', - entryUrlKey: 'url', + entryUrlKey: 'httpUrl', supportsSubAgentEntries: true, }, instructions: { filename: 'AGENTS.md' }, @@ -514,6 +520,7 @@ export const providers: Record = { serversKey: 'mcpServers', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'streamable-http', supportsSubAgentEntries: true, }, instructions: { filename: 'AGENTS.md' }, @@ -574,6 +581,7 @@ export const providers: Record = { serversKey: 'mcpServers', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'http', supportsSubAgentEntries: true, }, instructions: { filename: 'AGENTS.md' }, @@ -627,6 +635,7 @@ export const providers: Record = { serversKey: 'mcp', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'remote', supportsSubAgentEntries: true, }, instructions: { filename: 'AGENTS.md' }, @@ -687,7 +696,7 @@ export const providers: Record = { format: 'json', serversKey: 'mcpServers', serverKey: 'capa', - entryUrlKey: 'url', + entryUrlKey: 'httpUrl', supportsSubAgentEntries: true, }, instructions: { filename: 'AGENTS.md' }, @@ -720,6 +729,7 @@ export const providers: Record = { serversKey: 'mcpServers', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'streamable-http', supportsSubAgentEntries: true, }, }, @@ -804,6 +814,7 @@ export const providers: Record = { serversKey: 'mcpServers', serverKey: 'capa', entryUrlKey: 'url', + entryType: 'http', supportsSubAgentEntries: true, }, // No project-root instructions file documented for Neovate. diff --git a/src/types/providers.ts b/src/types/providers.ts index 184bb862..3f4800ad 100644 --- a/src/types/providers.ts +++ b/src/types/providers.ts @@ -15,6 +15,8 @@ export interface McpIntegration { serverKey: string; /** The key inside the server entry that holds the URL (e.g. 'url'). */ entryUrlKey: string; + /** Transport discriminator written as the entry's `type` field, for providers that require it. */ + entryType?: string; /** Whether per-sub-agent MCP entries ('capa-{id}') can coexist with the main entry. */ supportsSubAgentEntries: boolean; /** Fallback config path when the primary `configPath` doesn't exist. */ From 69597fc4ef53afcaeba93b7a2299db14d60c0a7b Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Fri, 29 May 2026 03:53:05 +0300 Subject: [PATCH 10/10] test(mcp): add transport type to MCP server configuration test - Updated the test for MCP client registration to include the `type` field in the expected configuration for the `capa-infra-agent`. - This change ensures that the test accurately reflects the recent enhancements made to the MCP integration regarding transport type specification. --- src/cli/utils/__tests__/sub-agent-mcp.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/utils/__tests__/sub-agent-mcp.test.ts b/src/cli/utils/__tests__/sub-agent-mcp.test.ts index f9e4495d..63bf924d 100644 --- a/src/cli/utils/__tests__/sub-agent-mcp.test.ts +++ b/src/cli/utils/__tests__/sub-agent-mcp.test.ts @@ -31,6 +31,7 @@ describe('sub-agent MCP client registration', () => { const config = readConfig(join(tempDir, '.mcp.json')); expect(config.mcpServers['capa-infra-agent']).toEqual({ + type: 'http', url: 'http://localhost:5912/proj-1/agents/infra-agent/mcp', }); expect(config.mcpServers['capa']).toBeUndefined();