diff --git a/README.md b/README.md
index ddeb90f..5d018fc 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
A local-first control center for AI extensions.
- Use, review, scan, and discover Skills, MCP servers, slash commands, and CLI tools across agent harnesses.
+ Use, review, scan, and discover Skills, MCP servers, slash commands, hooks, and CLI tools across agent harnesses.
@@ -40,6 +40,7 @@ AI extensions are scattered across harness-specific folders, MCP config files, s
- Scan Skills with a saved LLM provider configuration and review findings before use.
- Install or adopt MCP server configs, resolve differences, and enable them where supported.
- Manage reusable slash commands once, then sync them to supported harnesses.
+- Manage hooks as normalized records, then sync them into supported harness settings with drift detection and review for unmanaged entries.
- Discover Skills, MCP servers, and preview-only CLI tools from marketplace sources.
## Product tour
@@ -164,16 +165,22 @@ The npm wrapper downloads the native release artifact for the current platform a
OpenClaw
Docs
+
+
+ Antigravity (agy)
+ Docs
+
-| Harness | Skills | MCP servers | Slash commands |
-|---|---:|---:|---:|
-| Codex CLI | Yes | Yes | Yes |
-| Claude Code | Yes | Yes | Yes |
-| Cursor | Yes | Yes | Yes |
-| OpenCode | Yes | Yes | Yes |
-| OpenClaw | Yes | Not Yet | Not Yet |
+| Harness | Skills | MCP servers | Slash commands | Hooks |
+|---|---:|---:|---:|---:|
+| Codex CLI | Yes | Yes | Yes | Yes |
+| Claude Code | Yes | Yes | Yes | Yes |
+| Cursor | Yes | Yes | Yes | Yes |
+| OpenCode | Yes | Yes | Yes | Partial |
+| OpenClaw | Yes | Not Yet | Not Yet | Not Yet |
+| Antigravity (agy) | Yes | Yes | Not Yet | Partial |
## Local-first safety
@@ -191,6 +198,7 @@ Actions that can change local state include:
- adopting an existing MCP config
- enabling, disabling, resolving, or uninstalling an MCP server
- creating, updating, syncing, importing, or deleting a slash command
+- creating, enabling, disabling, resolving, or deleting a hook binding
- changing harness support settings
App-owned files live under `~/Library/Application Support/skill-manager` on macOS and XDG base directories on Linux.
@@ -218,6 +226,7 @@ MCP servers are stored as normalized Skill Manager records, then translated into
- Codex uses TOML under `mcp_servers`.
- Claude Code and Cursor use `mcpServers` JSON entries.
- OpenCode uses typed local/remote MCP entries.
+- Antigravity (agy) uses `mcpServers` JSON entries with `serverUrl` for HTTP transports and `command`/`args`/`env` for stdio.
- OpenClaw MCP writes are not yet supported.
When Skill Manager finds different configs for the same MCP server, it asks you to resolve the source of truth first.
@@ -232,10 +241,24 @@ Slash commands are stored as TOML records under Skill Manager app storage, then
- Claude Code writes Markdown command files under `~/.claude/commands` and invokes them with `/`.
- Cursor writes plain text command files under `~/.cursor/commands` and invokes them with `/`.
- Codex writes prompt files under `~/.codex/prompts` and invokes them with `/prompts:`.
-- OpenClaw slash command writes are not yet supported.
+- OpenClaw and Antigravity (agy) slash command writes are not yet supported.
Skill Manager tracks target ownership with sync state and content hashes. It will not overwrite an untracked command file automatically, and it reports managed files as changed or missing when the target no longer matches the last synced hash. Review actions let you adopt unmanaged commands, restore managed content, adopt a changed harness command as the new source, or remove a broken binding while leaving the harness file untouched.
+### Hooks
+
+Hooks are stored as normalized Skill Manager records using **canonical events** (`pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `session_start`, `stop`, `pre_compact`) and **canonical tool categories** (`shell`, `file_read`, `file_write`, `mcp`, `web`, `any`). Each harness codec translates a canonical record into that harness's native event names and config shape, and merges it into the harness's hook config:
+
+- Claude Code writes hook entries into `~/.claude/settings.json` under the `hooks` key.
+- Codex writes inline `[hooks]` tables into `~/.codex/config.toml` (same event schema as Claude).
+- Cursor writes `~/.cursor/hooks.json`, expressing each tool category as its dedicated event (`beforeShellExecution`, `afterFileEdit`, `beforeMCPExecution`, and so on).
+- OpenCode writes `experimental.hook` entries in `opencode.json` — limited to `file_edited` (post-edit on write) and `session_completed` (stop), so coverage is partial.
+- Antigravity (agy) writes a name-keyed `~/.gemini/config/hooks.json`, matching against its own tool names (`run_command`, `view_file`, …); it covers tool, stop, and (via `PreInvocation`) prompt-submit hooks, so coverage is partial.
+
+Because harnesses differ, not every canonical event maps to every harness. Skill Manager exposes a **representability matrix** showing where each hook can sync and where it cannot, including caveats — for example, an Antigravity `user_prompt_submit` hook maps to `PreInvocation`, which fires before every model invocation rather than only on prompt submit.
+
+Skill Manager owns only the specific hook entries it writes. It merges into each harness's config without disturbing hooks or other keys it does not manage, and it tracks ownership with content hashes. When a managed hook is edited outside Skill Manager it is reported as drifted, and hooks found in a harness that Skill Manager does not manage are reported as unmanaged for review.
+
### CLIs
CLI marketplace entries are preview-only.
@@ -248,6 +271,7 @@ Useful macOS paths:
- shared skills store: `~/Library/Application Support/skill-manager/shared`
- MCP manifest: `~/Library/Application Support/skill-manager/mcp/manifest.json`
+- hooks manifest: `~/Library/Application Support/skill-manager/hooks/manifest.json`
- slash command library: `~/Library/Application Support/skill-manager/slash-commands/commands`
- slash command sync state: `~/Library/Application Support/skill-manager/slash-commands/sync-state.json`
- marketplace cache: `~/Library/Application Support/skill-manager/marketplace`
@@ -258,6 +282,7 @@ Useful Linux paths:
- shared skills store: `${XDG_DATA_HOME:-~/.local/share}/skill-manager/shared`
- MCP manifest: `${XDG_DATA_HOME:-~/.local/share}/skill-manager/mcp/manifest.json`
+- hooks manifest: `${XDG_DATA_HOME:-~/.local/share}/skill-manager/hooks/manifest.json`
- slash command library: `${XDG_DATA_HOME:-~/.local/share}/skill-manager/slash-commands/commands`
- slash command sync state: `${XDG_DATA_HOME:-~/.local/share}/skill-manager/slash-commands/sync-state.json`
- marketplace cache: `${XDG_DATA_HOME:-~/.local/share}/skill-manager/marketplace`
@@ -273,6 +298,7 @@ Most users do not need to change these locations. If you manage skills in a cust
| Cursor | `SKILL_MANAGER_CURSOR_ROOT` | `~/.cursor/skills` |
| OpenCode | `SKILL_MANAGER_OPENCODE_ROOT` | `~/.config/opencode/skills` |
| OpenClaw | `n/a` | `~/.openclaw/skills` |
+| Antigravity (agy) | `SKILL_MANAGER_AGY_ROOT` | `~/.gemini/antigravity-cli/skills` |
MCP config locations are harness-owned. Skill Manager writes only to verified config paths and skips unsupported harness writes.
@@ -337,7 +363,7 @@ npm run build
### Extension families
-- [ ] Hook support
+- [x] Hook support
- [x] Slash command support
- [ ] Plugin support
diff --git a/assets/harness-logos/agy-logo.svg b/assets/harness-logos/agy-logo.svg
new file mode 100644
index 0000000..fe5fd4c
--- /dev/null
+++ b/assets/harness-logos/agy-logo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d94b43d..266b368 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -21,6 +21,8 @@ const SlashCommandsPage = lazy(() => import("./features/slash-commands/screens/S
const SlashCommandsReviewPage = lazy(() => import("./features/slash-commands/screens/SlashCommandsReviewPage"));
const McpNeedsReviewPage = lazy(() => import("./features/mcp/screens/McpNeedsReviewPage"));
const McpInUsePage = lazy(() => import("./features/mcp/screens/McpInUsePage"));
+const HooksInUsePage = lazy(() => import("./features/hooks/screens/HooksInUsePage"));
+const HooksNeedsReviewPage = lazy(() => import("./features/hooks/screens/HooksNeedsReviewPage"));
export function App() {
const [queryClient] = useState(
@@ -105,6 +107,24 @@ function AppContent() {
} />
} />
+ } />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+
;
export interface components {
schemas: {
+ /** AddHookRequest */
+ AddHookRequest: {
+ /** Command */
+ command: string;
+ /**
+ * Description
+ * @default
+ */
+ description: string;
+ /** Event */
+ event: string;
+ /** Id */
+ id: string;
+ /** Match */
+ match?: string | null;
+ /** Timeout */
+ timeout?: number | null;
+ };
/** AddMcpServerRequest */
AddMcpServerRequest: {
/** Qualifiedname */
@@ -948,6 +1070,14 @@ export interface components {
/** Provider */
provider: string;
};
+ /** DisableHookRequest */
+ DisableHookRequest: {
+ /**
+ * Harness
+ * @description Harness identifier
+ */
+ harness: string;
+ };
/** DisableMcpServerRequest */
DisableMcpServerRequest: {
/**
@@ -964,6 +1094,14 @@ export interface components {
*/
harness: string;
};
+ /** EnableHookRequest */
+ EnableHookRequest: {
+ /**
+ * Harness
+ * @description Harness identifier
+ */
+ harness: string;
+ };
/** EnableMcpServerRequest */
EnableMcpServerRequest: {
/** Config */
@@ -1016,6 +1154,129 @@ export interface components {
/** Logokey */
logoKey?: string | null;
};
+ /** HookApplyConfigResponse */
+ HookApplyConfigResponse: {
+ /** Failed */
+ failed: components["schemas"]["HookMutationFailureResponse"][];
+ hook: components["schemas"]["HookSpecResponse"];
+ /** Ok */
+ ok: boolean;
+ /** Succeeded */
+ succeeded: string[];
+ };
+ /** HookBindingResponse */
+ HookBindingResponse: {
+ /** Caveat */
+ caveat?: string | null;
+ /** Driftdetail */
+ driftDetail?: string | null;
+ /** Harness */
+ harness: string;
+ /**
+ * State
+ * @enum {string}
+ */
+ state: "managed" | "drifted" | "unmanaged" | "missing" | "unsupported";
+ };
+ /** HookInventoryColumnResponse */
+ HookInventoryColumnResponse: {
+ /** Configpresent */
+ configPresent: boolean;
+ /** Harness */
+ harness: string;
+ /** Hooksunavailablereason */
+ hooksUnavailableReason?: string | null;
+ /**
+ * Hookswritable
+ * @default true
+ */
+ hooksWritable: boolean;
+ /** Installed */
+ installed: boolean;
+ /** Label */
+ label: string;
+ /** Logokey */
+ logoKey?: string | null;
+ };
+ /** HookInventoryEntryResponse */
+ HookInventoryEntryResponse: {
+ /** Canenable */
+ canEnable: boolean;
+ /** Displayname */
+ displayName: string;
+ /**
+ * Enabledstatus
+ * @enum {string}
+ */
+ enabledStatus: "enabled" | "disabled";
+ /** Id */
+ id: string;
+ /**
+ * Kind
+ * @enum {string}
+ */
+ kind: "managed" | "unmanaged";
+ /** Sightings */
+ sightings: components["schemas"]["HookBindingResponse"][];
+ spec?: components["schemas"]["HookSpecResponse"] | null;
+ };
+ /** HookInventoryIssueResponse */
+ HookInventoryIssueResponse: {
+ /** Name */
+ name: string;
+ /** Reason */
+ reason: string;
+ };
+ /** HookInventoryResponse */
+ HookInventoryResponse: {
+ /** Columns */
+ columns: components["schemas"]["HookInventoryColumnResponse"][];
+ /** Entries */
+ entries: components["schemas"]["HookInventoryEntryResponse"][];
+ /** Issues */
+ issues?: components["schemas"]["HookInventoryIssueResponse"][];
+ };
+ /** HookMutationFailureResponse */
+ HookMutationFailureResponse: {
+ /** Error */
+ error: string;
+ /** Harness */
+ harness: string;
+ };
+ /** HookMutationResponse */
+ HookMutationResponse: {
+ hook: components["schemas"]["HookSpecResponse"];
+ /** Ok */
+ ok: boolean;
+ };
+ /** HookSetHarnessesResultResponse */
+ HookSetHarnessesResultResponse: {
+ /** Failed */
+ failed: components["schemas"]["HookMutationFailureResponse"][];
+ /** Ok */
+ ok: boolean;
+ /** Succeeded */
+ succeeded: string[];
+ };
+ /** HookSpecResponse */
+ HookSpecResponse: {
+ /** Command */
+ command: string;
+ /** Description */
+ description: string;
+ /** Event */
+ event: string;
+ /** Id */
+ id: string;
+ /** Installedat */
+ installedAt: string;
+ /** Match */
+ match?: string | null;
+ /** Revision */
+ revision: string;
+ /** Timeout */
+ timeout?: number | null;
+ };
/** InstallMarketplaceSkillRequest */
InstallMarketplaceSkillRequest: {
/** Installtoken */
@@ -1614,6 +1875,18 @@ export interface components {
/** Ok */
ok: boolean;
};
+ /** ReconcileHookRequest */
+ ReconcileHookRequest: {
+ /** Harnesses */
+ harnesses?: string[] | null;
+ /** Observed harness */
+ observedHarness?: string | null;
+ /**
+ * Sourcekind
+ * @enum {string}
+ */
+ sourceKind: "managed" | "harness";
+ };
/** ReconcileMcpServerRequest */
ReconcileMcpServerRequest: {
/** Harnesses */
@@ -1874,6 +2147,14 @@ export interface components {
/** Enabled */
enabled: boolean;
};
+ /** SetHookHarnessesRequest */
+ SetHookHarnessesRequest: {
+ /**
+ * Target
+ * @enum {string}
+ */
+ target: "enabled" | "disabled";
+ };
/** SetMcpServerHarnessesRequest */
SetMcpServerHarnessesRequest: {
/** Config */
@@ -2293,6 +2574,261 @@ export interface operations {
};
};
};
+ list_hooks_api_hooks_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HookInventoryResponse"];
+ };
+ };
+ };
+ };
+ create_hook_api_hooks_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["AddHookRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HookMutationResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_hook_api_hooks__id__get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HookInventoryEntryResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_hook_api_hooks__id__delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HookSetHarnessesResultResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ disable_hook_api_hooks__id__disable_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["DisableHookRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["OkResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ enable_hook_api_hooks__id__enable_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["EnableHookRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["OkResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ reconcile_hook_api_hooks__id__reconcile_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ReconcileHookRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HookApplyConfigResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ set_hook_harnesses_api_hooks__id__set_harnesses_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["SetHookHarnessesRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HookSetHarnessesResultResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_cli_marketplace_detail_api_marketplace_clis_items__slug__get: {
parameters: {
query?: never;
diff --git a/frontend/src/api/openapi.json b/frontend/src/api/openapi.json
index 2697179..293745c 100644
--- a/frontend/src/api/openapi.json
+++ b/frontend/src/api/openapi.json
@@ -1,6 +1,60 @@
{
"components": {
"schemas": {
+ "AddHookRequest": {
+ "additionalProperties": false,
+ "properties": {
+ "command": {
+ "minLength": 1,
+ "title": "Command",
+ "type": "string"
+ },
+ "description": {
+ "default": "",
+ "title": "Description",
+ "type": "string"
+ },
+ "event": {
+ "minLength": 1,
+ "title": "Event",
+ "type": "string"
+ },
+ "id": {
+ "minLength": 1,
+ "title": "Id",
+ "type": "string"
+ },
+ "match": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Match"
+ },
+ "timeout": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Timeout"
+ }
+ },
+ "required": [
+ "id",
+ "event",
+ "command"
+ ],
+ "title": "AddHookRequest",
+ "type": "object"
+ },
"AddMcpServerRequest": {
"additionalProperties": false,
"properties": {
@@ -491,6 +545,21 @@
"title": "DetectedProviderResponse",
"type": "object"
},
+ "DisableHookRequest": {
+ "properties": {
+ "harness": {
+ "description": "Harness identifier",
+ "minLength": 1,
+ "title": "Harness",
+ "type": "string"
+ }
+ },
+ "required": [
+ "harness"
+ ],
+ "title": "DisableHookRequest",
+ "type": "object"
+ },
"DisableMcpServerRequest": {
"properties": {
"harness": {
@@ -521,6 +590,21 @@
"title": "DisableSkillRequest",
"type": "object"
},
+ "EnableHookRequest": {
+ "properties": {
+ "harness": {
+ "description": "Harness identifier",
+ "minLength": 1,
+ "title": "Harness",
+ "type": "string"
+ }
+ },
+ "required": [
+ "harness"
+ ],
+ "title": "EnableHookRequest",
+ "type": "object"
+ },
"EnableMcpServerRequest": {
"properties": {
"config": {
@@ -655,23 +739,42 @@
"title": "HarnessColumnResponse",
"type": "object"
},
- "InstallMarketplaceSkillRequest": {
+ "HookApplyConfigResponse": {
"properties": {
- "installToken": {
- "minLength": 1,
- "title": "Installtoken",
- "type": "string"
+ "failed": {
+ "items": {
+ "$ref": "#/components/schemas/HookMutationFailureResponse"
+ },
+ "title": "Failed",
+ "type": "array"
+ },
+ "hook": {
+ "$ref": "#/components/schemas/HookSpecResponse"
+ },
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
+ },
+ "succeeded": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Succeeded",
+ "type": "array"
}
},
"required": [
- "installToken"
+ "ok",
+ "hook",
+ "succeeded",
+ "failed"
],
- "title": "InstallMarketplaceSkillRequest",
+ "title": "HookApplyConfigResponse",
"type": "object"
},
- "LLMDetectionResponse": {
+ "HookBindingResponse": {
"properties": {
- "defaultModel": {
+ "caveat": {
"anyOf": [
{
"type": "string"
@@ -680,9 +783,9 @@
"type": "null"
}
],
- "title": "Defaultmodel"
+ "title": "Caveat"
},
- "defaultProvider": {
+ "driftDetail": {
"anyOf": [
{
"type": "string"
@@ -691,30 +794,42 @@
"type": "null"
}
],
- "title": "Defaultprovider"
+ "title": "Driftdetail"
},
- "hasAnyAvailable": {
- "title": "Hasanyavailable",
- "type": "boolean"
+ "harness": {
+ "title": "Harness",
+ "type": "string"
},
- "providers": {
- "items": {
- "$ref": "#/components/schemas/DetectedProviderResponse"
- },
- "title": "Providers",
- "type": "array"
+ "state": {
+ "enum": [
+ "managed",
+ "drifted",
+ "unmanaged",
+ "missing",
+ "unsupported"
+ ],
+ "title": "State",
+ "type": "string"
}
},
"required": [
- "providers",
- "hasAnyAvailable"
+ "harness",
+ "state"
],
- "title": "LLMDetectionResponse",
+ "title": "HookBindingResponse",
"type": "object"
},
- "McpAdoptionIssueResponse": {
+ "HookInventoryColumnResponse": {
"properties": {
- "configPath": {
+ "configPresent": {
+ "title": "Configpresent",
+ "type": "boolean"
+ },
+ "harness": {
+ "title": "Harness",
+ "type": "string"
+ },
+ "hooksUnavailableReason": {
"anyOf": [
{
"type": "string"
@@ -723,11 +838,16 @@
"type": "null"
}
],
- "title": "Configpath"
+ "title": "Hooksunavailablereason"
},
- "harness": {
- "title": "Harness",
- "type": "string"
+ "hooksWritable": {
+ "default": true,
+ "title": "Hookswritable",
+ "type": "boolean"
+ },
+ "installed": {
+ "title": "Installed",
+ "type": "boolean"
},
"label": {
"title": "Label",
@@ -743,22 +863,81 @@
}
],
"title": "Logokey"
+ }
+ },
+ "required": [
+ "harness",
+ "label",
+ "installed",
+ "configPresent"
+ ],
+ "title": "HookInventoryColumnResponse",
+ "type": "object"
+ },
+ "HookInventoryEntryResponse": {
+ "properties": {
+ "canEnable": {
+ "title": "Canenable",
+ "type": "boolean"
},
- "name": {
- "title": "Name",
+ "displayName": {
+ "title": "Displayname",
"type": "string"
},
- "payloadPreview": {
+ "enabledStatus": {
+ "enum": [
+ "enabled",
+ "disabled"
+ ],
+ "title": "Enabledstatus",
+ "type": "string"
+ },
+ "id": {
+ "title": "Id",
+ "type": "string"
+ },
+ "kind": {
+ "enum": [
+ "managed",
+ "unmanaged"
+ ],
+ "title": "Kind",
+ "type": "string"
+ },
+ "sightings": {
+ "items": {
+ "$ref": "#/components/schemas/HookBindingResponse"
+ },
+ "title": "Sightings",
+ "type": "array"
+ },
+ "spec": {
"anyOf": [
{
- "additionalProperties": true,
- "type": "object"
+ "$ref": "#/components/schemas/HookSpecResponse"
},
{
"type": "null"
}
- ],
- "title": "Payloadpreview"
+ ]
+ }
+ },
+ "required": [
+ "id",
+ "displayName",
+ "kind",
+ "canEnable",
+ "enabledStatus",
+ "sightings"
+ ],
+ "title": "HookInventoryEntryResponse",
+ "type": "object"
+ },
+ "HookInventoryIssueResponse": {
+ "properties": {
+ "name": {
+ "title": "Name",
+ "type": "string"
},
"reason": {
"title": "Reason",
@@ -766,71 +945,65 @@
}
},
"required": [
- "harness",
- "label",
"name",
"reason"
],
- "title": "McpAdoptionIssueResponse",
+ "title": "HookInventoryIssueResponse",
"type": "object"
},
- "McpApplyConfigResponse": {
+ "HookInventoryResponse": {
"properties": {
- "failed": {
+ "columns": {
"items": {
- "$ref": "#/components/schemas/McpMutationFailureResponse"
+ "$ref": "#/components/schemas/HookInventoryColumnResponse"
},
- "title": "Failed",
+ "title": "Columns",
"type": "array"
},
- "ok": {
- "title": "Ok",
- "type": "boolean"
- },
- "server": {
- "$ref": "#/components/schemas/McpServerSpecResponse"
+ "entries": {
+ "items": {
+ "$ref": "#/components/schemas/HookInventoryEntryResponse"
+ },
+ "title": "Entries",
+ "type": "array"
},
- "succeeded": {
+ "issues": {
"items": {
- "type": "string"
+ "$ref": "#/components/schemas/HookInventoryIssueResponse"
},
- "title": "Succeeded",
+ "title": "Issues",
"type": "array"
}
},
"required": [
- "ok",
- "server",
- "succeeded",
- "failed"
+ "columns",
+ "entries"
],
- "title": "McpApplyConfigResponse",
+ "title": "HookInventoryResponse",
"type": "object"
},
- "McpAvailabilityCheckResponse": {
+ "HookMutationFailureResponse": {
"properties": {
- "availabilityReason": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Availabilityreason"
- },
- "availabilityStatus": {
- "enum": [
- "available",
- "unavailable"
- ],
- "title": "Availabilitystatus",
+ "error": {
+ "title": "Error",
"type": "string"
},
- "name": {
- "title": "Name",
+ "harness": {
+ "title": "Harness",
"type": "string"
+ }
+ },
+ "required": [
+ "harness",
+ "error"
+ ],
+ "title": "HookMutationFailureResponse",
+ "type": "object"
+ },
+ "HookMutationResponse": {
+ "properties": {
+ "hook": {
+ "$ref": "#/components/schemas/HookSpecResponse"
},
"ok": {
"title": "Ok",
@@ -839,76 +1012,63 @@
},
"required": [
"ok",
- "name",
- "availabilityStatus"
+ "hook"
],
- "title": "McpAvailabilityCheckResponse",
+ "title": "HookMutationResponse",
"type": "object"
},
- "McpBindingResponse": {
+ "HookSetHarnessesResultResponse": {
"properties": {
- "driftDetail": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Driftdetail"
+ "failed": {
+ "items": {
+ "$ref": "#/components/schemas/HookMutationFailureResponse"
+ },
+ "title": "Failed",
+ "type": "array"
},
- "harness": {
- "title": "Harness",
- "type": "string"
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
},
- "state": {
- "enum": [
- "managed",
- "drifted",
- "unmanaged",
- "missing"
- ],
- "title": "State",
- "type": "string"
+ "succeeded": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Succeeded",
+ "type": "array"
}
},
"required": [
- "harness",
- "state"
+ "ok",
+ "succeeded",
+ "failed"
],
- "title": "McpBindingResponse",
+ "title": "HookSetHarnessesResultResponse",
"type": "object"
},
- "McpConfigChoiceResponse": {
+ "HookSpecResponse": {
"properties": {
- "configPath": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Configpath"
+ "command": {
+ "title": "Command",
+ "type": "string"
},
- "env": {
- "items": {
- "$ref": "#/components/schemas/McpEnvEntryResponse"
- },
- "title": "Env",
- "type": "array"
+ "description": {
+ "title": "Description",
+ "type": "string"
+ },
+ "event": {
+ "title": "Event",
+ "type": "string"
},
"id": {
"title": "Id",
"type": "string"
},
- "label": {
- "title": "Label",
+ "installedAt": {
+ "title": "Installedat",
"type": "string"
},
- "logoKey": {
+ "match": {
"anyOf": [
{
"type": "string"
@@ -917,127 +1077,93 @@
"type": "null"
}
],
- "title": "Logokey"
+ "title": "Match"
},
- "observedHarness": {
+ "revision": {
+ "title": "Revision",
+ "type": "string"
+ },
+ "timeout": {
"anyOf": [
{
- "type": "string"
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Observed harness"
- },
- "payloadPreview": {
- "additionalProperties": true,
- "title": "Payloadpreview",
- "type": "object"
- },
- "recommended": {
- "default": false,
- "title": "Recommended",
- "type": "boolean"
- },
- "sourceKind": {
- "enum": [
- "managed",
- "harness"
- ],
- "title": "Sourcekind",
- "type": "string"
- },
- "spec": {
- "$ref": "#/components/schemas/McpServerSpecResponse"
+ "title": "Timeout"
}
},
"required": [
"id",
- "sourceKind",
- "label",
- "payloadPreview",
- "spec"
+ "event",
+ "command",
+ "description",
+ "installedAt",
+ "revision"
],
- "title": "McpConfigChoiceResponse",
+ "title": "HookSpecResponse",
"type": "object"
},
- "McpEnvEntryResponse": {
+ "InstallMarketplaceSkillRequest": {
"properties": {
- "isEnvRef": {
- "title": "Isenvref",
- "type": "boolean"
- },
- "key": {
- "title": "Key",
+ "installToken": {
+ "minLength": 1,
+ "title": "Installtoken",
"type": "string"
- },
- "value": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Value"
}
},
"required": [
- "key",
- "isEnvRef"
+ "installToken"
],
- "title": "McpEnvEntryResponse",
+ "title": "InstallMarketplaceSkillRequest",
"type": "object"
},
- "McpIdentityGroupResponse": {
+ "LLMDetectionResponse": {
"properties": {
- "canonicalSpec": {
+ "defaultModel": {
"anyOf": [
{
- "$ref": "#/components/schemas/McpServerSpecResponse"
+ "type": "string"
},
{
"type": "null"
}
- ]
- },
- "identical": {
- "title": "Identical",
- "type": "boolean"
+ ],
+ "title": "Defaultmodel"
},
- "marketplaceLink": {
+ "defaultProvider": {
"anyOf": [
{
- "$ref": "#/components/schemas/McpMarketplaceLinkResponse"
+ "type": "string"
},
{
"type": "null"
}
- ]
+ ],
+ "title": "Defaultprovider"
},
- "name": {
- "title": "Name",
- "type": "string"
+ "hasAnyAvailable": {
+ "title": "Hasanyavailable",
+ "type": "boolean"
},
- "sightings": {
+ "providers": {
"items": {
- "$ref": "#/components/schemas/McpIdentitySightingResponse"
+ "$ref": "#/components/schemas/DetectedProviderResponse"
},
- "title": "Sightings",
+ "title": "Providers",
"type": "array"
}
},
"required": [
- "name",
- "identical",
- "sightings"
+ "providers",
+ "hasAnyAvailable"
],
- "title": "McpIdentityGroupResponse",
+ "title": "LLMDetectionResponse",
"type": "object"
},
- "McpIdentitySightingResponse": {
+ "McpAdoptionIssueResponse": {
"properties": {
"configPath": {
"anyOf": [
@@ -1050,13 +1176,6 @@
],
"title": "Configpath"
},
- "env": {
- "items": {
- "$ref": "#/components/schemas/McpEnvEntryResponse"
- },
- "title": "Env",
- "type": "array"
- },
"harness": {
"title": "Harness",
"type": "string"
@@ -1076,39 +1195,72 @@
],
"title": "Logokey"
},
- "payloadPreview": {
- "additionalProperties": true,
- "title": "Payloadpreview",
- "type": "object"
+ "name": {
+ "title": "Name",
+ "type": "string"
},
- "recommended": {
- "default": false,
- "title": "Recommended",
- "type": "boolean"
+ "payloadPreview": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Payloadpreview"
},
- "spec": {
- "$ref": "#/components/schemas/McpServerSpecResponse"
+ "reason": {
+ "title": "Reason",
+ "type": "string"
}
},
"required": [
"harness",
"label",
- "payloadPreview",
- "spec"
+ "name",
+ "reason"
],
- "title": "McpIdentitySightingResponse",
+ "title": "McpAdoptionIssueResponse",
"type": "object"
},
- "McpInstallConfigFieldResponse": {
+ "McpApplyConfigResponse": {
"properties": {
- "choices": {
+ "failed": {
"items": {
- "type": "string"
+ "$ref": "#/components/schemas/McpMutationFailureResponse"
},
- "title": "Choices",
+ "title": "Failed",
"type": "array"
},
- "default": {
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
+ },
+ "server": {
+ "$ref": "#/components/schemas/McpServerSpecResponse"
+ },
+ "succeeded": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Succeeded",
+ "type": "array"
+ }
+ },
+ "required": [
+ "ok",
+ "server",
+ "succeeded",
+ "failed"
+ ],
+ "title": "McpApplyConfigResponse",
+ "type": "object"
+ },
+ "McpAvailabilityCheckResponse": {
+ "properties": {
+ "availabilityReason": {
"anyOf": [
{
"type": "string"
@@ -1117,31 +1269,36 @@
"type": "null"
}
],
- "title": "Default"
- },
- "description": {
- "title": "Description",
- "type": "string"
+ "title": "Availabilityreason"
},
- "format": {
+ "availabilityStatus": {
"enum": [
- "string",
- "number",
- "boolean",
- "filepath"
+ "available",
+ "unavailable"
],
- "title": "Format",
- "type": "string"
- },
- "label": {
- "title": "Label",
+ "title": "Availabilitystatus",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
- "placeholder": {
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "ok",
+ "name",
+ "availabilityStatus"
+ ],
+ "title": "McpAvailabilityCheckResponse",
+ "type": "object"
+ },
+ "McpBindingResponse": {
+ "properties": {
+ "driftDetail": {
"anyOf": [
{
"type": "string"
@@ -1150,100 +1307,54 @@
"type": "null"
}
],
- "title": "Placeholder"
- },
- "required": {
- "title": "Required",
- "type": "boolean"
+ "title": "Driftdetail"
},
- "secret": {
- "title": "Secret",
- "type": "boolean"
+ "harness": {
+ "title": "Harness",
+ "type": "string"
},
- "target": {
+ "state": {
"enum": [
- "env",
- "header",
- "urlVariable",
- "packageArgument",
- "runtimeArgument"
+ "managed",
+ "drifted",
+ "unmanaged",
+ "missing"
],
- "title": "Target",
+ "title": "State",
"type": "string"
}
},
"required": [
- "name",
- "label",
- "description",
- "format",
- "required",
- "secret",
- "target"
- ],
- "title": "McpInstallConfigFieldResponse",
- "type": "object"
- },
- "McpInstallConfigResponse": {
- "properties": {
- "fields": {
- "items": {
- "$ref": "#/components/schemas/McpInstallConfigFieldResponse"
- },
- "title": "Fields",
- "type": "array"
- },
- "required": {
- "title": "Required",
- "type": "boolean"
- }
- },
- "required": [
- "required"
+ "harness",
+ "state"
],
- "title": "McpInstallConfigResponse",
+ "title": "McpBindingResponse",
"type": "object"
},
- "McpInstallConfigStatusResponse": {
+ "McpConfigChoiceResponse": {
"properties": {
- "configured": {
- "title": "Configured",
- "type": "boolean"
- },
- "hasFields": {
- "title": "Hasfields",
- "type": "boolean"
+ "configPath": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Configpath"
},
- "missingRequired": {
+ "env": {
"items": {
- "type": "string"
+ "$ref": "#/components/schemas/McpEnvEntryResponse"
},
- "title": "Missingrequired",
+ "title": "Env",
"type": "array"
- }
- },
- "required": [
- "hasFields",
- "missingRequired",
- "configured"
- ],
- "title": "McpInstallConfigStatusResponse",
- "type": "object"
- },
- "McpInventoryColumnResponse": {
- "properties": {
- "configPresent": {
- "title": "Configpresent",
- "type": "boolean"
},
- "harness": {
- "title": "Harness",
+ "id": {
+ "title": "Id",
"type": "string"
},
- "installed": {
- "title": "Installed",
- "type": "boolean"
- },
"label": {
"title": "Label",
"type": "string"
@@ -1259,7 +1370,7 @@
],
"title": "Logokey"
},
- "mcpUnavailableReason": {
+ "observedHarness": {
"anyOf": [
{
"type": "string"
@@ -1268,26 +1379,51 @@
"type": "null"
}
],
- "title": "Mcpunavailablereason"
+ "title": "Observed harness"
},
- "mcpWritable": {
- "default": true,
- "title": "Mcpwritable",
+ "payloadPreview": {
+ "additionalProperties": true,
+ "title": "Payloadpreview",
+ "type": "object"
+ },
+ "recommended": {
+ "default": false,
+ "title": "Recommended",
"type": "boolean"
+ },
+ "sourceKind": {
+ "enum": [
+ "managed",
+ "harness"
+ ],
+ "title": "Sourcekind",
+ "type": "string"
+ },
+ "spec": {
+ "$ref": "#/components/schemas/McpServerSpecResponse"
}
},
"required": [
- "harness",
+ "id",
+ "sourceKind",
"label",
- "installed",
- "configPresent"
+ "payloadPreview",
+ "spec"
],
- "title": "McpInventoryColumnResponse",
+ "title": "McpConfigChoiceResponse",
"type": "object"
},
- "McpInventoryEntryResponse": {
+ "McpEnvEntryResponse": {
"properties": {
- "availabilityReason": {
+ "isEnvRef": {
+ "title": "Isenvref",
+ "type": "boolean"
+ },
+ "key": {
+ "title": "Key",
+ "type": "string"
+ },
+ "value": {
"anyOf": [
{
"type": "string"
@@ -1296,159 +1432,65 @@
"type": "null"
}
],
- "description": "Deprecated compatibility field; use mcpStatus.reason instead.",
- "title": "Availabilityreason"
- },
- "availabilityStatus": {
- "description": "Deprecated compatibility field; use mcpStatus instead.",
- "enum": [
- "available",
- "unavailable"
- ],
- "title": "Availabilitystatus",
- "type": "string"
+ "title": "Value"
+ }
+ },
+ "required": [
+ "key",
+ "isEnvRef"
+ ],
+ "title": "McpEnvEntryResponse",
+ "type": "object"
+ },
+ "McpIdentityGroupResponse": {
+ "properties": {
+ "canonicalSpec": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/McpServerSpecResponse"
+ },
+ {
+ "type": "null"
+ }
+ ]
},
- "canEnable": {
- "title": "Canenable",
+ "identical": {
+ "title": "Identical",
"type": "boolean"
},
- "displayName": {
- "title": "Displayname",
- "type": "string"
- },
- "enabledStatus": {
- "enum": [
- "enabled",
- "disabled"
- ],
- "title": "Enabledstatus",
- "type": "string"
- },
- "installConfigStatus": {
- "$ref": "#/components/schemas/McpInstallConfigStatusResponse"
- },
- "kind": {
- "enum": [
- "managed",
- "unmanaged"
- ],
- "title": "Kind",
- "type": "string"
- },
- "mcpStatus": {
- "$ref": "#/components/schemas/McpStatusResponse"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "sightings": {
- "items": {
- "$ref": "#/components/schemas/McpBindingResponse"
- },
- "title": "Sightings",
- "type": "array"
- },
- "spec": {
+ "marketplaceLink": {
"anyOf": [
{
- "$ref": "#/components/schemas/McpServerSpecResponse"
+ "$ref": "#/components/schemas/McpMarketplaceLinkResponse"
},
{
"type": "null"
}
]
- }
- },
- "required": [
- "name",
- "displayName",
- "kind",
- "canEnable",
- "enabledStatus",
- "availabilityStatus",
- "mcpStatus",
- "installConfigStatus",
- "sightings"
- ],
- "title": "McpInventoryEntryResponse",
- "type": "object"
- },
- "McpInventoryIssueResponse": {
- "properties": {
+ },
"name": {
"title": "Name",
"type": "string"
},
- "reason": {
- "title": "Reason",
- "type": "string"
- }
- },
- "required": [
- "name",
- "reason"
- ],
- "title": "McpInventoryIssueResponse",
- "type": "object"
- },
- "McpInventoryResponse": {
- "properties": {
- "columns": {
- "items": {
- "$ref": "#/components/schemas/McpInventoryColumnResponse"
- },
- "title": "Columns",
- "type": "array"
- },
- "entries": {
- "items": {
- "$ref": "#/components/schemas/McpInventoryEntryResponse"
- },
- "title": "Entries",
- "type": "array"
- },
- "issues": {
+ "sightings": {
"items": {
- "$ref": "#/components/schemas/McpInventoryIssueResponse"
+ "$ref": "#/components/schemas/McpIdentitySightingResponse"
},
- "title": "Issues",
+ "title": "Sightings",
"type": "array"
}
},
"required": [
- "columns",
- "entries"
- ],
- "title": "McpInventoryResponse",
- "type": "object"
- },
- "McpMarketplaceCapabilityCountsResponse": {
- "properties": {
- "prompts": {
- "title": "Prompts",
- "type": "integer"
- },
- "resources": {
- "title": "Resources",
- "type": "integer"
- },
- "tools": {
- "title": "Tools",
- "type": "integer"
- }
- },
- "required": [
- "tools",
- "resources",
- "prompts"
+ "name",
+ "identical",
+ "sightings"
],
- "title": "McpMarketplaceCapabilityCountsResponse",
+ "title": "McpIdentityGroupResponse",
"type": "object"
},
- "McpMarketplaceConnectionResponse": {
+ "McpIdentitySightingResponse": {
"properties": {
- "bundleUrl": {
+ "configPath": {
"anyOf": [
{
"type": "string"
@@ -1457,36 +1499,24 @@
"type": "null"
}
],
- "title": "Bundleurl"
+ "title": "Configpath"
},
- "configSchema": {
- "anyOf": [
- {
- "additionalProperties": true,
- "type": "object"
- },
- {
- "type": "null"
- }
- ],
- "title": "Configschema"
+ "env": {
+ "items": {
+ "$ref": "#/components/schemas/McpEnvEntryResponse"
+ },
+ "title": "Env",
+ "type": "array"
},
- "deploymentUrl": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Deploymenturl"
+ "harness": {
+ "title": "Harness",
+ "type": "string"
},
- "kind": {
- "title": "Kind",
+ "label": {
+ "title": "Label",
"type": "string"
},
- "runtime": {
+ "logoKey": {
"anyOf": [
{
"type": "string"
@@ -1495,64 +1525,41 @@
"type": "null"
}
],
- "title": "Runtime"
+ "title": "Logokey"
},
- "stdioArgs": {
- "anyOf": [
- {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- {
- "type": "null"
- }
- ],
- "title": "Stdioargs"
+ "payloadPreview": {
+ "additionalProperties": true,
+ "title": "Payloadpreview",
+ "type": "object"
},
- "stdioCommand": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Stdiocommand"
+ "recommended": {
+ "default": false,
+ "title": "Recommended",
+ "type": "boolean"
},
- "stdioFunction": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Stdiofunction"
+ "spec": {
+ "$ref": "#/components/schemas/McpServerSpecResponse"
}
},
"required": [
- "kind"
+ "harness",
+ "label",
+ "payloadPreview",
+ "spec"
],
- "title": "McpMarketplaceConnectionResponse",
+ "title": "McpIdentitySightingResponse",
"type": "object"
},
- "McpMarketplaceDetailResponse": {
+ "McpInstallConfigFieldResponse": {
"properties": {
- "capabilityCounts": {
- "$ref": "#/components/schemas/McpMarketplaceCapabilityCountsResponse"
- },
- "connections": {
+ "choices": {
"items": {
- "$ref": "#/components/schemas/McpMarketplaceConnectionResponse"
+ "type": "string"
},
- "title": "Connections",
+ "title": "Choices",
"type": "array"
},
- "deploymentUrl": {
+ "default": {
"anyOf": [
{
"type": "string"
@@ -1561,21 +1568,31 @@
"type": "null"
}
],
- "title": "Deploymenturl"
+ "title": "Default"
},
"description": {
"title": "Description",
"type": "string"
},
- "displayName": {
- "title": "Displayname",
+ "format": {
+ "enum": [
+ "string",
+ "number",
+ "boolean",
+ "filepath"
+ ],
+ "title": "Format",
"type": "string"
},
- "externalUrl": {
- "title": "Externalurl",
+ "label": {
+ "title": "Label",
"type": "string"
},
- "githubUrl": {
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "placeholder": {
"anyOf": [
{
"type": "string"
@@ -1584,109 +1601,105 @@
"type": "null"
}
],
- "title": "Githuburl"
+ "title": "Placeholder"
},
- "iconUrl": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Iconurl"
- },
- "installConfig": {
- "$ref": "#/components/schemas/McpInstallConfigResponse"
+ "required": {
+ "title": "Required",
+ "type": "boolean"
},
- "isRemote": {
- "title": "Isremote",
+ "secret": {
+ "title": "Secret",
"type": "boolean"
},
- "managedName": {
- "title": "Managedname",
+ "target": {
+ "enum": [
+ "env",
+ "header",
+ "urlVariable",
+ "packageArgument",
+ "runtimeArgument"
+ ],
+ "title": "Target",
"type": "string"
- },
- "prompts": {
+ }
+ },
+ "required": [
+ "name",
+ "label",
+ "description",
+ "format",
+ "required",
+ "secret",
+ "target"
+ ],
+ "title": "McpInstallConfigFieldResponse",
+ "type": "object"
+ },
+ "McpInstallConfigResponse": {
+ "properties": {
+ "fields": {
"items": {
- "$ref": "#/components/schemas/McpMarketplacePromptResponse"
+ "$ref": "#/components/schemas/McpInstallConfigFieldResponse"
},
- "title": "Prompts",
+ "title": "Fields",
"type": "array"
},
- "qualifiedName": {
- "title": "Qualifiedname",
- "type": "string"
+ "required": {
+ "title": "Required",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "required"
+ ],
+ "title": "McpInstallConfigResponse",
+ "type": "object"
+ },
+ "McpInstallConfigStatusResponse": {
+ "properties": {
+ "configured": {
+ "title": "Configured",
+ "type": "boolean"
},
- "resources": {
- "items": {
- "$ref": "#/components/schemas/McpMarketplaceResourceResponse"
- },
- "title": "Resources",
- "type": "array"
+ "hasFields": {
+ "title": "Hasfields",
+ "type": "boolean"
},
- "tools": {
+ "missingRequired": {
"items": {
- "$ref": "#/components/schemas/McpMarketplaceToolResponse"
+ "type": "string"
},
- "title": "Tools",
+ "title": "Missingrequired",
"type": "array"
- },
- "websiteUrl": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Websiteurl"
}
},
"required": [
- "qualifiedName",
- "managedName",
- "displayName",
- "description",
- "isRemote",
- "connections",
- "tools",
- "resources",
- "prompts",
- "capabilityCounts",
- "externalUrl"
+ "hasFields",
+ "missingRequired",
+ "configured"
],
- "title": "McpMarketplaceDetailResponse",
+ "title": "McpInstallConfigStatusResponse",
"type": "object"
},
- "McpMarketplaceItemResponse": {
+ "McpInventoryColumnResponse": {
"properties": {
- "createdAt": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Createdat"
+ "configPresent": {
+ "title": "Configpresent",
+ "type": "boolean"
},
- "description": {
- "title": "Description",
+ "harness": {
+ "title": "Harness",
"type": "string"
},
- "displayName": {
- "title": "Displayname",
- "type": "string"
+ "installed": {
+ "title": "Installed",
+ "type": "boolean"
},
- "externalUrl": {
- "title": "Externalurl",
+ "label": {
+ "title": "Label",
"type": "string"
},
- "githubUrl": {
+ "logoKey": {
"anyOf": [
{
"type": "string"
@@ -1695,9 +1708,9 @@
"type": "null"
}
],
- "title": "Githuburl"
+ "title": "Logokey"
},
- "homepage": {
+ "mcpUnavailableReason": {
"anyOf": [
{
"type": "string"
@@ -1706,9 +1719,26 @@
"type": "null"
}
],
- "title": "Homepage"
+ "title": "Mcpunavailablereason"
},
- "iconUrl": {
+ "mcpWritable": {
+ "default": true,
+ "title": "Mcpwritable",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "harness",
+ "label",
+ "installed",
+ "configPresent"
+ ],
+ "title": "McpInventoryColumnResponse",
+ "type": "object"
+ },
+ "McpInventoryEntryResponse": {
+ "properties": {
+ "availabilityReason": {
"anyOf": [
{
"type": "string"
@@ -1717,338 +1747,286 @@
"type": "null"
}
],
- "title": "Iconurl"
+ "description": "Deprecated compatibility field; use mcpStatus.reason instead.",
+ "title": "Availabilityreason"
},
- "isDeployed": {
- "title": "Isdeployed",
- "type": "boolean"
+ "availabilityStatus": {
+ "description": "Deprecated compatibility field; use mcpStatus instead.",
+ "enum": [
+ "available",
+ "unavailable"
+ ],
+ "title": "Availabilitystatus",
+ "type": "string"
},
- "isRemote": {
- "title": "Isremote",
+ "canEnable": {
+ "title": "Canenable",
"type": "boolean"
},
- "isVerified": {
- "title": "Isverified",
- "type": "boolean"
+ "displayName": {
+ "title": "Displayname",
+ "type": "string"
},
- "namespace": {
- "title": "Namespace",
+ "enabledStatus": {
+ "enum": [
+ "enabled",
+ "disabled"
+ ],
+ "title": "Enabledstatus",
"type": "string"
},
- "qualifiedName": {
- "title": "Qualifiedname",
+ "installConfigStatus": {
+ "$ref": "#/components/schemas/McpInstallConfigStatusResponse"
+ },
+ "kind": {
+ "enum": [
+ "managed",
+ "unmanaged"
+ ],
+ "title": "Kind",
"type": "string"
},
- "useCount": {
- "title": "Usecount",
- "type": "integer"
+ "mcpStatus": {
+ "$ref": "#/components/schemas/McpStatusResponse"
},
- "websiteUrl": {
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "sightings": {
+ "items": {
+ "$ref": "#/components/schemas/McpBindingResponse"
+ },
+ "title": "Sightings",
+ "type": "array"
+ },
+ "spec": {
"anyOf": [
{
- "type": "string"
+ "$ref": "#/components/schemas/McpServerSpecResponse"
},
{
"type": "null"
}
- ],
- "title": "Websiteurl"
+ ]
}
},
"required": [
- "qualifiedName",
- "namespace",
+ "name",
"displayName",
- "description",
- "isVerified",
- "isRemote",
- "isDeployed",
- "useCount",
- "externalUrl"
+ "kind",
+ "canEnable",
+ "enabledStatus",
+ "availabilityStatus",
+ "mcpStatus",
+ "installConfigStatus",
+ "sightings"
],
- "title": "McpMarketplaceItemResponse",
+ "title": "McpInventoryEntryResponse",
"type": "object"
},
- "McpMarketplaceLinkResponse": {
+ "McpInventoryIssueResponse": {
"properties": {
- "description": {
- "title": "Description",
+ "name": {
+ "title": "Name",
"type": "string"
},
- "displayName": {
- "title": "Displayname",
- "type": "string"
- },
- "externalUrl": {
- "title": "Externalurl",
- "type": "string"
- },
- "githubUrl": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Githuburl"
- },
- "iconUrl": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Iconurl"
- },
- "isRemote": {
- "title": "Isremote",
- "type": "boolean"
- },
- "isVerified": {
- "title": "Isverified",
- "type": "boolean"
- },
- "qualifiedName": {
- "title": "Qualifiedname",
+ "reason": {
+ "title": "Reason",
"type": "string"
- },
- "websiteUrl": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Websiteurl"
}
},
"required": [
- "qualifiedName",
- "displayName",
- "externalUrl",
- "description",
- "isRemote",
- "isVerified"
+ "name",
+ "reason"
],
- "title": "McpMarketplaceLinkResponse",
+ "title": "McpInventoryIssueResponse",
"type": "object"
},
- "McpMarketplacePageResponse": {
+ "McpInventoryResponse": {
"properties": {
- "hasMore": {
- "title": "Hasmore",
- "type": "boolean"
+ "columns": {
+ "items": {
+ "$ref": "#/components/schemas/McpInventoryColumnResponse"
+ },
+ "title": "Columns",
+ "type": "array"
},
- "items": {
+ "entries": {
"items": {
- "$ref": "#/components/schemas/McpMarketplaceItemResponse"
+ "$ref": "#/components/schemas/McpInventoryEntryResponse"
},
- "title": "Items",
+ "title": "Entries",
"type": "array"
},
- "nextOffset": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Nextoffset"
+ "issues": {
+ "items": {
+ "$ref": "#/components/schemas/McpInventoryIssueResponse"
+ },
+ "title": "Issues",
+ "type": "array"
}
},
"required": [
- "items",
- "hasMore"
+ "columns",
+ "entries"
],
- "title": "McpMarketplacePageResponse",
+ "title": "McpInventoryResponse",
"type": "object"
},
- "McpMarketplaceParameterResponse": {
+ "McpMarketplaceCapabilityCountsResponse": {
"properties": {
- "default": {
- "anyOf": [
- {},
- {
- "type": "null"
- }
- ],
- "title": "Default"
+ "prompts": {
+ "title": "Prompts",
+ "type": "integer"
},
- "description": {
- "title": "Description",
- "type": "string"
+ "resources": {
+ "title": "Resources",
+ "type": "integer"
},
- "enum": {
+ "tools": {
+ "title": "Tools",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "tools",
+ "resources",
+ "prompts"
+ ],
+ "title": "McpMarketplaceCapabilityCountsResponse",
+ "type": "object"
+ },
+ "McpMarketplaceConnectionResponse": {
+ "properties": {
+ "bundleUrl": {
"anyOf": [
{
- "items": {},
- "type": "array"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Enum"
+ "title": "Bundleurl"
},
- "maxItems": {
+ "configSchema": {
"anyOf": [
{
- "type": "integer"
+ "additionalProperties": true,
+ "type": "object"
},
{
"type": "null"
}
],
- "title": "Maxitems"
+ "title": "Configschema"
},
- "maxLength": {
+ "deploymentUrl": {
"anyOf": [
{
- "type": "integer"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Maxlength"
+ "title": "Deploymenturl"
},
- "maximum": {
+ "kind": {
+ "title": "Kind",
+ "type": "string"
+ },
+ "runtime": {
"anyOf": [
{
- "type": "number"
- },
- {
- "type": "integer"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Maximum"
+ "title": "Runtime"
},
- "minItems": {
+ "stdioArgs": {
"anyOf": [
{
- "type": "integer"
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
},
{
"type": "null"
}
],
- "title": "Minitems"
+ "title": "Stdioargs"
},
- "minLength": {
+ "stdioCommand": {
"anyOf": [
{
- "type": "integer"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Minlength"
+ "title": "Stdiocommand"
},
- "minimum": {
+ "stdioFunction": {
"anyOf": [
{
- "type": "number"
- },
- {
- "type": "integer"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Minimum"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "required": {
- "title": "Required",
- "type": "boolean"
- },
- "type": {
- "title": "Type",
- "type": "string"
+ "title": "Stdiofunction"
}
},
"required": [
- "name",
- "type",
- "description",
- "required"
+ "kind"
],
- "title": "McpMarketplaceParameterResponse",
+ "title": "McpMarketplaceConnectionResponse",
"type": "object"
},
- "McpMarketplacePromptArgumentResponse": {
+ "McpMarketplaceDetailResponse": {
"properties": {
- "description": {
- "title": "Description",
- "type": "string"
- },
- "name": {
- "title": "Name",
- "type": "string"
+ "capabilityCounts": {
+ "$ref": "#/components/schemas/McpMarketplaceCapabilityCountsResponse"
},
- "required": {
- "title": "Required",
- "type": "boolean"
- }
- },
- "required": [
- "name",
- "description",
- "required"
- ],
- "title": "McpMarketplacePromptArgumentResponse",
- "type": "object"
- },
- "McpMarketplacePromptResponse": {
- "properties": {
- "arguments": {
+ "connections": {
"items": {
- "$ref": "#/components/schemas/McpMarketplacePromptArgumentResponse"
+ "$ref": "#/components/schemas/McpMarketplaceConnectionResponse"
},
- "title": "Arguments",
+ "title": "Connections",
"type": "array"
},
+ "deploymentUrl": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Deploymenturl"
+ },
"description": {
"title": "Description",
"type": "string"
},
- "name": {
- "title": "Name",
+ "displayName": {
+ "title": "Displayname",
"type": "string"
- }
- },
- "required": [
- "name",
- "description",
- "arguments"
- ],
- "title": "McpMarketplacePromptResponse",
- "type": "object"
- },
- "McpMarketplaceResourceResponse": {
- "properties": {
- "description": {
- "title": "Description",
+ },
+ "externalUrl": {
+ "title": "Externalurl",
"type": "string"
},
- "mimeType": {
+ "githubUrl": {
"anyOf": [
{
"type": "string"
@@ -2057,72 +2035,9 @@
"type": "null"
}
],
- "title": "Mimetype"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "uri": {
- "title": "Uri",
- "type": "string"
- }
- },
- "required": [
- "name",
- "uri",
- "description"
- ],
- "title": "McpMarketplaceResourceResponse",
- "type": "object"
- },
- "McpMarketplaceToolResponse": {
- "properties": {
- "description": {
- "title": "Description",
- "type": "string"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "parameters": {
- "items": {
- "$ref": "#/components/schemas/McpMarketplaceParameterResponse"
- },
- "title": "Parameters",
- "type": "array"
- }
- },
- "required": [
- "name",
- "description",
- "parameters"
- ],
- "title": "McpMarketplaceToolResponse",
- "type": "object"
- },
- "McpMutationFailureResponse": {
- "properties": {
- "error": {
- "title": "Error",
- "type": "string"
+ "title": "Githuburl"
},
- "harness": {
- "title": "Harness",
- "type": "string"
- }
- },
- "required": [
- "harness",
- "error"
- ],
- "title": "McpMutationFailureResponse",
- "type": "object"
- },
- "McpServerDetailResponse": {
- "properties": {
- "availabilityReason": {
+ "iconUrl": {
"anyOf": [
{
"type": "string"
@@ -2131,142 +2046,75 @@
"type": "null"
}
],
- "description": "Deprecated compatibility field; use mcpStatus.reason instead.",
- "title": "Availabilityreason"
+ "title": "Iconurl"
},
- "availabilityStatus": {
- "description": "Deprecated compatibility field; use mcpStatus instead.",
- "enum": [
- "available",
- "unavailable"
- ],
- "title": "Availabilitystatus",
- "type": "string"
+ "installConfig": {
+ "$ref": "#/components/schemas/McpInstallConfigResponse"
},
- "canEnable": {
- "title": "Canenable",
+ "isRemote": {
+ "title": "Isremote",
"type": "boolean"
},
- "configChoices": {
+ "managedName": {
+ "title": "Managedname",
+ "type": "string"
+ },
+ "prompts": {
"items": {
- "$ref": "#/components/schemas/McpConfigChoiceResponse"
+ "$ref": "#/components/schemas/McpMarketplacePromptResponse"
},
- "title": "Configchoices",
+ "title": "Prompts",
"type": "array"
},
- "displayName": {
- "title": "Displayname",
- "type": "string"
- },
- "enabledStatus": {
- "enum": [
- "enabled",
- "disabled"
- ],
- "title": "Enabledstatus",
+ "qualifiedName": {
+ "title": "Qualifiedname",
"type": "string"
},
- "env": {
+ "resources": {
"items": {
- "$ref": "#/components/schemas/McpEnvEntryResponse"
+ "$ref": "#/components/schemas/McpMarketplaceResourceResponse"
},
- "title": "Env",
+ "title": "Resources",
"type": "array"
},
- "installConfigStatus": {
- "$ref": "#/components/schemas/McpInstallConfigStatusResponse"
- },
- "kind": {
- "enum": [
- "managed",
- "unmanaged"
- ],
- "title": "Kind",
- "type": "string"
- },
- "marketplaceLink": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/McpMarketplaceLinkResponse"
- },
- {
- "type": "null"
- }
- ]
- },
- "mcpStatus": {
- "$ref": "#/components/schemas/McpStatusResponse"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "sightings": {
+ "tools": {
"items": {
- "$ref": "#/components/schemas/McpBindingResponse"
+ "$ref": "#/components/schemas/McpMarketplaceToolResponse"
},
- "title": "Sightings",
+ "title": "Tools",
"type": "array"
},
- "spec": {
+ "websiteUrl": {
"anyOf": [
{
- "$ref": "#/components/schemas/McpServerSpecResponse"
+ "type": "string"
},
{
"type": "null"
}
- ]
+ ],
+ "title": "Websiteurl"
}
},
"required": [
- "name",
+ "qualifiedName",
+ "managedName",
"displayName",
- "kind",
- "canEnable",
- "enabledStatus",
- "availabilityStatus",
- "mcpStatus",
- "installConfigStatus",
- "sightings"
- ],
- "title": "McpServerDetailResponse",
- "type": "object"
- },
- "McpServerMutationResponse": {
- "properties": {
- "ok": {
- "title": "Ok",
- "type": "boolean"
- },
- "server": {
- "$ref": "#/components/schemas/McpServerSpecResponse"
- }
- },
- "required": [
- "ok",
- "server"
+ "description",
+ "isRemote",
+ "connections",
+ "tools",
+ "resources",
+ "prompts",
+ "capabilityCounts",
+ "externalUrl"
],
- "title": "McpServerMutationResponse",
+ "title": "McpMarketplaceDetailResponse",
"type": "object"
},
- "McpServerSpecResponse": {
+ "McpMarketplaceItemResponse": {
"properties": {
- "args": {
- "anyOf": [
- {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- {
- "type": "null"
- }
- ],
- "title": "Args"
- },
- "command": {
+ "createdAt": {
"anyOf": [
{
"type": "string"
@@ -2275,65 +2123,78 @@
"type": "null"
}
],
- "title": "Command"
+ "title": "Createdat"
+ },
+ "description": {
+ "title": "Description",
+ "type": "string"
},
"displayName": {
"title": "Displayname",
"type": "string"
},
- "env": {
+ "externalUrl": {
+ "title": "Externalurl",
+ "type": "string"
+ },
+ "githubUrl": {
"anyOf": [
{
- "additionalProperties": {
- "type": "string"
- },
- "type": "object"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Env"
+ "title": "Githuburl"
},
- "headers": {
+ "homepage": {
"anyOf": [
{
- "additionalProperties": {
- "type": "string"
- },
- "type": "object"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Headers"
+ "title": "Homepage"
},
- "installedAt": {
- "title": "Installedat",
- "type": "string"
+ "iconUrl": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Iconurl"
},
- "name": {
- "title": "Name",
- "type": "string"
+ "isDeployed": {
+ "title": "Isdeployed",
+ "type": "boolean"
},
- "revision": {
- "title": "Revision",
- "type": "string"
+ "isRemote": {
+ "title": "Isremote",
+ "type": "boolean"
},
- "source": {
- "$ref": "#/components/schemas/McpSourceResponse"
+ "isVerified": {
+ "title": "Isverified",
+ "type": "boolean"
},
- "transport": {
- "enum": [
- "stdio",
- "http",
- "sse"
- ],
- "title": "Transport",
+ "namespace": {
+ "title": "Namespace",
"type": "string"
},
- "url": {
+ "qualifiedName": {
+ "title": "Qualifiedname",
+ "type": "string"
+ },
+ "useCount": {
+ "title": "Usecount",
+ "type": "integer"
+ },
+ "websiteUrl": {
"anyOf": [
{
"type": "string"
@@ -2342,85 +2203,72 @@
"type": "null"
}
],
- "title": "Url"
+ "title": "Websiteurl"
}
},
"required": [
- "name",
+ "qualifiedName",
+ "namespace",
"displayName",
- "source",
- "transport",
- "installedAt",
- "revision"
+ "description",
+ "isVerified",
+ "isRemote",
+ "isDeployed",
+ "useCount",
+ "externalUrl"
],
- "title": "McpServerSpecResponse",
+ "title": "McpMarketplaceItemResponse",
"type": "object"
},
- "McpSetHarnessesResultResponse": {
+ "McpMarketplaceLinkResponse": {
"properties": {
- "failed": {
- "items": {
- "$ref": "#/components/schemas/McpMutationFailureResponse"
- },
- "title": "Failed",
- "type": "array"
- },
- "ok": {
- "title": "Ok",
- "type": "boolean"
+ "description": {
+ "title": "Description",
+ "type": "string"
},
- "succeeded": {
- "items": {
- "type": "string"
- },
- "title": "Succeeded",
- "type": "array"
- }
- },
- "required": [
- "ok",
- "succeeded",
- "failed"
- ],
- "title": "McpSetHarnessesResultResponse",
- "type": "object"
- },
- "McpSourceResponse": {
- "properties": {
- "kind": {
- "enum": [
- "marketplace",
- "adopted",
- "manual"
- ],
- "title": "Kind",
+ "displayName": {
+ "title": "Displayname",
"type": "string"
},
- "locator": {
- "title": "Locator",
+ "externalUrl": {
+ "title": "Externalurl",
"type": "string"
- }
- },
- "required": [
- "kind",
- "locator"
- ],
- "title": "McpSourceResponse",
- "type": "object"
- },
- "McpStatusResponse": {
- "properties": {
- "kind": {
- "enum": [
- "available",
- "needs_config",
- "connection_issue",
- "unchecked"
+ },
+ "githubUrl": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
],
- "title": "Kind",
+ "title": "Githuburl"
+ },
+ "iconUrl": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Iconurl"
+ },
+ "isRemote": {
+ "title": "Isremote",
+ "type": "boolean"
+ },
+ "isVerified": {
+ "title": "Isverified",
+ "type": "boolean"
+ },
+ "qualifiedName": {
+ "title": "Qualifiedname",
"type": "string"
},
- "reason": {
+ "websiteUrl": {
"anyOf": [
{
"type": "string"
@@ -2429,216 +2277,229 @@
"type": "null"
}
],
- "title": "Reason"
+ "title": "Websiteurl"
}
},
"required": [
- "kind"
+ "qualifiedName",
+ "displayName",
+ "externalUrl",
+ "description",
+ "isRemote",
+ "isVerified"
],
- "title": "McpStatusResponse",
+ "title": "McpMarketplaceLinkResponse",
"type": "object"
},
- "McpUnmanagedByServerResponse": {
+ "McpMarketplacePageResponse": {
"properties": {
- "harnesses": {
- "items": {
- "$ref": "#/components/schemas/McpUnmanagedHarnessResponse"
- },
- "title": "Harnesses",
- "type": "array"
+ "hasMore": {
+ "title": "Hasmore",
+ "type": "boolean"
},
- "issues": {
+ "items": {
"items": {
- "$ref": "#/components/schemas/McpAdoptionIssueResponse"
+ "$ref": "#/components/schemas/McpMarketplaceItemResponse"
},
- "title": "Issues",
+ "title": "Items",
"type": "array"
},
- "servers": {
- "items": {
- "$ref": "#/components/schemas/McpIdentityGroupResponse"
- },
- "title": "Servers",
- "type": "array"
+ "nextOffset": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Nextoffset"
}
},
"required": [
- "harnesses",
- "servers"
+ "items",
+ "hasMore"
],
- "title": "McpUnmanagedByServerResponse",
+ "title": "McpMarketplacePageResponse",
"type": "object"
},
- "McpUnmanagedHarnessResponse": {
+ "McpMarketplaceParameterResponse": {
"properties": {
- "configPath": {
+ "default": {
"anyOf": [
- {
- "type": "string"
- },
+ {},
{
"type": "null"
}
],
- "title": "Configpath"
- },
- "configPresent": {
- "title": "Configpresent",
- "type": "boolean"
- },
- "harness": {
- "title": "Harness",
- "type": "string"
- },
- "installed": {
- "title": "Installed",
- "type": "boolean"
+ "title": "Default"
},
- "label": {
- "title": "Label",
+ "description": {
+ "title": "Description",
"type": "string"
},
- "logoKey": {
+ "enum": {
"anyOf": [
{
- "type": "string"
+ "items": {},
+ "type": "array"
},
{
"type": "null"
}
],
- "title": "Logokey"
+ "title": "Enum"
},
- "mcpUnavailableReason": {
+ "maxItems": {
"anyOf": [
{
- "type": "string"
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Mcpunavailablereason"
+ "title": "Maxitems"
},
- "mcpWritable": {
- "default": true,
- "title": "Mcpwritable",
- "type": "boolean"
- }
- },
- "required": [
- "harness",
- "label",
- "installed",
- "configPresent"
- ],
- "title": "McpUnmanagedHarnessResponse",
- "type": "object"
- },
- "OkResponse": {
- "properties": {
- "ok": {
- "title": "Ok",
- "type": "boolean"
- }
- },
- "required": [
- "ok"
- ],
- "title": "OkResponse",
- "type": "object"
- },
- "ReconcileMcpServerRequest": {
- "additionalProperties": false,
- "properties": {
- "harnesses": {
+ "maxLength": {
"anyOf": [
{
- "items": {
- "type": "string"
- },
- "type": "array"
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Harnesses"
+ "title": "Maxlength"
},
- "observedHarness": {
+ "maximum": {
"anyOf": [
{
- "type": "string"
+ "type": "number"
+ },
+ {
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Observed harness"
+ "title": "Maximum"
},
- "sourceKind": {
- "enum": [
- "managed",
- "harness"
+ "minItems": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
],
- "title": "Sourcekind",
+ "title": "Minitems"
+ },
+ "minLength": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Minlength"
+ },
+ "minimum": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Minimum"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "required": {
+ "title": "Required",
+ "type": "boolean"
+ },
+ "type": {
+ "title": "Type",
"type": "string"
}
},
"required": [
- "sourceKind"
+ "name",
+ "type",
+ "description",
+ "required"
],
- "title": "ReconcileMcpServerRequest",
+ "title": "McpMarketplaceParameterResponse",
"type": "object"
},
- "ScanAvailabilityResponse": {
+ "McpMarketplacePromptArgumentResponse": {
"properties": {
- "available": {
- "title": "Available",
+ "description": {
+ "title": "Description",
+ "type": "string"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "required": {
+ "title": "Required",
"type": "boolean"
}
},
"required": [
- "available"
+ "name",
+ "description",
+ "required"
],
- "title": "ScanAvailabilityResponse",
+ "title": "McpMarketplacePromptArgumentResponse",
"type": "object"
},
- "ScanConfigItem": {
+ "McpMarketplacePromptResponse": {
"properties": {
- "apiKeyMasked": {
- "title": "Apikeymasked",
- "type": "string"
- },
- "apiVersion": {
- "title": "Apiversion",
- "type": "string"
+ "arguments": {
+ "items": {
+ "$ref": "#/components/schemas/McpMarketplacePromptArgumentResponse"
+ },
+ "title": "Arguments",
+ "type": "array"
},
- "awsProfile": {
- "title": "Awsprofile",
+ "description": {
+ "title": "Description",
"type": "string"
},
- "awsRegion": {
- "title": "Awsregion",
+ "name": {
+ "title": "Name",
"type": "string"
- },
- "baseUrl": {
- "title": "Baseurl",
+ }
+ },
+ "required": [
+ "name",
+ "description",
+ "arguments"
+ ],
+ "title": "McpMarketplacePromptResponse",
+ "type": "object"
+ },
+ "McpMarketplaceResourceResponse": {
+ "properties": {
+ "description": {
+ "title": "Description",
"type": "string"
},
- "consensusRuns": {
- "title": "Consensusruns",
- "type": "integer"
- },
- "id": {
- "title": "Id",
- "type": "integer"
- },
- "isActive": {
- "title": "Isactive",
- "type": "boolean"
- },
- "lastValidatedAt": {
+ "mimeType": {
"anyOf": [
{
"type": "string"
@@ -2647,239 +2508,216 @@
"type": "null"
}
],
- "title": "Lastvalidatedat"
- },
- "lastValidationError": {
- "default": "",
- "title": "Lastvalidationerror",
- "type": "string"
- },
- "maxTokens": {
- "title": "Maxtokens",
- "type": "integer"
- },
- "model": {
- "title": "Model",
- "type": "string"
+ "title": "Mimetype"
},
"name": {
"title": "Name",
"type": "string"
},
- "provider": {
- "title": "Provider",
+ "uri": {
+ "title": "Uri",
"type": "string"
}
},
"required": [
- "id",
"name",
- "baseUrl",
- "apiKeyMasked",
- "model",
- "provider",
- "apiVersion",
- "awsRegion",
- "awsProfile",
- "maxTokens",
- "consensusRuns",
- "isActive"
+ "uri",
+ "description"
],
- "title": "ScanConfigItem",
+ "title": "McpMarketplaceResourceResponse",
"type": "object"
},
- "ScanConfigListResponse": {
+ "McpMarketplaceToolResponse": {
"properties": {
- "activeId": {
- "anyOf": [
- {
- "type": "integer"
- },
- {
- "type": "null"
- }
- ],
- "title": "Activeid"
+ "description": {
+ "title": "Description",
+ "type": "string"
},
- "configs": {
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "parameters": {
"items": {
- "$ref": "#/components/schemas/ScanConfigItem"
+ "$ref": "#/components/schemas/McpMarketplaceParameterResponse"
},
- "title": "Configs",
+ "title": "Parameters",
"type": "array"
}
},
"required": [
- "configs",
- "activeId"
+ "name",
+ "description",
+ "parameters"
],
- "title": "ScanConfigListResponse",
+ "title": "McpMarketplaceToolResponse",
"type": "object"
},
- "ScanConfigSaveRequest": {
+ "McpMutationFailureResponse": {
"properties": {
- "apiKey": {
- "title": "Apikey",
+ "error": {
+ "title": "Error",
"type": "string"
},
- "apiVersion": {
- "default": "",
- "title": "Apiversion",
- "type": "string"
- },
- "awsProfile": {
- "default": "",
- "title": "Awsprofile",
- "type": "string"
- },
- "awsRegion": {
- "default": "",
- "title": "Awsregion",
- "type": "string"
- },
- "awsSessionToken": {
- "default": "",
- "title": "Awssessiontoken",
- "type": "string"
- },
- "baseUrl": {
- "title": "Baseurl",
- "type": "string"
- },
- "consensusRuns": {
- "default": 1,
- "title": "Consensusruns",
- "type": "integer"
- },
- "maxTokens": {
- "default": 8192,
- "title": "Maxtokens",
- "type": "integer"
- },
- "model": {
- "title": "Model",
- "type": "string"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "provider": {
- "default": "",
- "title": "Provider",
- "type": "string"
- }
- },
- "required": [
- "name",
- "baseUrl",
- "apiKey",
- "model"
- ],
- "title": "ScanConfigSaveRequest",
- "type": "object"
- },
- "ScanConfigSecretResponse": {
- "properties": {
- "apiKey": {
- "title": "Apikey",
+ "harness": {
+ "title": "Harness",
"type": "string"
}
},
"required": [
- "apiKey"
+ "harness",
+ "error"
],
- "title": "ScanConfigSecretResponse",
+ "title": "McpMutationFailureResponse",
"type": "object"
},
- "ScanConfigValidateRequest": {
+ "McpServerDetailResponse": {
"properties": {
- "apiKey": {
- "title": "Apikey",
- "type": "string"
+ "availabilityReason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Deprecated compatibility field; use mcpStatus.reason instead.",
+ "title": "Availabilityreason"
},
- "apiVersion": {
- "default": "",
- "title": "Apiversion",
+ "availabilityStatus": {
+ "description": "Deprecated compatibility field; use mcpStatus instead.",
+ "enum": [
+ "available",
+ "unavailable"
+ ],
+ "title": "Availabilitystatus",
"type": "string"
},
- "awsProfile": {
- "default": "",
- "title": "Awsprofile",
- "type": "string"
+ "canEnable": {
+ "title": "Canenable",
+ "type": "boolean"
},
- "awsRegion": {
- "default": "",
- "title": "Awsregion",
- "type": "string"
+ "configChoices": {
+ "items": {
+ "$ref": "#/components/schemas/McpConfigChoiceResponse"
+ },
+ "title": "Configchoices",
+ "type": "array"
},
- "awsSessionToken": {
- "default": "",
- "title": "Awssessiontoken",
+ "displayName": {
+ "title": "Displayname",
"type": "string"
},
- "baseUrl": {
- "title": "Baseurl",
+ "enabledStatus": {
+ "enum": [
+ "enabled",
+ "disabled"
+ ],
+ "title": "Enabledstatus",
"type": "string"
},
- "consensusRuns": {
- "default": 1,
- "title": "Consensusruns",
- "type": "integer"
+ "env": {
+ "items": {
+ "$ref": "#/components/schemas/McpEnvEntryResponse"
+ },
+ "title": "Env",
+ "type": "array"
},
- "existingConfigId": {
+ "installConfigStatus": {
+ "$ref": "#/components/schemas/McpInstallConfigStatusResponse"
+ },
+ "kind": {
+ "enum": [
+ "managed",
+ "unmanaged"
+ ],
+ "title": "Kind",
+ "type": "string"
+ },
+ "marketplaceLink": {
"anyOf": [
{
- "type": "integer"
+ "$ref": "#/components/schemas/McpMarketplaceLinkResponse"
},
{
"type": "null"
}
- ],
- "title": "Existingconfigid"
- },
- "maxTokens": {
- "default": 8192,
- "title": "Maxtokens",
- "type": "integer"
+ ]
},
- "model": {
- "title": "Model",
- "type": "string"
+ "mcpStatus": {
+ "$ref": "#/components/schemas/McpStatusResponse"
},
"name": {
"title": "Name",
"type": "string"
},
- "provider": {
- "default": "",
- "title": "Provider",
- "type": "string"
+ "sightings": {
+ "items": {
+ "$ref": "#/components/schemas/McpBindingResponse"
+ },
+ "title": "Sightings",
+ "type": "array"
+ },
+ "spec": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/McpServerSpecResponse"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
"name",
- "baseUrl",
- "apiKey",
- "model"
+ "displayName",
+ "kind",
+ "canEnable",
+ "enabledStatus",
+ "availabilityStatus",
+ "mcpStatus",
+ "installConfigStatus",
+ "sightings"
],
- "title": "ScanConfigValidateRequest",
+ "title": "McpServerDetailResponse",
"type": "object"
},
- "ScanConfigValidationResponse": {
+ "McpServerMutationResponse": {
"properties": {
- "durationMs": {
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
+ },
+ "server": {
+ "$ref": "#/components/schemas/McpServerSpecResponse"
+ }
+ },
+ "required": [
+ "ok",
+ "server"
+ ],
+ "title": "McpServerMutationResponse",
+ "type": "object"
+ },
+ "McpServerSpecResponse": {
+ "properties": {
+ "args": {
"anyOf": [
{
- "type": "integer"
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
},
{
"type": "null"
}
],
- "title": "Durationms"
+ "title": "Args"
},
- "errorCode": {
+ "command": {
"anyOf": [
{
"type": "string"
@@ -2888,49 +2726,65 @@
"type": "null"
}
],
- "title": "Errorcode"
+ "title": "Command"
},
- "message": {
- "title": "Message",
+ "displayName": {
+ "title": "Displayname",
"type": "string"
},
- "model": {
+ "env": {
"anyOf": [
{
- "type": "string"
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
},
{
"type": "null"
}
],
- "title": "Model"
- },
- "ok": {
- "title": "Ok",
- "type": "boolean"
+ "title": "Env"
},
- "provider": {
+ "headers": {
"anyOf": [
{
- "type": "string"
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
},
{
"type": "null"
}
],
- "title": "Provider"
- }
- },
- "required": [
- "ok",
- "message"
- ],
- "title": "ScanConfigValidationResponse",
- "type": "object"
- },
- "ScanFindingResponse": {
- "properties": {
- "analyzer": {
+ "title": "Headers"
+ },
+ "installedAt": {
+ "title": "Installedat",
+ "type": "string"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "revision": {
+ "title": "Revision",
+ "type": "string"
+ },
+ "source": {
+ "$ref": "#/components/schemas/McpSourceResponse"
+ },
+ "transport": {
+ "enum": [
+ "stdio",
+ "http",
+ "sse"
+ ],
+ "title": "Transport",
+ "type": "string"
+ },
+ "url": {
"anyOf": [
{
"type": "string"
@@ -2939,17 +2793,85 @@
"type": "null"
}
],
- "title": "Analyzer"
+ "title": "Url"
+ }
+ },
+ "required": [
+ "name",
+ "displayName",
+ "source",
+ "transport",
+ "installedAt",
+ "revision"
+ ],
+ "title": "McpServerSpecResponse",
+ "type": "object"
+ },
+ "McpSetHarnessesResultResponse": {
+ "properties": {
+ "failed": {
+ "items": {
+ "$ref": "#/components/schemas/McpMutationFailureResponse"
+ },
+ "title": "Failed",
+ "type": "array"
},
- "category": {
- "title": "Category",
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
+ },
+ "succeeded": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Succeeded",
+ "type": "array"
+ }
+ },
+ "required": [
+ "ok",
+ "succeeded",
+ "failed"
+ ],
+ "title": "McpSetHarnessesResultResponse",
+ "type": "object"
+ },
+ "McpSourceResponse": {
+ "properties": {
+ "kind": {
+ "enum": [
+ "marketplace",
+ "adopted",
+ "manual"
+ ],
+ "title": "Kind",
"type": "string"
},
- "description": {
- "title": "Description",
+ "locator": {
+ "title": "Locator",
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "locator"
+ ],
+ "title": "McpSourceResponse",
+ "type": "object"
+ },
+ "McpStatusResponse": {
+ "properties": {
+ "kind": {
+ "enum": [
+ "available",
+ "needs_config",
+ "connection_issue",
+ "unchecked"
+ ],
+ "title": "Kind",
"type": "string"
},
- "filePath": {
+ "reason": {
"anyOf": [
{
"type": "string"
@@ -2958,30 +2880,76 @@
"type": "null"
}
],
- "title": "Filepath"
+ "title": "Reason"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "title": "McpStatusResponse",
+ "type": "object"
+ },
+ "McpUnmanagedByServerResponse": {
+ "properties": {
+ "harnesses": {
+ "items": {
+ "$ref": "#/components/schemas/McpUnmanagedHarnessResponse"
+ },
+ "title": "Harnesses",
+ "type": "array"
},
- "id": {
- "title": "Id",
- "type": "string"
+ "issues": {
+ "items": {
+ "$ref": "#/components/schemas/McpAdoptionIssueResponse"
+ },
+ "title": "Issues",
+ "type": "array"
},
- "lineNumber": {
+ "servers": {
+ "items": {
+ "$ref": "#/components/schemas/McpIdentityGroupResponse"
+ },
+ "title": "Servers",
+ "type": "array"
+ }
+ },
+ "required": [
+ "harnesses",
+ "servers"
+ ],
+ "title": "McpUnmanagedByServerResponse",
+ "type": "object"
+ },
+ "McpUnmanagedHarnessResponse": {
+ "properties": {
+ "configPath": {
"anyOf": [
{
- "type": "integer"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Linenumber"
+ "title": "Configpath"
},
- "metadata": {
- "additionalProperties": true,
- "default": {},
- "title": "Metadata",
- "type": "object"
+ "configPresent": {
+ "title": "Configpresent",
+ "type": "boolean"
},
- "remediation": {
+ "harness": {
+ "title": "Harness",
+ "type": "string"
+ },
+ "installed": {
+ "title": "Installed",
+ "type": "boolean"
+ },
+ "label": {
+ "title": "Label",
+ "type": "string"
+ },
+ "logoKey": {
"anyOf": [
{
"type": "string"
@@ -2990,17 +2958,9 @@
"type": "null"
}
],
- "title": "Remediation"
- },
- "ruleId": {
- "title": "Ruleid",
- "type": "string"
- },
- "severity": {
- "title": "Severity",
- "type": "string"
+ "title": "Logokey"
},
- "snippet": {
+ "mcpUnavailableReason": {
"anyOf": [
{
"type": "string"
@@ -3009,38 +2969,54 @@
"type": "null"
}
],
- "title": "Snippet"
+ "title": "Mcpunavailablereason"
},
- "title": {
- "title": "Title",
- "type": "string"
+ "mcpWritable": {
+ "default": true,
+ "title": "Mcpwritable",
+ "type": "boolean"
}
},
"required": [
- "id",
- "ruleId",
- "category",
- "severity",
- "title",
- "description"
+ "harness",
+ "label",
+ "installed",
+ "configPresent"
],
- "title": "ScanFindingResponse",
+ "title": "McpUnmanagedHarnessResponse",
"type": "object"
},
- "ScanOptionsRequest": {
+ "OkResponse": {
"properties": {
- "awsProfile": {
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "ok"
+ ],
+ "title": "OkResponse",
+ "type": "object"
+ },
+ "ReconcileHookRequest": {
+ "additionalProperties": false,
+ "properties": {
+ "harnesses": {
"anyOf": [
{
- "type": "string"
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
},
{
"type": "null"
}
],
- "title": "Awsprofile"
+ "title": "Harnesses"
},
- "awsRegion": {
+ "observedHarness": {
"anyOf": [
{
"type": "string"
@@ -3049,31 +3025,41 @@
"type": "null"
}
],
- "title": "Awsregion"
+ "title": "Observed harness"
},
- "awsSessionToken": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
+ "sourceKind": {
+ "enum": [
+ "managed",
+ "harness"
],
- "title": "Awssessiontoken"
- },
- "llmApiKey": {
+ "title": "Sourcekind",
+ "type": "string"
+ }
+ },
+ "required": [
+ "sourceKind"
+ ],
+ "title": "ReconcileHookRequest",
+ "type": "object"
+ },
+ "ReconcileMcpServerRequest": {
+ "additionalProperties": false,
+ "properties": {
+ "harnesses": {
"anyOf": [
{
- "type": "string"
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
},
{
"type": "null"
}
],
- "title": "Llmapikey"
+ "title": "Harnesses"
},
- "llmApiVersion": {
+ "observedHarness": {
"anyOf": [
{
"type": "string"
@@ -3082,30 +3068,71 @@
"type": "null"
}
],
- "title": "Llmapiversion"
+ "title": "Observed harness"
},
- "llmBaseUrl": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
+ "sourceKind": {
+ "enum": [
+ "managed",
+ "harness"
],
- "title": "Llmbaseurl"
+ "title": "Sourcekind",
+ "type": "string"
+ }
+ },
+ "required": [
+ "sourceKind"
+ ],
+ "title": "ReconcileMcpServerRequest",
+ "type": "object"
+ },
+ "ScanAvailabilityResponse": {
+ "properties": {
+ "available": {
+ "title": "Available",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "available"
+ ],
+ "title": "ScanAvailabilityResponse",
+ "type": "object"
+ },
+ "ScanConfigItem": {
+ "properties": {
+ "apiKeyMasked": {
+ "title": "Apikeymasked",
+ "type": "string"
},
- "llmConsensusRuns": {
- "default": 1,
- "title": "Llmconsensusruns",
+ "apiVersion": {
+ "title": "Apiversion",
+ "type": "string"
+ },
+ "awsProfile": {
+ "title": "Awsprofile",
+ "type": "string"
+ },
+ "awsRegion": {
+ "title": "Awsregion",
+ "type": "string"
+ },
+ "baseUrl": {
+ "title": "Baseurl",
+ "type": "string"
+ },
+ "consensusRuns": {
+ "title": "Consensusruns",
"type": "integer"
},
- "llmMaxTokens": {
- "default": 8192,
- "title": "Llmmaxtokens",
+ "id": {
+ "title": "Id",
"type": "integer"
},
- "llmModel": {
+ "isActive": {
+ "title": "Isactive",
+ "type": "boolean"
+ },
+ "lastValidatedAt": {
"anyOf": [
{
"type": "string"
@@ -3114,199 +3141,341 @@
"type": "null"
}
],
- "title": "Llmmodel"
+ "title": "Lastvalidatedat"
},
- "llmProvider": {
+ "lastValidationError": {
+ "default": "",
+ "title": "Lastvalidationerror",
+ "type": "string"
+ },
+ "maxTokens": {
+ "title": "Maxtokens",
+ "type": "integer"
+ },
+ "model": {
+ "title": "Model",
+ "type": "string"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "provider": {
+ "title": "Provider",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "baseUrl",
+ "apiKeyMasked",
+ "model",
+ "provider",
+ "apiVersion",
+ "awsRegion",
+ "awsProfile",
+ "maxTokens",
+ "consensusRuns",
+ "isActive"
+ ],
+ "title": "ScanConfigItem",
+ "type": "object"
+ },
+ "ScanConfigListResponse": {
+ "properties": {
+ "activeId": {
"anyOf": [
{
- "type": "string"
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Llmprovider"
+ "title": "Activeid"
},
- "useLlm": {
- "default": true,
- "title": "Usellm",
- "type": "boolean"
+ "configs": {
+ "items": {
+ "$ref": "#/components/schemas/ScanConfigItem"
+ },
+ "title": "Configs",
+ "type": "array"
}
},
- "title": "ScanOptionsRequest",
+ "required": [
+ "configs",
+ "activeId"
+ ],
+ "title": "ScanConfigListResponse",
"type": "object"
},
- "ScanResultResponse": {
+ "ScanConfigSaveRequest": {
"properties": {
- "analyzersUsed": {
- "items": {
- "type": "string"
- },
- "title": "Analyzersused",
- "type": "array"
+ "apiKey": {
+ "title": "Apikey",
+ "type": "string"
},
- "durationSeconds": {
- "title": "Durationseconds",
- "type": "number"
+ "apiVersion": {
+ "default": "",
+ "title": "Apiversion",
+ "type": "string"
},
- "findings": {
- "items": {
- "$ref": "#/components/schemas/ScanFindingResponse"
- },
- "title": "Findings",
- "type": "array"
+ "awsProfile": {
+ "default": "",
+ "title": "Awsprofile",
+ "type": "string"
},
- "findingsCount": {
- "title": "Findingscount",
+ "awsRegion": {
+ "default": "",
+ "title": "Awsregion",
+ "type": "string"
+ },
+ "awsSessionToken": {
+ "default": "",
+ "title": "Awssessiontoken",
+ "type": "string"
+ },
+ "baseUrl": {
+ "title": "Baseurl",
+ "type": "string"
+ },
+ "consensusRuns": {
+ "default": 1,
+ "title": "Consensusruns",
"type": "integer"
},
- "isSafe": {
- "title": "Issafe",
- "type": "boolean"
+ "maxTokens": {
+ "default": 8192,
+ "title": "Maxtokens",
+ "type": "integer"
},
- "maxSeverity": {
- "title": "Maxseverity",
+ "model": {
+ "title": "Model",
"type": "string"
},
- "skillName": {
- "title": "Skillname",
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "provider": {
+ "default": "",
+ "title": "Provider",
"type": "string"
}
},
"required": [
- "skillName",
- "isSafe",
- "maxSeverity",
- "findingsCount",
- "findings",
- "analyzersUsed",
- "durationSeconds"
+ "name",
+ "baseUrl",
+ "apiKey",
+ "model"
],
- "title": "ScanResultResponse",
+ "title": "ScanConfigSaveRequest",
"type": "object"
},
- "SetHarnessSupportRequest": {
+ "ScanConfigSecretResponse": {
"properties": {
- "enabled": {
- "title": "Enabled",
- "type": "boolean"
+ "apiKey": {
+ "title": "Apikey",
+ "type": "string"
}
},
"required": [
- "enabled"
+ "apiKey"
],
- "title": "SetHarnessSupportRequest",
+ "title": "ScanConfigSecretResponse",
"type": "object"
},
- "SetMcpServerHarnessesRequest": {
+ "ScanConfigValidateRequest": {
"properties": {
- "config": {
+ "apiKey": {
+ "title": "Apikey",
+ "type": "string"
+ },
+ "apiVersion": {
+ "default": "",
+ "title": "Apiversion",
+ "type": "string"
+ },
+ "awsProfile": {
+ "default": "",
+ "title": "Awsprofile",
+ "type": "string"
+ },
+ "awsRegion": {
+ "default": "",
+ "title": "Awsregion",
+ "type": "string"
+ },
+ "awsSessionToken": {
+ "default": "",
+ "title": "Awssessiontoken",
+ "type": "string"
+ },
+ "baseUrl": {
+ "title": "Baseurl",
+ "type": "string"
+ },
+ "consensusRuns": {
+ "default": 1,
+ "title": "Consensusruns",
+ "type": "integer"
+ },
+ "existingConfigId": {
"anyOf": [
{
- "additionalProperties": true,
- "type": "object"
+ "type": "integer"
},
{
"type": "null"
}
],
- "title": "Config"
+ "title": "Existingconfigid"
},
- "target": {
- "enum": [
- "enabled",
- "disabled"
- ],
- "title": "Target",
+ "maxTokens": {
+ "default": 8192,
+ "title": "Maxtokens",
+ "type": "integer"
+ },
+ "model": {
+ "title": "Model",
"type": "string"
- }
- },
- "required": [
- "target"
- ],
- "title": "SetMcpServerHarnessesRequest",
- "type": "object"
- },
- "SetSkillHarnessesFailureResponse": {
- "properties": {
- "error": {
- "title": "Error",
+ },
+ "name": {
+ "title": "Name",
"type": "string"
},
- "harness": {
- "title": "Harness",
+ "provider": {
+ "default": "",
+ "title": "Provider",
"type": "string"
}
},
"required": [
- "harness",
- "error"
+ "name",
+ "baseUrl",
+ "apiKey",
+ "model"
],
- "title": "SetSkillHarnessesFailureResponse",
+ "title": "ScanConfigValidateRequest",
"type": "object"
},
- "SetSkillHarnessesRequest": {
+ "ScanConfigValidationResponse": {
"properties": {
- "target": {
- "description": "Target state to apply to every interactive harness cell on this skill",
- "enum": [
- "enabled",
- "disabled"
+ "durationMs": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
],
- "title": "Target",
+ "title": "Durationms"
+ },
+ "errorCode": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Errorcode"
+ },
+ "message": {
+ "title": "Message",
"type": "string"
- }
- },
- "required": [
- "target"
- ],
- "title": "SetSkillHarnessesRequest",
- "type": "object"
- },
- "SetSkillHarnessesResultResponse": {
- "properties": {
- "failed": {
- "items": {
- "$ref": "#/components/schemas/SetSkillHarnessesFailureResponse"
- },
- "title": "Failed",
- "type": "array"
+ },
+ "model": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Model"
},
"ok": {
"title": "Ok",
"type": "boolean"
},
- "succeeded": {
- "items": {
- "type": "string"
- },
- "title": "Succeeded",
- "type": "array"
+ "provider": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Provider"
}
},
"required": [
"ok",
- "succeeded",
- "failed"
+ "message"
],
- "title": "SetSkillHarnessesResultResponse",
+ "title": "ScanConfigValidationResponse",
"type": "object"
},
- "SettingsHarnessResponse": {
+ "ScanFindingResponse": {
"properties": {
- "harness": {
- "title": "Harness",
+ "analyzer": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Analyzer"
+ },
+ "category": {
+ "title": "Category",
"type": "string"
},
- "installed": {
- "title": "Installed",
- "type": "boolean"
+ "description": {
+ "title": "Description",
+ "type": "string"
},
- "label": {
- "title": "Label",
+ "filePath": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Filepath"
+ },
+ "id": {
+ "title": "Id",
"type": "string"
},
- "logoKey": {
+ "lineNumber": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Linenumber"
+ },
+ "metadata": {
+ "additionalProperties": true,
+ "default": {},
+ "title": "Metadata",
+ "type": "object"
+ },
+ "remediation": {
"anyOf": [
{
"type": "string"
@@ -3315,9 +3484,17 @@
"type": "null"
}
],
- "title": "Logokey"
+ "title": "Remediation"
},
- "managedLocation": {
+ "ruleId": {
+ "title": "Ruleid",
+ "type": "string"
+ },
+ "severity": {
+ "title": "Severity",
+ "type": "string"
+ },
+ "snippet": {
"anyOf": [
{
"type": "string"
@@ -3326,146 +3503,38 @@
"type": "null"
}
],
- "title": "Managedlocation"
+ "title": "Snippet"
},
- "supportEnabled": {
- "title": "Supportenabled",
- "type": "boolean"
+ "title": {
+ "title": "Title",
+ "type": "string"
}
},
"required": [
- "harness",
- "label",
- "supportEnabled",
- "installed",
- "managedLocation"
+ "id",
+ "ruleId",
+ "category",
+ "severity",
+ "title",
+ "description"
],
- "title": "SettingsHarnessResponse",
+ "title": "ScanFindingResponse",
"type": "object"
},
- "SettingsResponse": {
+ "ScanOptionsRequest": {
"properties": {
- "harnesses": {
- "items": {
- "$ref": "#/components/schemas/SettingsHarnessResponse"
- },
- "title": "Harnesses",
- "type": "array"
- },
- "storage": {
- "$ref": "#/components/schemas/SettingsStorageResponse"
- }
- },
- "required": [
- "storage",
- "harnesses"
- ],
- "title": "SettingsResponse",
- "type": "object"
- },
- "SettingsStorageResponse": {
- "properties": {
- "configDir": {
- "title": "Configdir",
- "type": "string"
- },
- "dataDir": {
- "title": "Datadir",
- "type": "string"
- },
- "marketplaceCachePath": {
- "title": "Marketplacecachepath",
- "type": "string"
- },
- "platform": {
- "enum": [
- "macos",
- "linux"
- ],
- "title": "Platform",
- "type": "string"
- },
- "settingsPath": {
- "title": "Settingspath",
- "type": "string"
- },
- "skillsStorePath": {
- "title": "Skillsstorepath",
- "type": "string"
- },
- "stateDir": {
- "title": "Statedir",
- "type": "string"
- }
- },
- "required": [
- "platform",
- "configDir",
- "dataDir",
- "stateDir",
- "skillsStorePath",
- "marketplaceCachePath",
- "settingsPath"
- ],
- "title": "SettingsStorageResponse",
- "type": "object"
- },
- "SkillDetailActionsResponse": {
- "properties": {
- "canDelete": {
- "title": "Candelete",
- "type": "boolean"
- },
- "canManage": {
- "title": "Canmanage",
- "type": "boolean"
- },
- "deleteHarnessLabels": {
- "items": {
- "type": "string"
- },
- "title": "Deleteharnesslabels",
- "type": "array"
- },
- "stopManagingHarnessLabels": {
- "items": {
- "type": "string"
- },
- "title": "Stopmanagingharnesslabels",
- "type": "array"
- },
- "stopManagingStatus": {
+ "awsProfile": {
"anyOf": [
{
- "enum": [
- "available",
- "disabled_no_enabled"
- ],
"type": "string"
},
{
"type": "null"
}
],
- "title": "Stopmanagingstatus"
- }
- },
- "required": [
- "canManage",
- "stopManagingStatus",
- "stopManagingHarnessLabels",
- "canDelete",
- "deleteHarnessLabels"
- ],
- "title": "SkillDetailActionsResponse",
- "type": "object"
- },
- "SkillDetailResponse": {
- "properties": {
- "actions": {
- "$ref": "#/components/schemas/SkillDetailActionsResponse"
+ "title": "Awsprofile"
},
- "attentionMessage": {
+ "awsRegion": {
"anyOf": [
{
"type": "string"
@@ -3474,21 +3543,9 @@
"type": "null"
}
],
- "title": "Attentionmessage"
- },
- "description": {
- "title": "Description",
- "type": "string"
- },
- "displayStatus": {
- "enum": [
- "Managed",
- "Unmanaged"
- ],
- "title": "Displaystatus",
- "type": "string"
+ "title": "Awsregion"
},
- "documentMarkdown": {
+ "awsSessionToken": {
"anyOf": [
{
"type": "string"
@@ -3497,59 +3554,9 @@
"type": "null"
}
],
- "title": "Documentmarkdown"
- },
- "harnessCells": {
- "items": {
- "$ref": "#/components/schemas/HarnessCellResponse"
- },
- "title": "Harnesscells",
- "type": "array"
- },
- "locations": {
- "items": {
- "$ref": "#/components/schemas/SkillLocationResponse"
- },
- "title": "Locations",
- "type": "array"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "skillRef": {
- "title": "Skillref",
- "type": "string"
+ "title": "Awssessiontoken"
},
- "sourceLinks": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/SkillSourceLinksResponse"
- },
- {
- "type": "null"
- }
- ]
- }
- },
- "required": [
- "skillRef",
- "name",
- "description",
- "displayStatus",
- "attentionMessage",
- "actions",
- "harnessCells",
- "locations",
- "sourceLinks",
- "documentMarkdown"
- ],
- "title": "SkillDetailResponse",
- "type": "object"
- },
- "SkillLocationResponse": {
- "properties": {
- "detail": {
+ "llmApiKey": {
"anyOf": [
{
"type": "string"
@@ -3558,9 +3565,9 @@
"type": "null"
}
],
- "title": "Detail"
+ "title": "Llmapikey"
},
- "harness": {
+ "llmApiVersion": {
"anyOf": [
{
"type": "string"
@@ -3569,21 +3576,9 @@
"type": "null"
}
],
- "title": "Harness"
- },
- "kind": {
- "enum": [
- "shared",
- "harness"
- ],
- "title": "Kind",
- "type": "string"
- },
- "label": {
- "title": "Label",
- "type": "string"
+ "title": "Llmapiversion"
},
- "path": {
+ "llmBaseUrl": {
"anyOf": [
{
"type": "string"
@@ -3592,9 +3587,19 @@
"type": "null"
}
],
- "title": "Path"
+ "title": "Llmbaseurl"
},
- "revision": {
+ "llmConsensusRuns": {
+ "default": 1,
+ "title": "Llmconsensusruns",
+ "type": "integer"
+ },
+ "llmMaxTokens": {
+ "default": 8192,
+ "title": "Llmmaxtokens",
+ "type": "integer"
+ },
+ "llmModel": {
"anyOf": [
{
"type": "string"
@@ -3603,9 +3608,9 @@
"type": "null"
}
],
- "title": "Revision"
+ "title": "Llmmodel"
},
- "scope": {
+ "llmProvider": {
"anyOf": [
{
"type": "string"
@@ -3614,583 +3619,448 @@
"type": "null"
}
],
- "title": "Scope"
- },
- "sourceKind": {
- "title": "Sourcekind",
- "type": "string"
+ "title": "Llmprovider"
},
- "sourceLocator": {
- "title": "Sourcelocator",
- "type": "string"
+ "useLlm": {
+ "default": true,
+ "title": "Usellm",
+ "type": "boolean"
}
},
- "required": [
- "kind",
- "harness",
- "label",
- "scope",
- "path",
- "revision",
- "sourceKind",
- "sourceLocator",
- "detail"
- ],
- "title": "SkillLocationResponse",
+ "title": "ScanOptionsRequest",
"type": "object"
},
- "SkillRowActionsResponse": {
+ "ScanResultResponse": {
"properties": {
- "canDelete": {
- "title": "Candelete",
- "type": "boolean"
- },
- "canManage": {
- "title": "Canmanage",
- "type": "boolean"
- },
- "canStopManaging": {
- "title": "Canstopmanaging",
+ "analyzersUsed": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Analyzersused",
+ "type": "array"
+ },
+ "durationSeconds": {
+ "title": "Durationseconds",
+ "type": "number"
+ },
+ "findings": {
+ "items": {
+ "$ref": "#/components/schemas/ScanFindingResponse"
+ },
+ "title": "Findings",
+ "type": "array"
+ },
+ "findingsCount": {
+ "title": "Findingscount",
+ "type": "integer"
+ },
+ "isSafe": {
+ "title": "Issafe",
"type": "boolean"
+ },
+ "maxSeverity": {
+ "title": "Maxseverity",
+ "type": "string"
+ },
+ "skillName": {
+ "title": "Skillname",
+ "type": "string"
}
},
"required": [
- "canManage",
- "canStopManaging",
- "canDelete"
+ "skillName",
+ "isSafe",
+ "maxSeverity",
+ "findingsCount",
+ "findings",
+ "analyzersUsed",
+ "durationSeconds"
],
- "title": "SkillRowActionsResponse",
+ "title": "ScanResultResponse",
"type": "object"
},
- "SkillSourceLinksResponse": {
+ "SetHarnessSupportRequest": {
"properties": {
- "folderUrl": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
+ "enabled": {
+ "title": "Enabled",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "enabled"
+ ],
+ "title": "SetHarnessSupportRequest",
+ "type": "object"
+ },
+ "SetHookHarnessesRequest": {
+ "properties": {
+ "target": {
+ "enum": [
+ "enabled",
+ "disabled"
],
- "title": "Folderurl"
- },
- "repoLabel": {
- "title": "Repolabel",
- "type": "string"
- },
- "repoUrl": {
- "title": "Repourl",
+ "title": "Target",
"type": "string"
}
},
"required": [
- "repoLabel",
- "repoUrl",
- "folderUrl"
+ "target"
],
- "title": "SkillSourceLinksResponse",
+ "title": "SetHookHarnessesRequest",
"type": "object"
},
- "SkillSourceStatusResponse": {
+ "SetMcpServerHarnessesRequest": {
"properties": {
- "updateStatus": {
+ "config": {
"anyOf": [
{
- "enum": [
- "update_available",
- "no_update_available",
- "no_source_available",
- "local_changes_detected"
- ],
- "type": "string"
+ "additionalProperties": true,
+ "type": "object"
},
{
"type": "null"
}
],
- "title": "Updatestatus"
- }
- },
- "required": [
- "updateStatus"
- ],
- "title": "SkillSourceStatusResponse",
- "type": "object"
- },
- "SkillTableRowResponse": {
- "properties": {
- "actions": {
- "$ref": "#/components/schemas/SkillRowActionsResponse"
- },
- "cells": {
- "items": {
- "$ref": "#/components/schemas/HarnessCellResponse"
- },
- "title": "Cells",
- "type": "array"
- },
- "description": {
- "title": "Description",
- "type": "string"
+ "title": "Config"
},
- "displayStatus": {
+ "target": {
"enum": [
- "Managed",
- "Unmanaged"
+ "enabled",
+ "disabled"
],
- "title": "Displaystatus",
- "type": "string"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "skillRef": {
- "title": "Skillref",
+ "title": "Target",
"type": "string"
}
},
"required": [
- "skillRef",
- "name",
- "description",
- "displayStatus",
- "actions",
- "cells"
+ "target"
],
- "title": "SkillTableRowResponse",
+ "title": "SetMcpServerHarnessesRequest",
"type": "object"
},
- "SkillsPageResponse": {
+ "SetSkillHarnessesFailureResponse": {
"properties": {
- "harnessColumns": {
- "items": {
- "$ref": "#/components/schemas/HarnessColumnResponse"
- },
- "title": "Harnesscolumns",
- "type": "array"
- },
- "rows": {
- "items": {
- "$ref": "#/components/schemas/SkillTableRowResponse"
- },
- "title": "Rows",
- "type": "array"
+ "error": {
+ "title": "Error",
+ "type": "string"
},
- "summary": {
- "$ref": "#/components/schemas/SkillsSummaryResponse"
+ "harness": {
+ "title": "Harness",
+ "type": "string"
}
},
"required": [
- "summary",
- "harnessColumns",
- "rows"
+ "harness",
+ "error"
],
- "title": "SkillsPageResponse",
+ "title": "SetSkillHarnessesFailureResponse",
"type": "object"
},
- "SkillsSummaryResponse": {
+ "SetSkillHarnessesRequest": {
"properties": {
- "managed": {
- "title": "Managed",
- "type": "integer"
- },
- "unmanaged": {
- "title": "Unmanaged",
- "type": "integer"
+ "target": {
+ "description": "Target state to apply to every interactive harness cell on this skill",
+ "enum": [
+ "enabled",
+ "disabled"
+ ],
+ "title": "Target",
+ "type": "string"
}
},
"required": [
- "managed",
- "unmanaged"
+ "target"
],
- "title": "SkillsSummaryResponse",
+ "title": "SetSkillHarnessesRequest",
"type": "object"
},
- "SlashCommandDeleteResponse": {
+ "SetSkillHarnessesResultResponse": {
"properties": {
+ "failed": {
+ "items": {
+ "$ref": "#/components/schemas/SetSkillHarnessesFailureResponse"
+ },
+ "title": "Failed",
+ "type": "array"
+ },
"ok": {
"title": "Ok",
"type": "boolean"
},
- "sync": {
+ "succeeded": {
"items": {
- "$ref": "#/components/schemas/SlashSyncEntryResponse"
+ "type": "string"
},
- "title": "Sync",
+ "title": "Succeeded",
"type": "array"
}
},
"required": [
"ok",
- "sync"
+ "succeeded",
+ "failed"
],
- "title": "SlashCommandDeleteResponse",
+ "title": "SetSkillHarnessesResultResponse",
"type": "object"
},
- "SlashCommandImportRequest": {
+ "SettingsHarnessResponse": {
"properties": {
- "name": {
- "minLength": 1,
- "title": "Name",
+ "harness": {
+ "title": "Harness",
"type": "string"
},
- "target": {
- "enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
- ],
- "title": "Target",
- "type": "string"
- }
- },
- "required": [
- "target",
- "name"
- ],
- "title": "SlashCommandImportRequest",
- "type": "object"
- },
- "SlashCommandListResponse": {
- "properties": {
- "commands": {
- "items": {
- "$ref": "#/components/schemas/SlashCommandResponse"
- },
- "title": "Commands",
- "type": "array"
- },
- "defaultTargets": {
- "items": {
- "enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
- ],
- "type": "string"
- },
- "title": "Defaulttargets",
- "type": "array"
- },
- "reviewCommands": {
- "items": {
- "$ref": "#/components/schemas/SlashCommandReviewResponse"
- },
- "title": "Reviewcommands",
- "type": "array"
- },
- "storePath": {
- "title": "Storepath",
- "type": "string"
- },
- "syncStatePath": {
- "title": "Syncstatepath",
- "type": "string"
- },
- "targets": {
- "items": {
- "$ref": "#/components/schemas/SlashTargetResponse"
- },
- "title": "Targets",
- "type": "array"
- }
- },
- "required": [
- "storePath",
- "syncStatePath",
- "targets",
- "defaultTargets",
- "commands",
- "reviewCommands"
- ],
- "title": "SlashCommandListResponse",
- "type": "object"
- },
- "SlashCommandMutationRequest": {
- "properties": {
- "description": {
- "minLength": 1,
- "title": "Description",
- "type": "string"
- },
- "name": {
- "minLength": 1,
- "title": "Name",
- "type": "string"
- },
- "prompt": {
- "minLength": 1,
- "title": "Prompt",
+ "installed": {
+ "title": "Installed",
+ "type": "boolean"
+ },
+ "label": {
+ "title": "Label",
"type": "string"
},
- "targets": {
+ "logoKey": {
"anyOf": [
{
- "items": {
- "enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
- ],
- "type": "string"
- },
- "type": "array"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Targets"
- }
- },
- "required": [
- "name",
- "description",
- "prompt"
- ],
- "title": "SlashCommandMutationRequest",
- "type": "object"
- },
- "SlashCommandMutationResponse": {
- "properties": {
- "command": {
+ "title": "Logokey"
+ },
+ "managedLocation": {
"anyOf": [
{
- "$ref": "#/components/schemas/SlashCommandResponse"
+ "type": "string"
},
{
"type": "null"
}
- ]
+ ],
+ "title": "Managedlocation"
},
- "ok": {
- "title": "Ok",
+ "supportEnabled": {
+ "title": "Supportenabled",
"type": "boolean"
- },
- "sync": {
+ }
+ },
+ "required": [
+ "harness",
+ "label",
+ "supportEnabled",
+ "installed",
+ "managedLocation"
+ ],
+ "title": "SettingsHarnessResponse",
+ "type": "object"
+ },
+ "SettingsResponse": {
+ "properties": {
+ "harnesses": {
"items": {
- "$ref": "#/components/schemas/SlashSyncEntryResponse"
+ "$ref": "#/components/schemas/SettingsHarnessResponse"
},
- "title": "Sync",
+ "title": "Harnesses",
"type": "array"
+ },
+ "storage": {
+ "$ref": "#/components/schemas/SettingsStorageResponse"
}
},
"required": [
- "ok",
- "command",
- "sync"
+ "storage",
+ "harnesses"
],
- "title": "SlashCommandMutationResponse",
+ "title": "SettingsResponse",
"type": "object"
},
- "SlashCommandResolveRequest": {
+ "SettingsStorageResponse": {
"properties": {
- "action": {
- "enum": [
- "restore_managed",
- "adopt_target",
- "remove_binding"
- ],
- "title": "Action",
+ "configDir": {
+ "title": "Configdir",
"type": "string"
},
- "name": {
- "minLength": 1,
- "title": "Name",
+ "dataDir": {
+ "title": "Datadir",
"type": "string"
},
- "target": {
+ "marketplaceCachePath": {
+ "title": "Marketplacecachepath",
+ "type": "string"
+ },
+ "platform": {
"enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
+ "macos",
+ "linux"
],
- "title": "Target",
- "type": "string"
- }
- },
- "required": [
- "target",
- "name",
- "action"
- ],
- "title": "SlashCommandResolveRequest",
- "type": "object"
- },
- "SlashCommandResponse": {
- "properties": {
- "description": {
- "title": "Description",
+ "title": "Platform",
"type": "string"
},
- "name": {
- "title": "Name",
+ "settingsPath": {
+ "title": "Settingspath",
"type": "string"
},
- "prompt": {
- "title": "Prompt",
+ "skillsStorePath": {
+ "title": "Skillsstorepath",
"type": "string"
},
- "syncTargets": {
- "items": {
- "$ref": "#/components/schemas/SlashSyncEntryResponse"
- },
- "title": "Synctargets",
- "type": "array"
+ "stateDir": {
+ "title": "Statedir",
+ "type": "string"
}
},
"required": [
- "name",
- "description",
- "prompt",
- "syncTargets"
+ "platform",
+ "configDir",
+ "dataDir",
+ "stateDir",
+ "skillsStorePath",
+ "marketplaceCachePath",
+ "settingsPath"
],
- "title": "SlashCommandResponse",
+ "title": "SettingsStorageResponse",
"type": "object"
},
- "SlashCommandReviewResponse": {
+ "SkillDetailActionsResponse": {
"properties": {
- "actions": {
+ "canDelete": {
+ "title": "Candelete",
+ "type": "boolean"
+ },
+ "canManage": {
+ "title": "Canmanage",
+ "type": "boolean"
+ },
+ "deleteHarnessLabels": {
"items": {
- "enum": [
- "import",
- "restore_managed",
- "adopt_target",
- "remove_binding"
- ],
"type": "string"
},
- "title": "Actions",
+ "title": "Deleteharnesslabels",
"type": "array"
},
- "canImport": {
- "title": "Canimport",
- "type": "boolean"
- },
- "commandExists": {
- "title": "Commandexists",
- "type": "boolean"
- },
- "description": {
- "title": "Description",
- "type": "string"
+ "stopManagingHarnessLabels": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Stopmanagingharnesslabels",
+ "type": "array"
},
- "error": {
+ "stopManagingStatus": {
"anyOf": [
{
+ "enum": [
+ "available",
+ "disabled_no_enabled"
+ ],
"type": "string"
},
{
"type": "null"
}
],
- "title": "Error"
- },
- "kind": {
- "enum": [
- "unmanaged",
- "drifted",
- "missing"
- ],
- "title": "Kind",
- "type": "string"
- },
- "name": {
- "title": "Name",
- "type": "string"
- },
- "path": {
- "title": "Path",
- "type": "string"
- },
- "prompt": {
- "title": "Prompt",
- "type": "string"
- },
- "reviewRef": {
- "title": "Reviewref",
- "type": "string"
- },
- "target": {
- "enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
- ],
- "title": "Target",
- "type": "string"
- },
- "targetLabel": {
- "title": "Targetlabel",
- "type": "string"
+ "title": "Stopmanagingstatus"
}
},
"required": [
- "reviewRef",
- "kind",
- "target",
- "targetLabel",
- "name",
- "path",
- "description",
- "prompt",
- "commandExists",
- "canImport",
- "actions"
+ "canManage",
+ "stopManagingStatus",
+ "stopManagingHarnessLabels",
+ "canDelete",
+ "deleteHarnessLabels"
],
- "title": "SlashCommandReviewResponse",
+ "title": "SkillDetailActionsResponse",
"type": "object"
},
- "SlashCommandUpdateRequest": {
+ "SkillDetailResponse": {
"properties": {
+ "actions": {
+ "$ref": "#/components/schemas/SkillDetailActionsResponse"
+ },
+ "attentionMessage": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Attentionmessage"
+ },
"description": {
- "minLength": 1,
"title": "Description",
"type": "string"
},
- "prompt": {
- "minLength": 1,
- "title": "Prompt",
+ "displayStatus": {
+ "enum": [
+ "Managed",
+ "Unmanaged"
+ ],
+ "title": "Displaystatus",
"type": "string"
},
- "targets": {
+ "documentMarkdown": {
"anyOf": [
{
- "items": {
- "enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
- ],
- "type": "string"
- },
- "type": "array"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Targets"
+ "title": "Documentmarkdown"
+ },
+ "harnessCells": {
+ "items": {
+ "$ref": "#/components/schemas/HarnessCellResponse"
+ },
+ "title": "Harnesscells",
+ "type": "array"
+ },
+ "locations": {
+ "items": {
+ "$ref": "#/components/schemas/SkillLocationResponse"
+ },
+ "title": "Locations",
+ "type": "array"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "skillRef": {
+ "title": "Skillref",
+ "type": "string"
+ },
+ "sourceLinks": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SkillSourceLinksResponse"
+ },
+ {
+ "type": "null"
+ }
+ ]
}
},
"required": [
+ "skillRef",
+ "name",
"description",
- "prompt"
+ "displayStatus",
+ "attentionMessage",
+ "actions",
+ "harnessCells",
+ "locations",
+ "sourceLinks",
+ "documentMarkdown"
],
- "title": "SlashCommandUpdateRequest",
+ "title": "SkillDetailResponse",
"type": "object"
},
- "SlashSyncEntryResponse": {
+ "SkillLocationResponse": {
"properties": {
- "error": {
+ "detail": {
"anyOf": [
{
"type": "string"
@@ -4199,235 +4069,1202 @@
"type": "null"
}
],
- "title": "Error"
- },
- "path": {
- "title": "Path",
- "type": "string"
- },
- "status": {
- "enum": [
- "synced",
- "removed",
- "not_selected",
- "blocked_manual_file",
- "blocked_modified_file",
- "missing",
- "drifted",
- "failed"
- ],
- "title": "Status",
- "type": "string"
+ "title": "Detail"
},
- "target": {
- "enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
- ],
- "title": "Target",
- "type": "string"
- }
- },
- "required": [
- "target",
- "path",
- "status"
- ],
- "title": "SlashSyncEntryResponse",
- "type": "object"
- },
- "SlashSyncRequest": {
- "properties": {
- "targets": {
+ "harness": {
"anyOf": [
{
- "items": {
- "enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
- ],
- "type": "string"
- },
- "type": "array"
+ "type": "string"
},
{
"type": "null"
}
],
- "title": "Targets"
- }
- },
- "title": "SlashSyncRequest",
- "type": "object"
- },
- "SlashTargetResponse": {
- "properties": {
- "available": {
- "title": "Available",
- "type": "boolean"
- },
- "defaultSelected": {
- "title": "Defaultselected",
- "type": "boolean"
- },
- "docsUrl": {
- "title": "Docsurl",
- "type": "string"
- },
- "enabled": {
- "title": "Enabled",
- "type": "boolean"
- },
- "fileGlob": {
- "title": "Fileglob",
- "type": "string"
+ "title": "Harness"
},
- "id": {
+ "kind": {
"enum": [
- "opencode",
- "claude",
- "cursor",
- "codex"
+ "shared",
+ "harness"
],
- "title": "Id",
- "type": "string"
- },
- "invocationPrefix": {
- "title": "Invocationprefix",
+ "title": "Kind",
"type": "string"
},
"label": {
"title": "Label",
"type": "string"
},
- "outputDir": {
- "title": "Outputdir",
- "type": "string"
+ "path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path"
},
- "renderFormat": {
- "enum": [
- "frontmatter_markdown",
- "cursor_plaintext"
+ "revision": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
],
- "title": "Renderformat",
+ "title": "Revision"
+ },
+ "scope": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scope"
+ },
+ "sourceKind": {
+ "title": "Sourcekind",
"type": "string"
},
- "rootPath": {
- "title": "Rootpath",
+ "sourceLocator": {
+ "title": "Sourcelocator",
"type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "harness",
+ "label",
+ "scope",
+ "path",
+ "revision",
+ "sourceKind",
+ "sourceLocator",
+ "detail"
+ ],
+ "title": "SkillLocationResponse",
+ "type": "object"
+ },
+ "SkillRowActionsResponse": {
+ "properties": {
+ "canDelete": {
+ "title": "Candelete",
+ "type": "boolean"
},
- "scope": {
- "enum": [
- "global",
- "project"
+ "canManage": {
+ "title": "Canmanage",
+ "type": "boolean"
+ },
+ "canStopManaging": {
+ "title": "Canstopmanaging",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "canManage",
+ "canStopManaging",
+ "canDelete"
+ ],
+ "title": "SkillRowActionsResponse",
+ "type": "object"
+ },
+ "SkillSourceLinksResponse": {
+ "properties": {
+ "folderUrl": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
],
- "title": "Scope",
+ "title": "Folderurl"
+ },
+ "repoLabel": {
+ "title": "Repolabel",
"type": "string"
},
- "supportNote": {
+ "repoUrl": {
+ "title": "Repourl",
+ "type": "string"
+ }
+ },
+ "required": [
+ "repoLabel",
+ "repoUrl",
+ "folderUrl"
+ ],
+ "title": "SkillSourceLinksResponse",
+ "type": "object"
+ },
+ "SkillSourceStatusResponse": {
+ "properties": {
+ "updateStatus": {
"anyOf": [
{
+ "enum": [
+ "update_available",
+ "no_update_available",
+ "no_source_available",
+ "local_changes_detected"
+ ],
"type": "string"
},
{
"type": "null"
}
],
- "title": "Supportnote"
- },
- "supportsFrontmatter": {
- "title": "Supportsfrontmatter",
- "type": "boolean"
+ "title": "Updatestatus"
}
},
"required": [
- "id",
- "label",
- "rootPath",
- "outputDir",
- "invocationPrefix",
- "renderFormat",
- "scope",
- "docsUrl",
- "fileGlob",
- "supportsFrontmatter",
- "defaultSelected",
- "enabled",
- "available"
+ "updateStatus"
],
- "title": "SlashTargetResponse",
+ "title": "SkillSourceStatusResponse",
"type": "object"
},
- "ValidationError": {
+ "SkillTableRowResponse": {
"properties": {
- "ctx": {
- "title": "Context",
- "type": "object"
- },
- "input": {
- "title": "Input"
+ "actions": {
+ "$ref": "#/components/schemas/SkillRowActionsResponse"
},
- "loc": {
+ "cells": {
"items": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "integer"
- }
- ]
+ "$ref": "#/components/schemas/HarnessCellResponse"
},
- "title": "Location",
+ "title": "Cells",
"type": "array"
},
- "msg": {
- "title": "Message",
+ "description": {
+ "title": "Description",
"type": "string"
},
- "type": {
- "title": "Error Type",
+ "displayStatus": {
+ "enum": [
+ "Managed",
+ "Unmanaged"
+ ],
+ "title": "Displaystatus",
+ "type": "string"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "skillRef": {
+ "title": "Skillref",
"type": "string"
}
},
"required": [
- "loc",
- "msg",
- "type"
+ "skillRef",
+ "name",
+ "description",
+ "displayStatus",
+ "actions",
+ "cells"
],
- "title": "ValidationError",
+ "title": "SkillTableRowResponse",
"type": "object"
- }
- }
- },
- "info": {
- "title": "skill-manager",
- "version": "0.1.0"
- },
- "openapi": "3.1.0",
- "paths": {
- "/api/health": {
- "get": {
- "operationId": "health_api_health_get",
- "responses": {
- "200": {
- "content": {
- "application/json": {
- "schema": {
- "additionalProperties": true,
- "title": "Response Health Api Health Get",
- "type": "object"
- }
- }
+ },
+ "SkillsPageResponse": {
+ "properties": {
+ "harnessColumns": {
+ "items": {
+ "$ref": "#/components/schemas/HarnessColumnResponse"
},
- "description": "Successful Response"
+ "title": "Harnesscolumns",
+ "type": "array"
+ },
+ "rows": {
+ "items": {
+ "$ref": "#/components/schemas/SkillTableRowResponse"
+ },
+ "title": "Rows",
+ "type": "array"
+ },
+ "summary": {
+ "$ref": "#/components/schemas/SkillsSummaryResponse"
}
},
- "summary": "Health"
+ "required": [
+ "summary",
+ "harnessColumns",
+ "rows"
+ ],
+ "title": "SkillsPageResponse",
+ "type": "object"
+ },
+ "SkillsSummaryResponse": {
+ "properties": {
+ "managed": {
+ "title": "Managed",
+ "type": "integer"
+ },
+ "unmanaged": {
+ "title": "Unmanaged",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "managed",
+ "unmanaged"
+ ],
+ "title": "SkillsSummaryResponse",
+ "type": "object"
+ },
+ "SlashCommandDeleteResponse": {
+ "properties": {
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
+ },
+ "sync": {
+ "items": {
+ "$ref": "#/components/schemas/SlashSyncEntryResponse"
+ },
+ "title": "Sync",
+ "type": "array"
+ }
+ },
+ "required": [
+ "ok",
+ "sync"
+ ],
+ "title": "SlashCommandDeleteResponse",
+ "type": "object"
+ },
+ "SlashCommandImportRequest": {
+ "properties": {
+ "name": {
+ "minLength": 1,
+ "title": "Name",
+ "type": "string"
+ },
+ "target": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "title": "Target",
+ "type": "string"
+ }
+ },
+ "required": [
+ "target",
+ "name"
+ ],
+ "title": "SlashCommandImportRequest",
+ "type": "object"
+ },
+ "SlashCommandListResponse": {
+ "properties": {
+ "commands": {
+ "items": {
+ "$ref": "#/components/schemas/SlashCommandResponse"
+ },
+ "title": "Commands",
+ "type": "array"
+ },
+ "defaultTargets": {
+ "items": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "type": "string"
+ },
+ "title": "Defaulttargets",
+ "type": "array"
+ },
+ "reviewCommands": {
+ "items": {
+ "$ref": "#/components/schemas/SlashCommandReviewResponse"
+ },
+ "title": "Reviewcommands",
+ "type": "array"
+ },
+ "storePath": {
+ "title": "Storepath",
+ "type": "string"
+ },
+ "syncStatePath": {
+ "title": "Syncstatepath",
+ "type": "string"
+ },
+ "targets": {
+ "items": {
+ "$ref": "#/components/schemas/SlashTargetResponse"
+ },
+ "title": "Targets",
+ "type": "array"
+ }
+ },
+ "required": [
+ "storePath",
+ "syncStatePath",
+ "targets",
+ "defaultTargets",
+ "commands",
+ "reviewCommands"
+ ],
+ "title": "SlashCommandListResponse",
+ "type": "object"
+ },
+ "SlashCommandMutationRequest": {
+ "properties": {
+ "description": {
+ "minLength": 1,
+ "title": "Description",
+ "type": "string"
+ },
+ "name": {
+ "minLength": 1,
+ "title": "Name",
+ "type": "string"
+ },
+ "prompt": {
+ "minLength": 1,
+ "title": "Prompt",
+ "type": "string"
+ },
+ "targets": {
+ "anyOf": [
+ {
+ "items": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Targets"
+ }
+ },
+ "required": [
+ "name",
+ "description",
+ "prompt"
+ ],
+ "title": "SlashCommandMutationRequest",
+ "type": "object"
+ },
+ "SlashCommandMutationResponse": {
+ "properties": {
+ "command": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SlashCommandResponse"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "ok": {
+ "title": "Ok",
+ "type": "boolean"
+ },
+ "sync": {
+ "items": {
+ "$ref": "#/components/schemas/SlashSyncEntryResponse"
+ },
+ "title": "Sync",
+ "type": "array"
+ }
+ },
+ "required": [
+ "ok",
+ "command",
+ "sync"
+ ],
+ "title": "SlashCommandMutationResponse",
+ "type": "object"
+ },
+ "SlashCommandResolveRequest": {
+ "properties": {
+ "action": {
+ "enum": [
+ "restore_managed",
+ "adopt_target",
+ "remove_binding"
+ ],
+ "title": "Action",
+ "type": "string"
+ },
+ "name": {
+ "minLength": 1,
+ "title": "Name",
+ "type": "string"
+ },
+ "target": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "title": "Target",
+ "type": "string"
+ }
+ },
+ "required": [
+ "target",
+ "name",
+ "action"
+ ],
+ "title": "SlashCommandResolveRequest",
+ "type": "object"
+ },
+ "SlashCommandResponse": {
+ "properties": {
+ "description": {
+ "title": "Description",
+ "type": "string"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "prompt": {
+ "title": "Prompt",
+ "type": "string"
+ },
+ "syncTargets": {
+ "items": {
+ "$ref": "#/components/schemas/SlashSyncEntryResponse"
+ },
+ "title": "Synctargets",
+ "type": "array"
+ }
+ },
+ "required": [
+ "name",
+ "description",
+ "prompt",
+ "syncTargets"
+ ],
+ "title": "SlashCommandResponse",
+ "type": "object"
+ },
+ "SlashCommandReviewResponse": {
+ "properties": {
+ "actions": {
+ "items": {
+ "enum": [
+ "import",
+ "restore_managed",
+ "adopt_target",
+ "remove_binding"
+ ],
+ "type": "string"
+ },
+ "title": "Actions",
+ "type": "array"
+ },
+ "canImport": {
+ "title": "Canimport",
+ "type": "boolean"
+ },
+ "commandExists": {
+ "title": "Commandexists",
+ "type": "boolean"
+ },
+ "description": {
+ "title": "Description",
+ "type": "string"
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error"
+ },
+ "kind": {
+ "enum": [
+ "unmanaged",
+ "drifted",
+ "missing"
+ ],
+ "title": "Kind",
+ "type": "string"
+ },
+ "name": {
+ "title": "Name",
+ "type": "string"
+ },
+ "path": {
+ "title": "Path",
+ "type": "string"
+ },
+ "prompt": {
+ "title": "Prompt",
+ "type": "string"
+ },
+ "reviewRef": {
+ "title": "Reviewref",
+ "type": "string"
+ },
+ "target": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "title": "Target",
+ "type": "string"
+ },
+ "targetLabel": {
+ "title": "Targetlabel",
+ "type": "string"
+ }
+ },
+ "required": [
+ "reviewRef",
+ "kind",
+ "target",
+ "targetLabel",
+ "name",
+ "path",
+ "description",
+ "prompt",
+ "commandExists",
+ "canImport",
+ "actions"
+ ],
+ "title": "SlashCommandReviewResponse",
+ "type": "object"
+ },
+ "SlashCommandUpdateRequest": {
+ "properties": {
+ "description": {
+ "minLength": 1,
+ "title": "Description",
+ "type": "string"
+ },
+ "prompt": {
+ "minLength": 1,
+ "title": "Prompt",
+ "type": "string"
+ },
+ "targets": {
+ "anyOf": [
+ {
+ "items": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Targets"
+ }
+ },
+ "required": [
+ "description",
+ "prompt"
+ ],
+ "title": "SlashCommandUpdateRequest",
+ "type": "object"
+ },
+ "SlashSyncEntryResponse": {
+ "properties": {
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error"
+ },
+ "path": {
+ "title": "Path",
+ "type": "string"
+ },
+ "status": {
+ "enum": [
+ "synced",
+ "removed",
+ "not_selected",
+ "blocked_manual_file",
+ "blocked_modified_file",
+ "missing",
+ "drifted",
+ "failed"
+ ],
+ "title": "Status",
+ "type": "string"
+ },
+ "target": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "title": "Target",
+ "type": "string"
+ }
+ },
+ "required": [
+ "target",
+ "path",
+ "status"
+ ],
+ "title": "SlashSyncEntryResponse",
+ "type": "object"
+ },
+ "SlashSyncRequest": {
+ "properties": {
+ "targets": {
+ "anyOf": [
+ {
+ "items": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Targets"
+ }
+ },
+ "title": "SlashSyncRequest",
+ "type": "object"
+ },
+ "SlashTargetResponse": {
+ "properties": {
+ "available": {
+ "title": "Available",
+ "type": "boolean"
+ },
+ "defaultSelected": {
+ "title": "Defaultselected",
+ "type": "boolean"
+ },
+ "docsUrl": {
+ "title": "Docsurl",
+ "type": "string"
+ },
+ "enabled": {
+ "title": "Enabled",
+ "type": "boolean"
+ },
+ "fileGlob": {
+ "title": "Fileglob",
+ "type": "string"
+ },
+ "id": {
+ "enum": [
+ "opencode",
+ "claude",
+ "cursor",
+ "codex"
+ ],
+ "title": "Id",
+ "type": "string"
+ },
+ "invocationPrefix": {
+ "title": "Invocationprefix",
+ "type": "string"
+ },
+ "label": {
+ "title": "Label",
+ "type": "string"
+ },
+ "outputDir": {
+ "title": "Outputdir",
+ "type": "string"
+ },
+ "renderFormat": {
+ "enum": [
+ "frontmatter_markdown",
+ "cursor_plaintext"
+ ],
+ "title": "Renderformat",
+ "type": "string"
+ },
+ "rootPath": {
+ "title": "Rootpath",
+ "type": "string"
+ },
+ "scope": {
+ "enum": [
+ "global",
+ "project"
+ ],
+ "title": "Scope",
+ "type": "string"
+ },
+ "supportNote": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Supportnote"
+ },
+ "supportsFrontmatter": {
+ "title": "Supportsfrontmatter",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "id",
+ "label",
+ "rootPath",
+ "outputDir",
+ "invocationPrefix",
+ "renderFormat",
+ "scope",
+ "docsUrl",
+ "fileGlob",
+ "supportsFrontmatter",
+ "defaultSelected",
+ "enabled",
+ "available"
+ ],
+ "title": "SlashTargetResponse",
+ "type": "object"
+ },
+ "ValidationError": {
+ "properties": {
+ "ctx": {
+ "title": "Context",
+ "type": "object"
+ },
+ "input": {
+ "title": "Input"
+ },
+ "loc": {
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "integer"
+ }
+ ]
+ },
+ "title": "Location",
+ "type": "array"
+ },
+ "msg": {
+ "title": "Message",
+ "type": "string"
+ },
+ "type": {
+ "title": "Error Type",
+ "type": "string"
+ }
+ },
+ "required": [
+ "loc",
+ "msg",
+ "type"
+ ],
+ "title": "ValidationError",
+ "type": "object"
+ }
+ }
+ },
+ "info": {
+ "title": "skill-manager",
+ "version": "0.1.0"
+ },
+ "openapi": "3.1.0",
+ "paths": {
+ "/api/health": {
+ "get": {
+ "operationId": "health_api_health_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": true,
+ "title": "Response Health Api Health Get",
+ "type": "object"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "summary": "Health"
+ }
+ },
+ "/api/hooks": {
+ "get": {
+ "operationId": "list_hooks_api_hooks_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HookInventoryResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "summary": "List Hooks"
+ },
+ "post": {
+ "operationId": "create_hook_api_hooks_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AddHookRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HookMutationResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Create Hook"
+ }
+ },
+ "/api/hooks/{id}": {
+ "delete": {
+ "operationId": "delete_hook_api_hooks__id__delete",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "title": "Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HookSetHarnessesResultResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Delete Hook"
+ },
+ "get": {
+ "operationId": "get_hook_api_hooks__id__get",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "title": "Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HookInventoryEntryResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Get Hook"
+ }
+ },
+ "/api/hooks/{id}/disable": {
+ "post": {
+ "operationId": "disable_hook_api_hooks__id__disable_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "title": "Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DisableHookRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Disable Hook"
+ }
+ },
+ "/api/hooks/{id}/enable": {
+ "post": {
+ "operationId": "enable_hook_api_hooks__id__enable_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "title": "Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EnableHookRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Enable Hook"
+ }
+ },
+ "/api/hooks/{id}/reconcile": {
+ "post": {
+ "operationId": "reconcile_hook_api_hooks__id__reconcile_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "title": "Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReconcileHookRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HookApplyConfigResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Reconcile Hook"
+ }
+ },
+ "/api/hooks/{id}/set-harnesses": {
+ "post": {
+ "operationId": "set_hook_harnesses_api_hooks__id__set_harnesses_post",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "title": "Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SetHookHarnessesRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HookSetHarnessesResultResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Set Hook Harnesses"
}
},
"/api/marketplace/clis/items/{slug}": {
diff --git a/frontend/src/app/capability-registry/invalidation.ts b/frontend/src/app/capability-registry/invalidation.ts
index 209845e..27e209d 100644
--- a/frontend/src/app/capability-registry/invalidation.ts
+++ b/frontend/src/app/capability-registry/invalidation.ts
@@ -5,6 +5,7 @@ import { invalidateMcpQueries } from "../../features/mcp/public";
import { invalidateSettingsQueries } from "../../features/settings/public";
import { invalidateSkillsQueries } from "../../features/skills/public";
import { invalidateSlashCommandQueries } from "../../features/slash-commands/public";
+import { invalidateHooksQueries } from "../../features/hooks/public";
export async function invalidateCapabilityQueries(queryClient: QueryClient): Promise {
await Promise.all([
@@ -13,5 +14,6 @@ export async function invalidateCapabilityQueries(queryClient: QueryClient): Pro
invalidateSettingsQueries(queryClient),
invalidateMarketplaceQueries(queryClient),
invalidateSlashCommandQueries(queryClient),
+ invalidateHooksQueries(queryClient),
]);
}
diff --git a/frontend/src/app/capability-registry/overview.test.ts b/frontend/src/app/capability-registry/overview.test.ts
index c08d0ce..85017d5 100644
--- a/frontend/src/app/capability-registry/overview.test.ts
+++ b/frontend/src/app/capability-registry/overview.test.ts
@@ -73,9 +73,10 @@ describe("capability overview model", () => {
],
issues: [],
},
+ null,
);
- expect(model.extensions.map((entry) => entry.key)).toEqual(["skills", "slash-commands", "mcp"]);
+ expect(model.extensions.map((entry) => entry.key)).toEqual(["skills", "slash-commands", "mcp", "hooks"]);
expect(model.marketplaceEntries.map((entry) => entry.key)).toEqual(["skills", "mcp", "clis"]);
expect(model.marketplaceEntries.find((entry) => entry.key === "clis")).toMatchObject({
badge: "Preview only",
diff --git a/frontend/src/app/capability-registry/overview.ts b/frontend/src/app/capability-registry/overview.ts
index bbbd895..f358833 100644
--- a/frontend/src/app/capability-registry/overview.ts
+++ b/frontend/src/app/capability-registry/overview.ts
@@ -22,6 +22,12 @@ import {
} from "../../features/slash-commands/public";
import { marketplaceRoutes } from "../../features/marketplace/public";
import { overviewCopy, useOverviewCopy, type OverviewCopy } from "../../features/overview/i18n";
+import {
+ invalidateHooksQueries,
+ hooksRoutes,
+ useHooksInventoryQuery,
+ type HookInventoryDto,
+} from "../../features/hooks/public";
export interface OverviewStatMetric {
value: number | null;
@@ -47,7 +53,7 @@ export interface OverviewExtensionFact {
}
export interface OverviewExtensionKind {
- key: "skills" | "slash-commands" | "mcp";
+ key: "skills" | "slash-commands" | "mcp" | "hooks";
label: string;
iconKey: "skills" | "slash-commands" | "mcp";
facts: OverviewExtensionFact[];
@@ -104,12 +110,19 @@ export function useOverviewData() {
const skillsQuery = useSkillsListQuery();
const slashCommandsQuery = useSlashCommandsQuery();
const mcpQuery = useMcpInventoryQuery();
- const model = useOverviewModel(skillsQuery.data, slashCommandsQuery.data, mcpQuery.data);
+ const hooksQuery = useHooksInventoryQuery();
+ const model = useOverviewModel(
+ skillsQuery.data,
+ slashCommandsQuery.data,
+ mcpQuery.data,
+ hooksQuery.data,
+ );
return {
skillsQuery,
slashCommandsQuery,
mcpQuery,
+ hooksQuery,
model,
};
}
@@ -119,6 +132,7 @@ export async function invalidateOverviewData(queryClient: QueryClient): Promise<
invalidateSkillsQueries(queryClient),
invalidateSlashCommandQueries(queryClient),
invalidateMcpQueries(queryClient),
+ invalidateHooksQueries(queryClient),
]);
}
@@ -130,31 +144,37 @@ export function useOverviewModel(
skills: SkillsWorkspaceData | null | undefined,
slashCommands: SlashCommandListDto | null | undefined,
mcp: McpInventoryDto | null | undefined,
+ hooks: HookInventoryDto | null | undefined,
): OverviewModel {
const copy = useOverviewCopy();
- return useMemo(() => buildOverviewModel(skills, slashCommands, mcp, copy), [skills, slashCommands, mcp, copy]);
+ return useMemo(
+ () => buildOverviewModel(skills, slashCommands, mcp, hooks, copy),
+ [skills, slashCommands, mcp, hooks, copy],
+ );
}
export function buildOverviewModel(
skills: SkillsWorkspaceData | null | undefined,
slashCommands: SlashCommandListDto | null | undefined,
mcp: McpInventoryDto | null | undefined,
+ hooks: HookInventoryDto | null | undefined,
copy: OverviewCopy = overviewCopy.en,
): OverviewModel {
const inUseSkills = skills?.summary.managed ?? null;
const skillsToReview = skills?.summary.unmanaged ?? null;
const inUseSlashCommands = slashCommands?.commands?.length ?? null;
const slashCommandsToReview = slashCommands?.reviewCommands?.length ?? null;
- const inUseMcpServers = mcp?.entries.filter((entry) => entry.kind === "managed").length ?? null;
- const mcpConfigsToReview = mcp?.entries.filter((entry) => entry.kind === "unmanaged").length ?? null;
+ const inUseMcpServers = mcp?.entries?.filter((entry) => entry.kind === "managed").length ?? null;
+ const mcpConfigsToReview = mcp?.entries?.filter((entry) => entry.kind === "unmanaged").length ?? null;
+ const inUseHooks = hooks?.entries?.filter((entry) => entry.kind === "managed").length ?? null;
const differentConfigMcpServers =
- mcp?.entries.filter(
+ mcp?.entries?.filter(
(entry) =>
entry.kind === "managed" &&
entry.sightings.some((sighting) => sighting.state === "drifted"),
).length ?? null;
const inventoryIssues = mcp?.issues?.length ?? null;
- const unavailableHarnesses = mcp?.columns.filter((column) => column.mcpWritable === false).length ?? null;
+ const unavailableHarnesses = mcp?.columns?.filter((column) => column.mcpWritable === false).length ?? null;
const reviewItems = buildReviewItems({
skillsToReview,
slashCommandsToReview,
@@ -165,13 +185,14 @@ export function buildOverviewModel(
copy,
});
const harnessRows = buildHarnessRows(skills, mcp);
- const hasOverviewData = Boolean(skills || slashCommands || mcp);
+ const hasOverviewData = Boolean(skills || slashCommands || mcp || hooks);
return {
stats: buildStats({
inUseSkills,
inUseSlashCommands,
inUseMcpServers,
+ inUseHooks,
needsReview: hasOverviewData ? reviewItems.reduce((total, item) => total + item.count, 0) : null,
harnesses: hasOverviewData ? harnessRows.length : null,
copy,
@@ -186,6 +207,7 @@ export function buildOverviewModel(
differentConfigMcpServers,
inventoryIssues,
unavailableHarnesses,
+ inUseHooks,
copy,
}),
marketplaceEntries: buildMarketplaceEntries(copy),
@@ -198,6 +220,7 @@ function buildStats({
inUseSkills,
inUseSlashCommands,
inUseMcpServers,
+ inUseHooks,
needsReview,
harnesses,
copy,
@@ -205,13 +228,14 @@ function buildStats({
inUseSkills: number | null;
inUseSlashCommands: number | null;
inUseMcpServers: number | null;
+ inUseHooks: number | null;
needsReview: number | null;
harnesses: number | null;
copy: OverviewCopy;
}): OverviewStats {
return {
inUse: {
- value: sumKnown(inUseSkills, inUseSlashCommands, inUseMcpServers),
+ value: sumKnown(inUseSkills, inUseSlashCommands, inUseMcpServers, inUseHooks),
detail: copy.stats.inUseDetail(inUseSkills, inUseSlashCommands, inUseMcpServers),
},
needsReview: {
@@ -243,6 +267,7 @@ function buildExtensions({
differentConfigMcpServers,
inventoryIssues,
unavailableHarnesses,
+ inUseHooks,
copy,
}: {
inUseSkills: number | null;
@@ -254,6 +279,7 @@ function buildExtensions({
differentConfigMcpServers: number | null;
inventoryIssues: number | null;
unavailableHarnesses: number | null;
+ inUseHooks: number | null;
copy: OverviewCopy;
}): OverviewExtensionKind[] {
return [
@@ -305,6 +331,17 @@ function buildExtensions({
{ label: copy.stats.needsReview, to: mcpRoutes.needsReview },
],
},
+ {
+ key: "hooks",
+ label: "Hooks",
+ iconKey: "mcp",
+ facts: [
+ { label: copy.extensions.inUseFact, value: inUseHooks },
+ ],
+ actions: [
+ { label: copy.stats.inUse, to: hooksRoutes.inUse, primary: true },
+ ],
+ },
];
}
diff --git a/frontend/src/app/capability-registry/sidebar.ts b/frontend/src/app/capability-registry/sidebar.ts
index f92baab..7691b23 100644
--- a/frontend/src/app/capability-registry/sidebar.ts
+++ b/frontend/src/app/capability-registry/sidebar.ts
@@ -5,9 +5,10 @@ import { useSkillsCopy } from "../../features/skills/i18n";
import { skillsRoutes, useSkillsListQuery } from "../../features/skills/public";
import { slashCommandRoutes, useSlashCommandsQuery } from "../../features/slash-commands/public";
import { marketplaceRoutes } from "../../features/marketplace/public";
+import { hooksRoutes, useHooksInventoryQuery } from "../../features/hooks/public";
import { useCommonCopy } from "../../i18n";
-export type SidebarIconKey = "overview" | "skills" | "slash-commands" | "mcp" | "marketplace";
+export type SidebarIconKey = "overview" | "skills" | "slash-commands" | "mcp" | "marketplace" | "hooks";
export interface SidebarLinkModel {
key: string;
@@ -41,6 +42,8 @@ export function useSidebarModel(): SidebarModel {
const slashCommandCount = slashCommandsQuery.data?.commands.length ?? null;
const slashCommandReviewCount = slashCommandsQuery.data?.reviewCommands.length ?? null;
const mcpCounts = mcpSidebarCounts(mcpQuery.data);
+ const hooksQuery = useHooksInventoryQuery();
+ const hooksCounts = hooksSidebarCounts(hooksQuery.data);
return useMemo(
() => ({
@@ -103,6 +106,21 @@ export function useSidebarModel(): SidebarModel {
},
],
},
+ {
+ key: "hooks",
+ label: "Hooks",
+ iconKey: "mcp",
+ count: hooksCounts.total,
+ links: [
+ { key: "hooks-use", to: hooksRoutes.inUse, label: common.productLanguage.inUse, count: hooksCounts.inUse },
+ {
+ key: "hooks-review",
+ to: hooksRoutes.needsReview,
+ label: common.productLanguage.needsReview,
+ count: hooksCounts.needsReview,
+ },
+ ],
+ },
{
key: "marketplace",
label: common.nav.marketplace,
@@ -120,6 +138,9 @@ export function useSidebarModel(): SidebarModel {
mcpCounts.inUse,
mcpCounts.needsReview,
mcpCounts.total,
+ hooksCounts.inUse,
+ hooksCounts.needsReview,
+ hooksCounts.total,
needsReviewSkills,
slashCommandCount,
slashCommandReviewCount,
@@ -145,7 +166,24 @@ function mcpSidebarCounts(inventory: ReturnType["da
needsReview: number | null;
total: number | null;
} {
- if (!inventory) {
+ if (!inventory || !inventory.entries) {
+ return { inUse: null, needsReview: null, total: null };
+ }
+ const inUse = inventory.entries.filter((entry) => entry.kind === "managed").length;
+ const needsReview = inventory.entries.filter((entry) => entry.kind === "unmanaged").length;
+ return {
+ inUse,
+ needsReview,
+ total: sumLoadedCounts(inUse, needsReview),
+ };
+}
+
+function hooksSidebarCounts(inventory: ReturnType["data"]): {
+ inUse: number | null;
+ needsReview: number | null;
+ total: number | null;
+} {
+ if (!inventory || !inventory.entries) {
return { inUse: null, needsReview: null, total: null };
}
const inUse = inventory.entries.filter((entry) => entry.kind === "managed").length;
diff --git a/frontend/src/assets/harness-logos/agy-logo.svg b/frontend/src/assets/harness-logos/agy-logo.svg
new file mode 100644
index 0000000..fe5fd4c
--- /dev/null
+++ b/frontend/src/assets/harness-logos/agy-logo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/harness/harnessPresentation.ts b/frontend/src/components/harness/harnessPresentation.ts
index 7abbca7..6730320 100644
--- a/frontend/src/components/harness/harnessPresentation.ts
+++ b/frontend/src/components/harness/harnessPresentation.ts
@@ -3,8 +3,9 @@ import codexLogo from "../../assets/harness-logos/codex-logo.svg";
import cursorLogo from "../../assets/harness-logos/cursor-logo.svg";
import openclawLogo from "../../assets/harness-logos/openclaw-logo.svg";
import opencodeLogo from "../../assets/harness-logos/opencode-logo.svg";
+import agyLogo from "../../assets/harness-logos/agy-logo.svg";
-export type HarnessLogoKey = "claude" | "codex" | "cursor" | "opencode" | "openclaw";
+export type HarnessLogoKey = "claude" | "codex" | "cursor" | "opencode" | "openclaw" | "agy";
interface HarnessPresentation {
logoSrc: string;
@@ -32,6 +33,10 @@ const HARNESS_LOGO_ASSETS: Record = {
logoSrc: openclawLogo,
variant: "openclaw",
},
+ agy: {
+ logoSrc: agyLogo,
+ variant: "agy",
+ },
};
export function getHarnessPresentation(logoKey: string | null | undefined): HarnessPresentation | null {
@@ -40,3 +45,4 @@ export function getHarnessPresentation(logoKey: string | null | undefined): Harn
}
return HARNESS_LOGO_ASSETS[logoKey as HarnessLogoKey] ?? null;
}
+
diff --git a/frontend/src/features/hooks/api/invalidation.ts b/frontend/src/features/hooks/api/invalidation.ts
new file mode 100644
index 0000000..0fdc4c5
--- /dev/null
+++ b/frontend/src/features/hooks/api/invalidation.ts
@@ -0,0 +1,7 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { hooksManagementKeys } from "./keys";
+
+export async function invalidateHooksQueries(queryClient: QueryClient): Promise {
+ await queryClient.invalidateQueries({ queryKey: hooksManagementKeys.all });
+}
diff --git a/frontend/src/features/hooks/api/keys.ts b/frontend/src/features/hooks/api/keys.ts
new file mode 100644
index 0000000..51c37a1
--- /dev/null
+++ b/frontend/src/features/hooks/api/keys.ts
@@ -0,0 +1,9 @@
+export const HOOKS_STALE_TIME_MS = 30_000;
+export const HOOKS_GC_TIME_MS = 5 * 60_000;
+export const HOOKS_INVENTORY_REFETCH_INTERVAL_MS = 5_000;
+
+export const hooksManagementKeys = {
+ all: ["hooks"] as const,
+ inventory: () => ["hooks", "inventory"] as const,
+ detail: (id: string) => ["hooks", "detail", id] as const,
+};
diff --git a/frontend/src/features/hooks/api/management-client.ts b/frontend/src/features/hooks/api/management-client.ts
new file mode 100644
index 0000000..ff9ab2f
--- /dev/null
+++ b/frontend/src/features/hooks/api/management-client.ts
@@ -0,0 +1,76 @@
+import { deleteJson, fetchJson, postJson } from "../../../api/http";
+
+import type {
+ HookApplyConfigResponseDto,
+ HookInventoryDto,
+ HookInventoryEntryDto,
+ HookMutationResponseDto,
+ HookSetHarnessesResponseDto,
+} from "./management-types";
+
+export async function fetchHooksInventory(): Promise {
+ return fetchJson("/hooks");
+}
+
+export async function enableHook(args: {
+ id: string;
+ harness: string;
+}): Promise<{ ok: boolean }> {
+ return postJson<{ ok: boolean }>(`/hooks/${encodeURIComponent(args.id)}/enable`, {
+ harness: args.harness,
+ });
+}
+
+export async function disableHook(args: {
+ id: string;
+ harness: string;
+}): Promise<{ ok: boolean }> {
+ return postJson<{ ok: boolean }>(`/hooks/${encodeURIComponent(args.id)}/disable`, {
+ harness: args.harness,
+ });
+}
+
+export async function setHookHarnesses(args: {
+ id: string;
+ target: "enabled" | "disabled";
+}): Promise {
+ return postJson(
+ `/hooks/${encodeURIComponent(args.id)}/set-harnesses`,
+ { target: args.target },
+ );
+}
+
+export async function uninstallHook(id: string): Promise {
+ return deleteJson(`/hooks/${encodeURIComponent(id)}`);
+}
+
+export async function fetchHookDetail(id: string): Promise {
+ return fetchJson(`/hooks/${encodeURIComponent(id)}`);
+}
+
+export async function createHook(body: {
+ id: string;
+ event: string;
+ command: string;
+ match?: string | null;
+ timeout?: number | null;
+ description?: string;
+}): Promise {
+ return postJson("/hooks", body);
+}
+
+export async function reconcileHook(args: {
+ id: string;
+ sourceKind: "managed" | "harness";
+ observedHarness?: string | null;
+ harnesses?: string[];
+}): Promise {
+ return postJson(
+ `/hooks/${encodeURIComponent(args.id)}/reconcile`,
+ {
+ sourceKind: args.sourceKind,
+ observedHarness: args.observedHarness ?? null,
+ harnesses: args.harnesses,
+ },
+ );
+}
diff --git a/frontend/src/features/hooks/api/management-queries.ts b/frontend/src/features/hooks/api/management-queries.ts
new file mode 100644
index 0000000..4b617b4
--- /dev/null
+++ b/frontend/src/features/hooks/api/management-queries.ts
@@ -0,0 +1,88 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+
+import { queryPolicy } from "../../../lib/query";
+import {
+ createHook,
+ disableHook,
+ enableHook,
+ fetchHooksInventory,
+ fetchHookDetail,
+ reconcileHook,
+ setHookHarnesses,
+ uninstallHook,
+} from "./management-client";
+import { invalidateHooksQueries } from "./invalidation";
+import { HOOKS_GC_TIME_MS, HOOKS_INVENTORY_REFETCH_INTERVAL_MS, HOOKS_STALE_TIME_MS, hooksManagementKeys } from "./keys";
+
+export { invalidateHooksQueries } from "./invalidation";
+export { hooksManagementKeys } from "./keys";
+
+export function useHooksInventoryQuery() {
+ return useQuery({
+ queryKey: hooksManagementKeys.inventory(),
+ queryFn: fetchHooksInventory,
+ refetchInterval: HOOKS_INVENTORY_REFETCH_INTERVAL_MS,
+ ...queryPolicy(HOOKS_STALE_TIME_MS, HOOKS_GC_TIME_MS),
+ });
+}
+
+export function useEnableHookMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: enableHook,
+ onSettled: () => invalidateHooksQueries(queryClient),
+ });
+}
+
+export function useDisableHookMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: disableHook,
+ onSettled: () => invalidateHooksQueries(queryClient),
+ });
+}
+
+export function useSetHookHarnessesMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: setHookHarnesses,
+ onSettled: () => invalidateHooksQueries(queryClient),
+ });
+}
+
+export function useUninstallHookMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: uninstallHook,
+ onSettled: () => invalidateHooksQueries(queryClient),
+ });
+}
+
+export function useHookDetailQuery(id: string | null) {
+ return useQuery({
+ queryKey: hooksManagementKeys.detail(id ?? "__none__"),
+ queryFn: () => fetchHookDetail(id!),
+ enabled: Boolean(id),
+ ...queryPolicy(HOOKS_STALE_TIME_MS, HOOKS_GC_TIME_MS),
+ });
+}
+
+export function useCreateHookMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: createHook,
+ onSettled: () => invalidateHooksQueries(queryClient),
+ });
+}
+
+export function useReconcileHookMutation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: reconcileHook,
+ onSettled: () => invalidateHooksQueries(queryClient),
+ });
+}
diff --git a/frontend/src/features/hooks/api/management-types.ts b/frontend/src/features/hooks/api/management-types.ts
new file mode 100644
index 0000000..dcfc140
--- /dev/null
+++ b/frontend/src/features/hooks/api/management-types.ts
@@ -0,0 +1,13 @@
+import type { components } from "../../../api/generated";
+
+export type HookBindingState = components["schemas"]["HookBindingResponse"]["state"];
+
+export type HookInventoryColumnDto = components["schemas"]["HookInventoryColumnResponse"];
+export type HookBindingDto = components["schemas"]["HookBindingResponse"];
+export type HookSpecDto = components["schemas"]["HookSpecResponse"];
+export type HookInventoryEntryDto = components["schemas"]["HookInventoryEntryResponse"];
+export type HookInventoryDto = components["schemas"]["HookInventoryResponse"];
+export type HookMutationFailureDto = components["schemas"]["HookMutationFailureResponse"];
+export type HookSetHarnessesResponseDto = components["schemas"]["HookSetHarnessesResultResponse"];
+export type HookApplyConfigResponseDto = components["schemas"]["HookApplyConfigResponse"];
+export type HookMutationResponseDto = components["schemas"]["HookMutationResponse"];
diff --git a/frontend/src/features/hooks/components/HookCard.tsx b/frontend/src/features/hooks/components/HookCard.tsx
new file mode 100644
index 0000000..b3dc6ce
--- /dev/null
+++ b/frontend/src/features/hooks/components/HookCard.tsx
@@ -0,0 +1,147 @@
+import { useMemo } from "react";
+import { Loader2, Power, Trash2 } from "lucide-react";
+
+import { CardMenu, type CardMenuItem } from "../../../components/cards/CardMenu";
+import { CardSelectCheckbox } from "../../../components/cards/CardSelectCheckbox";
+import { OverflowTooltipText } from "../../../components/ui/OverflowTooltipText";
+import type { HookInventoryColumnDto, HookInventoryEntryDto } from "../../hooks/api/management-types";
+import { useHooksCopy } from "../../hooks/i18n";
+import { isHooksHarnessAddressable } from "../model/selectors";
+import { HooksHarnessLogoStack } from "./HooksHarnessLogoStack";
+import { HooksStatusChip } from "./HooksStatusChip";
+
+interface HookCardProps {
+ entry: HookInventoryEntryDto;
+ columns: HookInventoryColumnDto[];
+ pending: boolean;
+ checked: boolean;
+ onOpenDetail: (id: string) => void;
+ onToggleChecked: (id: string) => void;
+ onSetHarnesses: (id: string, target: "enabled" | "disabled") => void;
+ onRequestUninstall: (id: string) => void;
+}
+
+function managedCount(
+ entry: HookInventoryEntryDto,
+ addressable: ReadonlySet,
+): number {
+ return entry.sightings.filter(
+ (b) => addressable.has(b.harness) && b.state === "managed",
+ ).length;
+}
+
+function hasDifferentConfig(
+ entry: HookInventoryEntryDto,
+ addressable: ReadonlySet,
+): boolean {
+ return entry.sightings.some(
+ (b) => addressable.has(b.harness) && b.state === "drifted",
+ );
+}
+
+export function HookCard({
+ entry,
+ columns,
+ pending,
+ checked,
+ onOpenDetail,
+ onToggleChecked,
+ onSetHarnesses,
+ onRequestUninstall,
+}: HookCardProps) {
+ const copy = useHooksCopy();
+ const addressableHarnesses = useMemo(
+ () => new Set(columns.filter(isHooksHarnessAddressable).map((c) => c.harness)),
+ [columns],
+ );
+ const enabled = managedCount(entry, addressableHarnesses);
+ const total = addressableHarnesses.size;
+ const differentConfig = hasDifferentConfig(entry, addressableHarnesses);
+ const allEnabled = total > 0 && enabled === total;
+ const target: "enabled" | "disabled" = allEnabled ? "disabled" : "enabled";
+
+ const menuItems = useMemo(
+ () => [
+ {
+ key: "uninstall",
+ label: copy.detail.uninstall,
+ icon: ,
+ destructive: true,
+ onSelect: () => onRequestUninstall(entry.id),
+ },
+ ],
+ [copy.detail.uninstall, entry.id, onRequestUninstall],
+ );
+
+ return (
+ onOpenDetail(entry.id)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onOpenDetail(entry.id);
+ }
+ }}
+ aria-label={copy.detail.openDetail(entry.displayName)}
+ >
+
+
+ {entry.displayName}
+
+ {entry.spec &&
}
+
+
+ onToggleChecked(entry.id)}
+ label={checked ? copy.detail.deselect(entry.displayName) : copy.detail.select(entry.displayName)}
+ disabled={pending}
+ />
+
+
+
+
+ {entry.spec?.command ?? "—"}
+
+
+ {entry.spec?.description ? (
+
+ {entry.spec.description}
+
+ ) : null}
+
+
+
+
{
+ event.stopPropagation();
+ if (differentConfig) {
+ onOpenDetail(entry.id);
+ return;
+ }
+ onSetHarnesses(entry.id, target);
+ }}
+ >
+ {pending ? (
+
+ ) : (
+
+ )}
+ {differentConfig ? copy.detail.resolveConfig : target === "enabled" ? "Enable all" : "Disable everywhere"}
+
+
+
+ );
+}
diff --git a/frontend/src/features/hooks/components/HookCardList.tsx b/frontend/src/features/hooks/components/HookCardList.tsx
new file mode 100644
index 0000000..7a7b62a
--- /dev/null
+++ b/frontend/src/features/hooks/components/HookCardList.tsx
@@ -0,0 +1,44 @@
+import type { HookInventoryColumnDto, HookInventoryEntryDto } from "../api/management-types";
+import { HookCard } from "./HookCard";
+
+interface HookCardListProps {
+ entries: HookInventoryEntryDto[];
+ columns: HookInventoryColumnDto[];
+ pendingHookKeys: ReadonlySet;
+ checkedIds: ReadonlySet;
+ onOpenDetail: (id: string) => void;
+ onToggleChecked: (id: string) => void;
+ onSetHarnesses: (id: string, target: "enabled" | "disabled") => void;
+ onRequestUninstall: (id: string) => void;
+ ariaLabel?: string;
+}
+
+export function HookCardList({
+ entries,
+ columns,
+ pendingHookKeys,
+ checkedIds,
+ onOpenDetail,
+ onToggleChecked,
+ onSetHarnesses,
+ onRequestUninstall,
+ ariaLabel,
+}: HookCardListProps) {
+ return (
+
+ {entries.map((entry) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/features/hooks/components/HooksFilterMenu.tsx b/frontend/src/features/hooks/components/HooksFilterMenu.tsx
new file mode 100644
index 0000000..ae26f59
--- /dev/null
+++ b/frontend/src/features/hooks/components/HooksFilterMenu.tsx
@@ -0,0 +1,38 @@
+import { SelectionMenu } from "../../../components/ui/SelectionMenu";
+import type { InUsePillValue } from "../model/selectors";
+import { useHooksCopy } from "../i18n";
+
+const OPTIONS: InUsePillValue[] = ["all", "enabled", "all-harnesses", "unbound", "drifted"];
+
+interface HooksFilterMenuProps {
+ pill: InUsePillValue;
+ counts: Record;
+ onChange: (next: InUsePillValue) => void;
+}
+
+export function HooksFilterMenu({ pill, counts, onChange }: HooksFilterMenuProps) {
+ const copy = useHooksCopy();
+ const options = OPTIONS.map((value) => ({
+ value,
+ label: pillLabel(copy, value),
+ meta: counts[value],
+ }));
+
+ return (
+
+ );
+}
+
+function pillLabel(copy: ReturnType, value: InUsePillValue): string {
+ if (value === "all") return copy.inUse.filters.all;
+ if (value === "enabled") return copy.inUse.filters.enabled;
+ if (value === "all-harnesses") return copy.inUse.filters.allHarnesses;
+ if (value === "unbound") return copy.inUse.filters.unbound;
+ return copy.inUse.filters.drifted;
+}
diff --git a/frontend/src/features/hooks/components/HooksHarnessLogoStack.tsx b/frontend/src/features/hooks/components/HooksHarnessLogoStack.tsx
new file mode 100644
index 0000000..a3b34c3
--- /dev/null
+++ b/frontend/src/features/hooks/components/HooksHarnessLogoStack.tsx
@@ -0,0 +1,57 @@
+import { UiTooltip } from "../../../components/ui/UiTooltip";
+import { getHarnessPresentation } from "../../../components/harness/harnessPresentation";
+import type { HookBindingDto, HookInventoryColumnDto } from "../../hooks/api/management-types";
+import { isHooksHarnessAddressable } from "../model/selectors";
+
+interface HooksHarnessLogoStackProps {
+ bindings: HookBindingDto[];
+ columns: HookInventoryColumnDto[];
+}
+
+export function HooksHarnessLogoStack({ bindings, columns }: HooksHarnessLogoStackProps) {
+ const labelByHarness = new Map(columns.map((c) => [c.harness, c.label]));
+ const logoByHarness = new Map(columns.map((c) => [c.harness, c.logoKey ?? c.harness]));
+ const addressable = new Set(columns.filter(isHooksHarnessAddressable).map((c) => c.harness));
+
+ const visible = bindings.filter(
+ (b) => addressable.has(b.harness) && (b.state === "managed" || b.state === "drifted"),
+ );
+ const managedCount = bindings.filter(
+ (b) => addressable.has(b.harness) && b.state === "managed",
+ ).length;
+ const totalCount = addressable.size;
+ const ariaLabel = `Bound to ${managedCount} of ${totalCount} harnesses`;
+
+ return (
+
+
+ {visible.map((binding, index) => {
+ const presentation = getHarnessPresentation(logoByHarness.get(binding.harness) ?? null);
+ const label = labelByHarness.get(binding.harness) ?? binding.harness;
+ const title =
+ binding.state === "drifted"
+ ? `${label} — Different config${binding.driftDetail ? ` (${binding.driftDetail})` : ""}`
+ : label;
+ return (
+
+
+ {presentation ? (
+
+ ) : (
+ {label.slice(0, 1)}
+ )}
+
+
+ );
+ })}
+
+
+ {managedCount}/{totalCount}
+
+
+ );
+}
diff --git a/frontend/src/features/hooks/components/HooksMatrixView.tsx b/frontend/src/features/hooks/components/HooksMatrixView.tsx
new file mode 100644
index 0000000..fd56f6b
--- /dev/null
+++ b/frontend/src/features/hooks/components/HooksMatrixView.tsx
@@ -0,0 +1,243 @@
+import { AlertTriangle } from "lucide-react";
+
+import { CardSelectCheckbox } from "../../../components/cards/CardSelectCheckbox";
+import {
+ MatrixHarnessCellTarget,
+ MatrixHarnessHeader,
+ MatrixHarnessIcon,
+ MatrixTable,
+} from "../../../components/matrix";
+import { UiTooltip } from "../../../components/ui/UiTooltip";
+import type { HookInventoryColumnDto, HookInventoryEntryDto } from "../api/management-types";
+import { useHooksCopy, type HooksCopy } from "../i18n";
+import {
+ matrixCellFor,
+ matrixColumns,
+ matrixCoverage,
+ type HooksMatrixCellModel,
+} from "../model/selectors";
+import { HooksHarnessLogoStack } from "./HooksHarnessLogoStack";
+
+interface HooksMatrixViewProps {
+ entries: HookInventoryEntryDto[];
+ columns: HookInventoryColumnDto[];
+ pendingHookKeys: ReadonlySet;
+ pendingPerHarnessKeys: ReadonlySet;
+ checkedIds: ReadonlySet;
+ onOpenDetail: (id: string) => void;
+ onToggleChecked: (id: string) => void;
+ onEnableHarness: (id: string, harness: string) => void;
+ onDisableHarness: (id: string, harness: string) => void;
+}
+
+export function HooksMatrixView({
+ entries,
+ columns,
+ pendingHookKeys,
+ pendingPerHarnessKeys,
+ checkedIds,
+ onOpenDetail,
+ onToggleChecked,
+ onEnableHarness,
+ onDisableHarness,
+}: HooksMatrixViewProps) {
+ const copy = useHooksCopy();
+ const displayColumns = matrixColumns({ columns });
+
+ return (
+
+
+
+
+ Hook ID
+ {displayColumns.map((column) => (
+
+ ))}
+
+ Harnesses
+
+ Enabled
+
+
+
+ {entries.map((entry) => (
+
+ ))}
+
+
+ );
+}
+
+function HooksMatrixRow({
+ entry,
+ columns,
+ pendingHook,
+ pendingPerHarnessKeys,
+ checked,
+ onOpenDetail,
+ onToggleChecked,
+ onEnableHarness,
+ onDisableHarness,
+ copy,
+}: {
+ entry: HookInventoryEntryDto;
+ columns: HookInventoryColumnDto[];
+ pendingHook: boolean;
+ pendingPerHarnessKeys: ReadonlySet;
+ checked: boolean;
+ onOpenDetail: (id: string) => void;
+ onToggleChecked: (id: string) => void;
+ onEnableHarness: (id: string, harness: string) => void;
+ onDisableHarness: (id: string, harness: string) => void;
+ copy: HooksCopy;
+}) {
+ const coverage = matrixCoverage(entry, columns);
+
+ return (
+
+
+ onToggleChecked(entry.id)}
+ disabled={pendingHook}
+ />
+
+
+ onOpenDetail(entry.id)}
+ >
+
+ {entry.displayName}
+
+
+ {entry.spec?.command ?? "—"} · {entry.spec?.event ?? "—"}
+
+
+
+ {columns.map((column) => {
+ const cell = matrixCellFor(entry, column, copy);
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+
+ {coverage.enabled}
+
+ {" / "}
+ {coverage.writable}
+
+
+
+
+ );
+}
+
+function HooksMatrixHarnessCell({
+ entry,
+ column,
+ cell,
+ pending,
+ onOpenDetail,
+ onEnableHarness,
+ onDisableHarness,
+}: {
+ entry: HookInventoryEntryDto;
+ column: HookInventoryColumnDto;
+ cell: HooksMatrixCellModel;
+ pending: boolean;
+ onOpenDetail: (id: string) => void;
+ onEnableHarness: (id: string, harness: string) => void;
+ onDisableHarness: (id: string, harness: string) => void;
+}) {
+ const content = cellContent(column, cell);
+ const disabled = pending || cell.action === null;
+
+ const control = cell.action === null ? (
+
+ {content}
+
+ ) : (
+ {
+ if (cell.action === "enable") {
+ onEnableHarness(entry.id, column.harness);
+ } else if (cell.action === "disable") {
+ onDisableHarness(entry.id, column.harness);
+ } else {
+ onOpenDetail(entry.id);
+ }
+ }}
+ >
+ {content}
+
+ );
+
+ return {control} ;
+}
+
+function cellContent(column: HookInventoryColumnDto, cell: HooksMatrixCellModel) {
+ if (cell.state === "unavailable") {
+ return ;
+ }
+ return (
+
+ );
+}
diff --git a/frontend/src/features/hooks/components/HooksStatusChip.tsx b/frontend/src/features/hooks/components/HooksStatusChip.tsx
new file mode 100644
index 0000000..18a5baf
--- /dev/null
+++ b/frontend/src/features/hooks/components/HooksStatusChip.tsx
@@ -0,0 +1,24 @@
+interface HooksStatusChipProps {
+ event: string;
+}
+
+export function HooksStatusChip({ event }: HooksStatusChipProps) {
+ return (
+
+ {event}
+
+ );
+}
diff --git a/frontend/src/features/hooks/components/detail/HookDetailSheet.tsx b/frontend/src/features/hooks/components/detail/HookDetailSheet.tsx
new file mode 100644
index 0000000..52a7ca6
--- /dev/null
+++ b/frontend/src/features/hooks/components/detail/HookDetailSheet.tsx
@@ -0,0 +1,242 @@
+import * as Dialog from "@radix-ui/react-dialog";
+import { Loader2, Trash2, X } from "lucide-react";
+import { type ReactNode, useId, useState } from "react";
+
+import { DetailBindingIdentity } from "../../../../components/detail/DetailBindingIdentity";
+import { DetailHeader } from "../../../../components/detail/DetailHeader";
+import { DetailSection } from "../../../../components/detail/DetailSection";
+import { ErrorBanner } from "../../../../components/ErrorBanner";
+import { LoadingSpinner } from "../../../../components/LoadingSpinner";
+import { useHookDetailQuery } from "../../api/management-queries";
+import { useHooksCopy } from "../../i18n";
+import { isHooksHarnessAddressable } from "../../model/selectors";
+
+interface HookDetailSheetProps {
+ id: string | null;
+ columns: any[];
+ pendingPerHarness: ReadonlySet;
+ isServerPending: boolean;
+ isUninstalling: boolean;
+ onClose: () => void;
+ onEnableHarness: (harness: string) => void;
+ onDisableHarness: (harness: string) => void;
+ onResolveConfig: (args: {
+ sourceKind: "managed" | "harness";
+ observedHarness?: string | null;
+ harnesses?: string[];
+ }) => Promise;
+ onUninstall: () => void;
+}
+
+export function HookDetailSheet({
+ id,
+ columns,
+ pendingPerHarness,
+ isServerPending,
+ isUninstalling,
+ onClose,
+ onEnableHarness,
+ onDisableHarness,
+ onResolveConfig,
+ onUninstall,
+}: HookDetailSheetProps) {
+ const headingId = useId();
+ const copy = useHooksCopy();
+ const detailQuery = useHookDetailQuery(id);
+ const [resolvePending, setResolvePending] = useState(false);
+ const [resolveError, setResolveError] = useState("");
+
+ if (!id) return null;
+
+ const detail = detailQuery.data ?? null;
+ const spec = detail?.spec ?? null;
+ const displayName = detail?.displayName ?? id;
+ const errorMessage = detailQuery.error instanceof Error ? detailQuery.error.message : "";
+
+ async function handleResolve(sourceKind: "managed" | "harness", observedHarness: string): Promise {
+ setResolveError("");
+ setResolvePending(true);
+ try {
+ await onResolveConfig({
+ sourceKind,
+ observedHarness,
+ harnesses: [observedHarness],
+ });
+ } catch (err) {
+ setResolveError(err instanceof Error ? err.message : "Reconciliation failed");
+ } finally {
+ setResolvePending(false);
+ }
+ }
+
+ return (
+ {
+ if (!open) onClose();
+ }}
+ >
+
+
+
+
+ Hook detail panel showing hook specs and enabled status across harnesses.
+
+
+ {displayName}}
+ closeLabel={copy.detail.close}
+ onClose={onClose}
+ />
+
+ {errorMessage ? : null}
+ {resolveError ? setResolveError("")} /> : null}
+
+ {detailQuery.isPending ? (
+
+
+
+ ) : detail ? (
+
+
+
+ {spec?.description ? (
+
+
Description
+ {spec.description}
+
+ ) : null}
+
+
{copy.detail.event}
+ {spec?.event}
+
+ {spec?.match ? (
+
+
{copy.detail.match}
+ {spec.match}
+
+ ) : null}
+ {spec?.timeout ? (
+
+
{copy.detail.timeout}
+ {spec.timeout}s
+
+ ) : null}
+
+
{copy.detail.command}
+
+ {spec?.command}
+
+
+
+
+
+
+
+ {columns.map((column) => {
+ const binding = detail.sightings.find((s) => s.harness === column.harness);
+ const state = binding?.state ?? "missing";
+ const pending = pendingPerHarness.has(column.harness) || isServerPending;
+ const canWriteConfig = isHooksHarnessAddressable(column);
+
+ const stateLabel = () => {
+ if (state === "managed") return "Enabled";
+ if (state === "drifted") return "Different config";
+ if (state === "unmanaged") return "Found in harness";
+ return "Disabled";
+ };
+
+ const stateTone = () => {
+ if (state === "managed") return "enabled";
+ if (state === "drifted" || state === "unmanaged") return "warning";
+ return "disabled";
+ };
+
+ return (
+
+
+
+ {state === "missing" ? (
+
{
+ if (detail.canEnable && canWriteConfig) onEnableHarness(column.harness);
+ }}
+ disabled={pending || !detail.canEnable || !canWriteConfig}
+ >
+ {pending ? : null}
+ {detail.canEnable && canWriteConfig ? "Enable" : "Unavailable"}
+
+ ) : null}
+
+ {state === "managed" ? (
+
onDisableHarness(column.harness)}
+ disabled={pending}
+ >
+ {pending ? : null}
+ Disable
+
+ ) : null}
+
+ {state === "drifted" ? (
+
+ handleResolve("managed", column.harness)}
+ disabled={pending || resolvePending}
+ >
+ Use central spec
+
+ handleResolve("harness", column.harness)}
+ disabled={pending || resolvePending}
+ >
+ Adopt harness spec
+
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+
+
+
+ {copy.detail.uninstall}
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/frontend/src/features/hooks/components/edit/HookFormDialog.tsx b/frontend/src/features/hooks/components/edit/HookFormDialog.tsx
new file mode 100644
index 0000000..2465702
--- /dev/null
+++ b/frontend/src/features/hooks/components/edit/HookFormDialog.tsx
@@ -0,0 +1,210 @@
+import { useEffect, useState } from "react";
+import * as Dialog from "@radix-ui/react-dialog";
+import { Loader2, X } from "lucide-react";
+
+const SUPPORTED_EVENTS = [
+ "pre_tool_use",
+ "post_tool_use",
+ "user_prompt_submit",
+ "session_start",
+ "stop",
+ "pre_compact",
+];
+
+interface HookFormValue {
+ id: string;
+ event: string;
+ command: string;
+ match: string | null;
+ timeout: number | null;
+ description: string;
+}
+
+interface HookFormDialogProps {
+ open: boolean;
+ pending: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (value: HookFormValue) => Promise | void;
+}
+
+export function HookFormDialog({
+ open,
+ pending,
+ onOpenChange,
+ onSubmit,
+}: HookFormDialogProps) {
+ const [id, setId] = useState("");
+ const [event, setEvent] = useState("pre_tool_use");
+ const [command, setCommand] = useState("");
+ const [match, setMatch] = useState("any");
+ const [timeoutStr, setTimeoutStr] = useState("");
+ const [description, setDescription] = useState("");
+
+ useEffect(() => {
+ if (!open) return;
+ setId("");
+ setEvent("pre_tool_use");
+ setCommand("");
+ setMatch("any");
+ setTimeoutStr("");
+ setDescription("");
+ }, [open]);
+
+ const canSubmit = id.trim() && event.trim() && command.trim();
+ const showMatchSelector = event === "pre_tool_use" || event === "post_tool_use";
+
+ async function handleSubmit(e: React.FormEvent): Promise {
+ e.preventDefault();
+ if (!canSubmit) return;
+ const timeoutVal = timeoutStr.trim() ? parseInt(timeoutStr, 10) : null;
+ await onSubmit({
+ id: id.trim(),
+ event,
+ command: command.trim(),
+ match: showMatchSelector ? match : null,
+ timeout: isNaN(Number(timeoutVal)) ? null : timeoutVal,
+ description: description.trim(),
+ });
+ }
+
+ return (
+
+
+
+
+
+
+ Add Hook
+
+
+
+
+
+
+
+ Add a new hook configuration that will run a command on the specified lifecycle event.
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/hooks/i18n.ts b/frontend/src/features/hooks/i18n.ts
new file mode 100644
index 0000000..394159b
--- /dev/null
+++ b/frontend/src/features/hooks/i18n.ts
@@ -0,0 +1,85 @@
+import { useLocalizedCopy, type CopyShape, type LocalizedCopy } from "../../i18n";
+
+const englishHooksCopy = {
+ inUse: {
+ title: "Hooks in use",
+ subtitle: "Browse, enable, and remove hooks across your harnesses.",
+ viewModeAria: "Hooks in use view mode",
+ searchPlaceholder: "Search by ID, event or command...",
+ searchLabel: "Search hooks",
+ loading: "Loading hooks",
+ unableToLoad: "Unable to load hooks.",
+ noMatchesBody: "Adjust the search or filter to see other hooks.",
+ emptyTitle: "No hooks in use yet",
+ emptyBody: "Add a hook or enable one to see it here.",
+ viewModes: {
+ cards: "Cards",
+ matrix: "Matrix",
+ },
+ filters: {
+ all: "All",
+ enabled: "Enabled",
+ allHarnesses: "Enabled on all",
+ unbound: "Unbound",
+ drifted: "Different config",
+ aria: (label: string) => `Filter: ${label}`,
+ },
+ uninstall: {
+ action: "Delete",
+ title: (id: string) => `Delete ${id}?`,
+ description: "Remove this hook from skill-manager and disable it on all harnesses.",
+ singleDescription: "Remove this hook from skill-manager and disable it on all harnesses.",
+ pending: "Deleting",
+ fallbackName: "this hook",
+ },
+ },
+ detail: {
+ close: "Close details",
+ closeShort: "Close",
+ loading: "Loading...",
+ unableTitle: "Unable to load hook details",
+ about: "About",
+ differentConfigsTitle: "Different configs found",
+ differentConfigsBody: "Choose which config Skill Manager should manage, then apply it to current bindings.",
+ resolveConfig: "Resolve config",
+ bindings: "Bindings",
+ uninstall: "Delete",
+ skillManagerConfig: "Skill Manager config",
+ event: "Event",
+ command: "Command",
+ match: "Tool Category",
+ timeout: "Timeout (seconds)",
+ openDetail: (id: string) => `Open detail for ${id}`,
+ moreActions: (id: string) => `More actions for ${id}`,
+ select: (id: string) => `Select ${id}`,
+ deselect: (id: string) => `Deselect ${id}`,
+ enabledStatus: {
+ enabled: "Enabled",
+ disabled: "Disabled",
+ },
+ enabledStatusAria: (label: string) => `Status: ${label}`,
+ matrix: {
+ baseLabel: (id: string, harness: string) => `${id} on ${harness}`,
+ enabledTooltip: (harness: string) => `Enabled on ${harness}`,
+ disabledTooltip: (harness: string) => `Disabled on ${harness}`,
+ differentTooltip: (harness: string, detail: string) => `Different configuration on ${harness}${detail}`,
+ foundTooltip: (harness: string) => `Configured outside skill-manager on ${harness}`,
+ disable: (label: string) => `Disable ${label}`,
+ enable: (label: string) => `Enable ${label}`,
+ resolveConfigFor: (label: string) => `Resolve config for ${label}`,
+ openDetailFor: (label: string) => `Open details for ${label}`,
+ unavailable: (label: string) => `Unavailable for ${label}`,
+ },
+ },
+};
+
+export type HooksCopy = typeof englishHooksCopy;
+
+export const hooksCopy = {
+ en: englishHooksCopy,
+ "zh-CN": englishHooksCopy,
+};
+
+export function useHooksCopy(): HooksCopy {
+ return useLocalizedCopy(hooksCopy);
+}
diff --git a/frontend/src/features/hooks/model/selectors.test.ts b/frontend/src/features/hooks/model/selectors.test.ts
new file mode 100644
index 0000000..cece5ce
--- /dev/null
+++ b/frontend/src/features/hooks/model/selectors.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import type {
+ HookInventoryEntryDto,
+ HookInventoryColumnDto,
+} from "../api/management-types";
+import { matrixCellFor } from "./selectors";
+
+describe("hooks selectors", () => {
+ const column: HookInventoryColumnDto = {
+ harness: "antigravity-hooks",
+ label: "Antigravity",
+ installed: true,
+ configPresent: true,
+ hooksWritable: true,
+ };
+
+ it("appends caveat to tooltip for enabled hooks when caveat exists", () => {
+ const entry: HookInventoryEntryDto = {
+ id: "my-hook",
+ displayName: "My Hook",
+ kind: "managed",
+ canEnable: true,
+ enabledStatus: "enabled",
+ sightings: [
+ {
+ harness: "antigravity-hooks",
+ state: "managed",
+ caveat: "On Antigravity this maps to PreInvocation, which fires before every model invocation, not only on user-prompt submit.",
+ },
+ ],
+ };
+
+ const cell = matrixCellFor(entry, column);
+ expect(cell.state).toBe("enabled");
+ expect(cell.tooltip).toBe(
+ "Enabled on Antigravity (Caveat: On Antigravity this maps to PreInvocation, which fires before every model invocation, not only on user-prompt submit.)"
+ );
+ });
+
+ it("appends caveat to tooltip for disabled/missing hooks when caveat exists", () => {
+ const entry: HookInventoryEntryDto = {
+ id: "my-hook",
+ displayName: "My Hook",
+ kind: "managed",
+ canEnable: true,
+ enabledStatus: "disabled",
+ sightings: [
+ {
+ harness: "antigravity-hooks",
+ state: "missing",
+ caveat: "On Antigravity this maps to PreInvocation, which fires before every model invocation, not only on user-prompt submit.",
+ },
+ ],
+ };
+
+ const cell = matrixCellFor(entry, column);
+ expect(cell.state).toBe("disabled");
+ expect(cell.tooltip).toBe(
+ "Disabled on Antigravity (Caveat: On Antigravity this maps to PreInvocation, which fires before every model invocation, not only on user-prompt submit.)"
+ );
+ });
+
+ it("does not append caveat when caveat is absent", () => {
+ const entry: HookInventoryEntryDto = {
+ id: "my-hook",
+ displayName: "My Hook",
+ kind: "managed",
+ canEnable: true,
+ enabledStatus: "enabled",
+ sightings: [
+ {
+ harness: "antigravity-hooks",
+ state: "managed",
+ },
+ ],
+ };
+
+ const cell = matrixCellFor(entry, column);
+ expect(cell.state).toBe("enabled");
+ expect(cell.tooltip).toBe("Enabled on Antigravity");
+ });
+});
diff --git a/frontend/src/features/hooks/model/selectors.ts b/frontend/src/features/hooks/model/selectors.ts
new file mode 100644
index 0000000..d24b37c
--- /dev/null
+++ b/frontend/src/features/hooks/model/selectors.ts
@@ -0,0 +1,196 @@
+import type {
+ HookBindingDto,
+ HookInventoryColumnDto,
+ HookInventoryDto,
+ HookInventoryEntryDto,
+} from "../api/management-types";
+import { hooksCopy, type HooksCopy } from "../i18n";
+
+export type InUsePillValue = "all" | "enabled" | "all-harnesses" | "unbound" | "drifted";
+
+export interface HooksInUseFilters {
+ search: string;
+ pill: InUsePillValue;
+}
+
+export type HooksMatrixCellState = "enabled" | "disabled" | "different" | "unavailable" | "observed";
+
+export interface HooksMatrixCellModel {
+ state: HooksMatrixCellState;
+ binding: HookBindingDto | null;
+ writable: boolean;
+ pendingKey: string;
+ tooltip: string;
+ ariaLabel: string;
+ action: "enable" | "disable" | "resolve" | "open" | null;
+}
+
+export function isHooksHarnessAddressable(column: HookInventoryColumnDto): boolean {
+ return column.hooksWritable !== false && (column.installed || column.configPresent);
+}
+
+function inUseBindingCount(
+ entry: HookInventoryEntryDto,
+ addressable?: ReadonlySet,
+): number {
+ return entry.sightings.filter(
+ (b) => b.state === "managed" && (!addressable || addressable.has(b.harness)),
+ ).length;
+}
+
+function hasDrift(entry: HookInventoryEntryDto, addressable?: ReadonlySet): boolean {
+ return entry.sightings.some(
+ (b) => b.state === "drifted" && (!addressable || addressable.has(b.harness)),
+ );
+}
+
+function addressableHarnesses(inventory: HookInventoryDto): ReadonlySet {
+ return new Set(inventory.columns.filter(isHooksHarnessAddressable).map((column) => column.harness));
+}
+
+function matchesSearch(entry: HookInventoryEntryDto, query: string): boolean {
+ if (!query) return true;
+ const needle = query.toLowerCase();
+ if (entry.id.toLowerCase().includes(needle)) return true;
+ if (entry.displayName.toLowerCase().includes(needle)) return true;
+ if (entry.spec?.command && entry.spec.command.toLowerCase().includes(needle)) return true;
+ if (entry.spec?.event && entry.spec.event.toLowerCase().includes(needle)) return true;
+ return false;
+}
+
+export function filterHooksInUse(
+ inventory: HookInventoryDto | null,
+ filters: HooksInUseFilters,
+): HookInventoryEntryDto[] {
+ if (!inventory) return [];
+ const addressable = addressableHarnesses(inventory);
+ const harnessCount = addressable.size;
+ return inventory.entries.filter((entry) => {
+ if (entry.kind !== "managed") return false;
+ if (!matchesSearch(entry, filters.search.trim())) return false;
+ const enabledCount = inUseBindingCount(entry, addressable);
+ switch (filters.pill) {
+ case "all":
+ return true;
+ case "enabled":
+ return enabledCount > 0;
+ case "all-harnesses":
+ return harnessCount > 0 && enabledCount === harnessCount;
+ case "unbound":
+ return enabledCount === 0 && !hasDrift(entry, addressable);
+ case "drifted":
+ return hasDrift(entry, addressable);
+ default:
+ return true;
+ }
+ });
+}
+
+export function pillCounts(inventory: HookInventoryDto | null): Record {
+ if (!inventory) {
+ return { all: 0, enabled: 0, "all-harnesses": 0, unbound: 0, drifted: 0 };
+ }
+ const addressable = addressableHarnesses(inventory);
+ const harnessCount = addressable.size;
+ const inUseEntries = inventory.entries.filter((e) => e.kind === "managed");
+ return {
+ all: inUseEntries.length,
+ enabled: inUseEntries.filter((e) => inUseBindingCount(e, addressable) > 0).length,
+ "all-harnesses": inUseEntries.filter(
+ (e) => harnessCount > 0 && inUseBindingCount(e, addressable) === harnessCount,
+ ).length,
+ unbound: inUseEntries.filter(
+ (e) => inUseBindingCount(e, addressable) === 0 && !hasDrift(e, addressable),
+ ).length,
+ drifted: inUseEntries.filter((entry) => hasDrift(entry, addressable)).length,
+ };
+}
+
+export function matrixColumns(inventory: { columns: HookInventoryColumnDto[] } | null): HookInventoryColumnDto[] {
+ return inventory?.columns ?? [];
+}
+
+export function matrixCellFor(
+ entry: HookInventoryEntryDto,
+ column: HookInventoryColumnDto,
+ copy: HooksCopy = hooksCopy.en,
+): HooksMatrixCellModel {
+ const binding = entry.sightings.find((candidate) => candidate.harness === column.harness) ?? null;
+ const writable = isHooksHarnessAddressable(column);
+ const pendingKey = `${entry.id}:${column.harness}`;
+ const baseLabel = `${entry.displayName} on ${column.label}`;
+
+ let cell: HooksMatrixCellModel;
+
+ if (binding?.state === "managed") {
+ cell = {
+ state: "enabled",
+ binding,
+ writable,
+ pendingKey,
+ tooltip: `Enabled on ${column.label}`,
+ ariaLabel: `Disable ${baseLabel}`,
+ action: "disable",
+ };
+ } else if (binding?.state === "drifted") {
+ const detail = binding.driftDetail ? ` (${binding.driftDetail})` : "";
+ cell = {
+ state: "different",
+ binding,
+ writable,
+ pendingKey,
+ tooltip: `Different config on ${column.label}${detail}`,
+ ariaLabel: `Resolve config for ${baseLabel}`,
+ action: "resolve",
+ };
+ } else if (binding?.state === "unmanaged") {
+ cell = {
+ state: "observed",
+ binding,
+ writable,
+ pendingKey,
+ tooltip: `Configured outside skill-manager on ${column.label}`,
+ ariaLabel: `Open details for ${baseLabel}`,
+ action: "open",
+ };
+ } else if (!writable || !entry.canEnable) {
+ cell = {
+ state: "unavailable",
+ binding,
+ writable,
+ pendingKey,
+ tooltip: column.hooksUnavailableReason ?? "Unavailable",
+ ariaLabel: `Unavailable for ${baseLabel}`,
+ action: null,
+ };
+ } else {
+ cell = {
+ state: "disabled",
+ binding,
+ writable,
+ pendingKey,
+ tooltip: `Disabled on ${column.label}`,
+ ariaLabel: `Enable ${baseLabel}`,
+ action: "enable",
+ };
+ }
+
+ if (binding?.caveat) {
+ cell.tooltip = `${cell.tooltip} (Caveat: ${binding.caveat})`;
+ }
+
+ return cell;
+}
+
+export function matrixCoverage(
+ entry: HookInventoryEntryDto,
+ columns: readonly HookInventoryColumnDto[],
+): { enabled: number; writable: number } {
+ const addressable = new Set(columns.filter(isHooksHarnessAddressable).map((column) => column.harness));
+ return {
+ enabled: entry.sightings.filter(
+ (binding) => addressable.has(binding.harness) && binding.state === "managed",
+ ).length,
+ writable: addressable.size,
+ };
+}
diff --git a/frontend/src/features/hooks/model/use-hooks-management-controller.ts b/frontend/src/features/hooks/model/use-hooks-management-controller.ts
new file mode 100644
index 0000000..11a7ffb
--- /dev/null
+++ b/frontend/src/features/hooks/model/use-hooks-management-controller.ts
@@ -0,0 +1,165 @@
+import { useCallback, useState } from "react";
+
+import { usePendingRegistry } from "../../../lib/async/pending-registry";
+import {
+ useCreateHookMutation,
+ useDisableHookMutation,
+ useEnableHookMutation,
+ useHooksInventoryQuery,
+ useReconcileHookMutation,
+ useSetHookHarnessesMutation,
+ useUninstallHookMutation,
+} from "../api/management-queries";
+
+export type HooksStatus = "loading" | "ready" | "error";
+
+export function useHooksManagementController() {
+ const inventoryQuery = useHooksInventoryQuery();
+ const setHarnessesMutation = useSetHookHarnessesMutation();
+ const uninstallMutation = useUninstallHookMutation();
+ const reconcileMutation = useReconcileHookMutation();
+ const enableMutation = useEnableHookMutation();
+ const disableMutation = useDisableHookMutation();
+ const createMutation = useCreateHookMutation();
+
+ const pendingHookRegistry = usePendingRegistry();
+ const pendingPerHarnessRegistry = usePendingRegistry(); // key: id:harness
+
+ const [actionErrorMessage, setActionErrorMessage] = useState("");
+ const [selectedHookId, setSelectedHookId] = useState(null);
+
+ const inventory = inventoryQuery.data ?? null;
+ const isInitialLoading = inventoryQuery.isPending && !inventory;
+ const queryErrorMessage =
+ inventoryQuery.error instanceof Error ? inventoryQuery.error.message : "";
+ const status: HooksStatus = isInitialLoading
+ ? "loading"
+ : inventory
+ ? "ready"
+ : queryErrorMessage
+ ? "error"
+ : "loading";
+
+ const handleSetHookHarnesses = useCallback(
+ async (
+ id: string,
+ target: "enabled" | "disabled",
+ ): Promise => {
+ try {
+ await pendingHookRegistry.run(id, async () => {
+ const response = await setHarnessesMutation.mutateAsync({ id, target });
+ if (!response.ok) {
+ const failed = response.failed.map((f) => `${f.harness}: ${f.error}`).join("; ");
+ setActionErrorMessage(failed || "Some harnesses could not be updated");
+ }
+ });
+ } catch (error) {
+ setActionErrorMessage(error instanceof Error ? error.message : "Action failed");
+ }
+ },
+ [pendingHookRegistry, setHarnessesMutation],
+ );
+
+ const handleUninstallHook = useCallback(
+ async (id: string): Promise => {
+ try {
+ await pendingHookRegistry.run(id, async () => {
+ const response = await uninstallMutation.mutateAsync(id);
+ if (!response.ok) {
+ const failed = response.failed.map((f) => `${f.harness}: ${f.error}`).join("; ");
+ setActionErrorMessage(failed || "Could not delete hook cleanly");
+ } else {
+ if (selectedHookId === id) {
+ setSelectedHookId(null);
+ }
+ }
+ });
+ } catch (error) {
+ setActionErrorMessage(error instanceof Error ? error.message : "Action failed");
+ }
+ },
+ [pendingHookRegistry, selectedHookId, uninstallMutation],
+ );
+
+ const handleToggleHarness = useCallback(
+ async (id: string, harness: string, currentEnabled: boolean): Promise => {
+ const pendingKey = `${id}:${harness}`;
+ try {
+ await pendingPerHarnessRegistry.run(pendingKey, async () => {
+ if (currentEnabled) {
+ await disableMutation.mutateAsync({ id, harness });
+ } else {
+ await enableMutation.mutateAsync({ id, harness });
+ }
+ });
+ } catch (error) {
+ setActionErrorMessage(error instanceof Error ? error.message : "Action failed");
+ }
+ },
+ [disableMutation, enableMutation, pendingPerHarnessRegistry],
+ );
+
+ const handleReconcileHook = useCallback(
+ async (args: {
+ id: string;
+ sourceKind: "managed" | "harness";
+ observedHarness?: string | null;
+ harnesses?: string[];
+ }): Promise => {
+ try {
+ await pendingHookRegistry.run(args.id, async () => {
+ const response = await reconcileMutation.mutateAsync(args);
+ if (!response.ok) {
+ const failed = response.failed.map((f) => `${f.harness}: ${f.error}`).join("; ");
+ setActionErrorMessage(failed || "Reconciliation failed");
+ }
+ });
+ } catch (error) {
+ setActionErrorMessage(error instanceof Error ? error.message : "Action failed");
+ }
+ },
+ [pendingHookRegistry, reconcileMutation],
+ );
+
+ const handleCreateHook = useCallback(
+ async (hook: {
+ id: string;
+ event: string;
+ command: string;
+ match?: string | null;
+ timeout?: number | null;
+ description?: string;
+ }): Promise => {
+ try {
+ await createMutation.mutateAsync(hook);
+ } catch (error) {
+ setActionErrorMessage(error instanceof Error ? error.message : "Action failed");
+ throw error;
+ }
+ },
+ [createMutation],
+ );
+
+ const clearActionError = useCallback(() => {
+ setActionErrorMessage("");
+ }, []);
+
+ return {
+ inventory,
+ status,
+ isInitialLoading,
+ queryErrorMessage,
+ actionErrorMessage,
+ clearActionError,
+ selectedHookId,
+ setSelectedHookId,
+ pendingHookKeys: pendingHookRegistry.pendingKeys,
+ pendingPerHarnessKeys: pendingPerHarnessRegistry.pendingKeys,
+ handleSetHookHarnesses,
+ handleUninstallHook,
+ handleToggleHarness,
+ handleReconcileHook,
+ handleCreateHook,
+ };
+}
+export type HooksManagementController = ReturnType;
diff --git a/frontend/src/features/hooks/model/useHooksInUseViewMode.ts b/frontend/src/features/hooks/model/useHooksInUseViewMode.ts
new file mode 100644
index 0000000..f912152
--- /dev/null
+++ b/frontend/src/features/hooks/model/useHooksInUseViewMode.ts
@@ -0,0 +1,17 @@
+import { usePersistentViewMode } from "../../../lib/usePersistentViewMode";
+
+export type HooksInUseViewMode = "cards" | "matrix";
+
+const STORAGE_KEY = "skillmgr.hooks.inUse.view";
+
+function isValidMode(value: unknown): value is HooksInUseViewMode {
+ return value === "cards" || value === "matrix";
+}
+
+export function useHooksInUseViewMode(): [HooksInUseViewMode, (next: HooksInUseViewMode) => void] {
+ return usePersistentViewMode({
+ storageKey: STORAGE_KEY,
+ defaultMode: "cards",
+ isValidMode,
+ });
+}
diff --git a/frontend/src/features/hooks/public.ts b/frontend/src/features/hooks/public.ts
new file mode 100644
index 0000000..a4ce27b
--- /dev/null
+++ b/frontend/src/features/hooks/public.ts
@@ -0,0 +1,26 @@
+export {
+ useCreateHookMutation,
+ useDisableHookMutation,
+ useEnableHookMutation,
+ useHooksInventoryQuery,
+ useHookDetailQuery,
+ useReconcileHookMutation,
+ useSetHookHarnessesMutation,
+ useUninstallHookMutation,
+} from "./api/management-queries";
+export { createHook } from "./api/management-client";
+export { invalidateHooksQueries } from "./api/invalidation";
+export { hooksManagementKeys } from "./api/keys";
+export type {
+ HookBindingDto,
+ HookInventoryColumnDto,
+ HookInventoryDto,
+ HookInventoryEntryDto,
+ HookSpecDto,
+} from "./api/management-types";
+export { isHooksHarnessAddressable } from "./model/selectors";
+
+export const hooksRoutes = {
+ inUse: "/hooks/use",
+ needsReview: "/hooks/review",
+} as const;
diff --git a/frontend/src/features/hooks/screens/HooksInUsePage.tsx b/frontend/src/features/hooks/screens/HooksInUsePage.tsx
new file mode 100644
index 0000000..6bdc164
--- /dev/null
+++ b/frontend/src/features/hooks/screens/HooksInUsePage.tsx
@@ -0,0 +1,291 @@
+import { useCallback, useMemo, useState } from "react";
+import { Grid2X2, Rows3, Plus } from "lucide-react";
+import { useSearchParams } from "react-router-dom";
+
+import { ConfirmActionDialog } from "../../../components/ConfirmActionDialog";
+import { ErrorBanner } from "../../../components/ErrorBanner";
+import { FilterBar } from "../../../components/FilterBar";
+import { LoadingSpinner } from "../../../components/LoadingSpinner";
+import { PageHeader } from "../../../components/PageHeader";
+import { ViewModeToggle, type ViewModeOption } from "../../../components/ViewModeToggle";
+import { useCommonCopy } from "../../../i18n";
+import { useHooksCopy } from "../i18n";
+import { HookCardList } from "../components/HookCardList";
+import { HooksMatrixView } from "../components/HooksMatrixView";
+import { HookDetailSheet } from "../components/detail/HookDetailSheet";
+import { HookFormDialog } from "../components/edit/HookFormDialog";
+import { HooksFilterMenu } from "../components/HooksFilterMenu";
+import {
+ filterHooksInUse,
+ pillCounts,
+ type InUsePillValue,
+} from "../model/selectors";
+import { useHooksManagementController } from "../model/use-hooks-management-controller";
+import { useHooksInUseViewMode, type HooksInUseViewMode } from "../model/useHooksInUseViewMode";
+
+const DETAIL_PARAM = "hook";
+
+export default function HooksInUsePage() {
+ const {
+ status,
+ inventory,
+ isInitialLoading,
+ selectedHookId,
+ setSelectedHookId,
+ pendingHookKeys,
+ pendingPerHarnessKeys,
+ queryErrorMessage,
+ actionErrorMessage,
+ clearActionError,
+ handleSetHookHarnesses,
+ handleUninstallHook,
+ handleToggleHarness,
+ handleReconcileHook,
+ handleCreateHook,
+ } = useHooksManagementController();
+
+ const [searchParams, setSearchParams] = useSearchParams();
+ const selectedId = searchParams.get(DETAIL_PARAM);
+ const [confirmUninstallId, setConfirmUninstallId] = useState(null);
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
+ const [addPending, setAddPending] = useState(false);
+
+ const [search, setSearch] = useState("");
+ const [pill, setPill] = useState("all");
+ const [viewMode, setViewMode] = useHooksInUseViewMode();
+ const copy = useHooksCopy();
+ const common = useCommonCopy();
+
+ const viewModeOptions: readonly ViewModeOption[] = useMemo(
+ () => [
+ { value: "cards", label: copy.inUse.viewModes.cards, icon: Grid2X2 },
+ { value: "matrix", label: copy.inUse.viewModes.matrix, icon: Rows3 },
+ ],
+ [copy],
+ );
+
+ const entries = useMemo(
+ () => filterHooksInUse(inventory, { search, pill }),
+ [inventory, search, pill],
+ );
+ const counts = useMemo(() => pillCounts(inventory), [inventory]);
+ const totalInUse = inventory?.entries.filter((e) => e.kind === "managed").length ?? 0;
+ const isReady = status === "ready" && Boolean(inventory);
+
+ const setDetailId = useCallback(
+ (id: string | null) => {
+ const next = new URLSearchParams(searchParams);
+ if (id) {
+ next.set(DETAIL_PARAM, id);
+ } else {
+ next.delete(DETAIL_PARAM);
+ }
+ setSearchParams(next, { replace: !id });
+ },
+ [searchParams, setSearchParams],
+ );
+
+ const pendingForSelected = useMemo(() => {
+ if (!selectedId) return new Set();
+ const result = new Set();
+ for (const key of pendingPerHarnessKeys) {
+ const [id, harness] = key.split(":", 2);
+ if (id === selectedId) result.add(harness);
+ }
+ return result;
+ }, [pendingPerHarnessKeys, selectedId]);
+
+ const isUninstallingSelected =
+ selectedId !== null && pendingHookKeys.has(selectedId);
+ const isHookPendingSelected =
+ selectedId !== null && pendingHookKeys.has(selectedId);
+
+ const handleCreateHookSubmit = async (value: {
+ id: string;
+ event: string;
+ command: string;
+ match?: string | null;
+ timeout?: number | null;
+ description?: string;
+ }) => {
+ setAddPending(true);
+ try {
+ await handleCreateHook(value);
+ setAddDialogOpen(false);
+ } finally {
+ setAddPending(false);
+ }
+ };
+
+ const executeUninstall = useCallback(async () => {
+ const target = confirmUninstallId;
+ if (!target) return;
+ setConfirmUninstallId(null);
+ await handleUninstallHook(target);
+ if (selectedId === target) {
+ setDetailId(null);
+ }
+ }, [confirmUninstallId, handleUninstallHook, selectedId, setDetailId]);
+
+ return (
+ <>
+
+
+
+ setAddDialogOpen(true)}
+ >
+
+ Add Hook
+
+ >
+ }
+ />
+ {totalInUse > 0 ? (
+ }
+ />
+ ) : null}
+
+
+ {actionErrorMessage ? (
+
+ ) : null}
+
+ {isInitialLoading ? (
+
+
+
+ ) : status === "error" ? (
+ {queryErrorMessage || copy.inUse.unableToLoad}
+ ) : isReady && inventory ? (
+ entries.length > 0 ? (
+ viewMode === "matrix" ? (
+ {}}
+ onEnableHarness={(id, harness) => {
+ void handleToggleHarness(id, harness, false);
+ }}
+ onDisableHarness={(id, harness) => {
+ void handleToggleHarness(id, harness, true);
+ }}
+ />
+ ) : (
+ {}}
+ onSetHarnesses={(id, target) => {
+ void handleSetHookHarnesses(id, target);
+ }}
+ onRequestUninstall={setConfirmUninstallId}
+ />
+ )
+ ) : totalInUse > 0 ? (
+
+
{common.status.noMatches}
+
+ {copy.inUse.noMatchesBody}
+
+
+ {
+ setSearch("");
+ setPill("all");
+ }}
+ >
+ {common.actions.clearFilters}
+
+
+
+ ) : (
+
+
{copy.inUse.emptyTitle}
+
+ {copy.inUse.emptyBody}
+
+
+ setAddDialogOpen(true)}
+ >
+ Add Hook
+
+
+
+ )
+ ) : null}
+
+ {inventory ? (
+ setDetailId(null)}
+ onEnableHarness={(harness) => {
+ if (selectedId) void handleToggleHarness(selectedId, harness, false);
+ }}
+ onDisableHarness={(harness) => {
+ if (selectedId) void handleToggleHarness(selectedId, harness, true);
+ }}
+ onResolveConfig={(args) => {
+ if (!selectedId) return Promise.resolve();
+ return handleReconcileHook({ id: selectedId, ...args });
+ }}
+ onUninstall={() => {
+ if (selectedId) setConfirmUninstallId(selectedId);
+ }}
+ />
+ ) : null}
+
+
+
+ {
+ if (!open) setConfirmUninstallId(null);
+ }}
+ onConfirm={executeUninstall}
+ />
+ >
+ );
+}
diff --git a/frontend/src/features/hooks/screens/HooksNeedsReviewPage.tsx b/frontend/src/features/hooks/screens/HooksNeedsReviewPage.tsx
new file mode 100644
index 0000000..62dd021
--- /dev/null
+++ b/frontend/src/features/hooks/screens/HooksNeedsReviewPage.tsx
@@ -0,0 +1,24 @@
+import { Link } from "react-router-dom";
+import { PageHeader } from "../../../components/PageHeader";
+
+export default function HooksNeedsReviewPage() {
+ return (
+
+
+
+
No hooks need review
+
+ Your harness configs only reference hooks that skill-manager already tracks.
+
+
+
+ View Hooks in Use
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/skills/screens/ScanConfigPage.test.tsx b/frontend/src/features/skills/screens/ScanConfigPage.test.tsx
index 002b1db..fd36baa 100644
--- a/frontend/src/features/skills/screens/ScanConfigPage.test.tsx
+++ b/frontend/src/features/skills/screens/ScanConfigPage.test.tsx
@@ -110,7 +110,7 @@ describe("ScanConfigPage", () => {
expect(screen.queryByText(/Missing required fields: API Key/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeDisabled();
expect(screen.queryByRole("columnheader", { name: "Last validation" })).not.toBeInTheDocument();
- expect(screen.getByLabelText("Last validation")).toHaveTextContent(/May 12|12 May|Failed|Not validated/);
+ expect(screen.getByLabelText("Last validation")).toHaveTextContent(/May 11|11 May|May 12|12 May|Failed|Not validated/);
const apiKeyInput = screen.getByLabelText("API Key", { selector: "input" });
expect(apiKeyInput).toHaveAttribute("type", "password");
expect(String(apiKeyInput.getAttribute("value") ?? "")).not.toBe("");
diff --git a/skill_manager/api/app.py b/skill_manager/api/app.py
index 850f4dc..d49f33c 100644
--- a/skill_manager/api/app.py
+++ b/skill_manager/api/app.py
@@ -8,7 +8,7 @@
from skill_manager.application import BackendContainer
from .errors import install_error_handlers
-from .routers import health, marketplace, mcp, scan, settings, skills, slash_commands
+from .routers import health, hooks, marketplace, mcp, scan, settings, skills, slash_commands
def create_app(
@@ -26,6 +26,7 @@ def create_app(
app.include_router(slash_commands.router)
app.include_router(marketplace.router)
app.include_router(mcp.router)
+ app.include_router(hooks.router)
app.include_router(scan.router)
@app.get("/{full_path:path}", include_in_schema=False, response_model=None)
diff --git a/skill_manager/api/routers/__init__.py b/skill_manager/api/routers/__init__.py
index 5b5d875..68ed143 100644
--- a/skill_manager/api/routers/__init__.py
+++ b/skill_manager/api/routers/__init__.py
@@ -1,3 +1,3 @@
-from . import health, marketplace, mcp, settings, skills, slash_commands
+from . import health, hooks, marketplace, mcp, settings, skills, slash_commands
-__all__ = ["health", "marketplace", "mcp", "settings", "skills", "slash_commands"]
+__all__ = ["health", "hooks", "marketplace", "mcp", "settings", "skills", "slash_commands"]
diff --git a/skill_manager/api/routers/hooks.py b/skill_manager/api/routers/hooks.py
new file mode 100644
index 0000000..1fb1e4c
--- /dev/null
+++ b/skill_manager/api/routers/hooks.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends
+
+from skill_manager.api.deps import get_container
+from skill_manager.api.schemas import (
+ AddHookRequest,
+ DisableHookRequest,
+ EnableHookRequest,
+ HookApplyConfigResponse,
+ HookInventoryEntryResponse,
+ HookInventoryResponse,
+ HookMutationResponse,
+ HookSetHarnessesResultResponse,
+ OkResponse,
+ ReconcileHookRequest,
+ SetHookHarnessesRequest,
+)
+from skill_manager.application import BackendContainer
+from skill_manager.application.hooks.store import HookSpec
+
+router = APIRouter(prefix="/api/hooks")
+
+
+@router.get("", response_model=HookInventoryResponse)
+def list_hooks(container: BackendContainer = Depends(get_container)) -> dict[str, object]:
+ return container.hooks_queries.list_hooks()
+
+
+@router.get("/{id}", response_model=HookInventoryEntryResponse)
+def get_hook(
+ id: str,
+ container: BackendContainer = Depends(get_container),
+) -> dict[str, object]:
+ return container.hooks_queries.get_hook(id)
+
+
+@router.post("", response_model=HookMutationResponse)
+def create_hook(
+ body: AddHookRequest,
+ container: BackendContainer = Depends(get_container),
+) -> dict[str, object]:
+ spec = HookSpec(
+ id=body.id,
+ event=body.event,
+ command=body.command,
+ match=body.match,
+ timeout=body.timeout,
+ description=body.description,
+ )
+ stored = container.hooks_mutations.create_hook(spec)
+ return {"ok": True, "hook": stored.to_dict()}
+
+
+@router.delete("/{id}", response_model=HookSetHarnessesResultResponse)
+def delete_hook(
+ id: str,
+ container: BackendContainer = Depends(get_container),
+) -> dict[str, object]:
+ return container.hooks_mutations.delete_hook(id)
+
+
+@router.post("/{id}/enable", response_model=OkResponse)
+def enable_hook(
+ id: str,
+ body: EnableHookRequest,
+ container: BackendContainer = Depends(get_container),
+) -> dict[str, bool]:
+ return container.hooks_mutations.enable_hook(id, body.harness)
+
+
+@router.post("/{id}/disable", response_model=OkResponse)
+def disable_hook(
+ id: str,
+ body: DisableHookRequest,
+ container: BackendContainer = Depends(get_container),
+) -> dict[str, bool]:
+ return container.hooks_mutations.disable_hook(id, body.harness)
+
+
+@router.post("/{id}/reconcile", response_model=HookApplyConfigResponse)
+def reconcile_hook(
+ id: str,
+ body: ReconcileHookRequest,
+ container: BackendContainer = Depends(get_container),
+) -> dict[str, object]:
+ return container.hooks_mutations.reconcile_hook(
+ id,
+ source_kind=body.source_kind,
+ observed_harness=body.observed_harness,
+ harnesses=body.harnesses,
+ )
+
+
+@router.post("/{id}/set-harnesses", response_model=HookSetHarnessesResultResponse)
+def set_hook_harnesses(
+ id: str,
+ body: SetHookHarnessesRequest,
+ container: BackendContainer = Depends(get_container),
+) -> dict[str, object]:
+ return container.hooks_mutations.set_hook_all_harnesses(id, body.target)
diff --git a/skill_manager/api/schemas/__init__.py b/skill_manager/api/schemas/__init__.py
index c1f215f..3cd8ca2 100644
--- a/skill_manager/api/schemas/__init__.py
+++ b/skill_manager/api/schemas/__init__.py
@@ -86,6 +86,23 @@
SlashSyncRequest,
SlashTargetResponse,
)
+from .hooks import (
+ AddHookRequest,
+ DisableHookRequest,
+ EnableHookRequest,
+ HookApplyConfigResponse,
+ HookBindingResponse,
+ HookInventoryColumnResponse,
+ HookInventoryEntryResponse,
+ HookInventoryIssueResponse,
+ HookInventoryResponse,
+ HookMutationFailureResponse,
+ HookMutationResponse,
+ HookSetHarnessesResultResponse,
+ HookSpecResponse,
+ ReconcileHookRequest,
+ SetHookHarnessesRequest,
+)
__all__ = [
"AdoptMcpRequest",
@@ -172,4 +189,19 @@
"SlashSyncEntryResponse",
"SlashSyncRequest",
"SlashTargetResponse",
+ "AddHookRequest",
+ "DisableHookRequest",
+ "EnableHookRequest",
+ "HookApplyConfigResponse",
+ "HookBindingResponse",
+ "HookInventoryColumnResponse",
+ "HookInventoryEntryResponse",
+ "HookInventoryIssueResponse",
+ "HookInventoryResponse",
+ "HookMutationFailureResponse",
+ "HookMutationResponse",
+ "HookSetHarnessesResultResponse",
+ "HookSpecResponse",
+ "ReconcileHookRequest",
+ "SetHookHarnessesRequest",
]
diff --git a/skill_manager/api/schemas/hooks.py b/skill_manager/api/schemas/hooks.py
new file mode 100644
index 0000000..62d7245
--- /dev/null
+++ b/skill_manager/api/schemas/hooks.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from .common import HarnessTarget
+
+
+class AddHookRequest(BaseModel):
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
+
+ id: str = Field(..., min_length=1)
+ event: str = Field(..., min_length=1)
+ command: str = Field(..., min_length=1)
+ match: str | None = None
+ timeout: int | None = None
+ description: str = ""
+
+
+class EnableHookRequest(HarnessTarget):
+ pass
+
+
+class DisableHookRequest(HarnessTarget):
+ pass
+
+
+class SetHookHarnessesRequest(BaseModel):
+ target: Literal["enabled", "disabled"]
+
+
+class ReconcileHookRequest(BaseModel):
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
+
+ source_kind: Literal["managed", "harness"] = Field(..., alias="sourceKind")
+ observed_harness: str | None = Field(
+ default=None,
+ alias="observedHarness",
+ title="Observed harness",
+ )
+ harnesses: list[str] | None = None
+
+
+class HookSpecResponse(BaseModel):
+ id: str
+ event: str
+ command: str
+ match: str | None = None
+ timeout: int | None = None
+ description: str
+ installedAt: str
+ revision: str
+
+
+class HookInventoryColumnResponse(BaseModel):
+ harness: str
+ label: str
+ logoKey: str | None = None
+ installed: bool
+ configPresent: bool
+ hooksWritable: bool = True
+ hooksUnavailableReason: str | None = None
+
+
+class HookInventoryIssueResponse(BaseModel):
+ name: str
+ reason: str
+
+
+class HookBindingResponse(BaseModel):
+ harness: str
+ state: Literal["managed", "drifted", "unmanaged", "missing", "unsupported"]
+ driftDetail: str | None = None
+ caveat: str | None = None
+
+
+class HookInventoryEntryResponse(BaseModel):
+ id: str
+ displayName: str
+ kind: Literal["managed", "unmanaged"]
+ spec: HookSpecResponse | None = None
+ canEnable: bool
+ enabledStatus: Literal["enabled", "disabled"]
+ sightings: list[HookBindingResponse]
+
+
+class HookInventoryResponse(BaseModel):
+ columns: list[HookInventoryColumnResponse]
+ entries: list[HookInventoryEntryResponse]
+ issues: list[HookInventoryIssueResponse] = Field(default_factory=list)
+
+
+class HookMutationFailureResponse(BaseModel):
+ harness: str
+ error: str
+
+
+class HookSetHarnessesResultResponse(BaseModel):
+ ok: bool
+ succeeded: list[str]
+ failed: list[HookMutationFailureResponse]
+
+
+class HookMutationResponse(BaseModel):
+ ok: bool
+ hook: HookSpecResponse
+
+
+class HookApplyConfigResponse(BaseModel):
+ ok: bool
+ hook: HookSpecResponse
+ succeeded: list[str]
+ failed: list[HookMutationFailureResponse]
+
+
+__all__ = [
+ "AddHookRequest",
+ "DisableHookRequest",
+ "EnableHookRequest",
+ "HookApplyConfigResponse",
+ "HookBindingResponse",
+ "HookInventoryColumnResponse",
+ "HookInventoryEntryResponse",
+ "HookInventoryIssueResponse",
+ "HookInventoryResponse",
+ "HookMutationFailureResponse",
+ "HookMutationResponse",
+ "HookSetHarnessesResultResponse",
+ "HookSpecResponse",
+ "ReconcileHookRequest",
+ "SetHookHarnessesRequest",
+]
diff --git a/skill_manager/application/container.py b/skill_manager/application/container.py
index a7b9b56..ae0797d 100644
--- a/skill_manager/application/container.py
+++ b/skill_manager/application/container.py
@@ -18,6 +18,12 @@
from .mcp.query import McpQueryService
from .mcp.read_models import McpReadModelService
from .mcp.store import McpServerStore
+from .hooks import (
+ HookStore,
+ HooksReadModelService,
+ HooksQueryService,
+ HooksMutationService,
+)
from .settings import SettingsMutationService, SettingsQueryService
from .slash_commands import (
SlashCommandMutationService,
@@ -74,6 +80,10 @@ class BackendContainer:
mcp_read_models: McpReadModelService
mcp_queries: McpQueryService
mcp_mutations: McpMutationService
+ hooks_store: HookStore
+ hooks_read_models: HooksReadModelService
+ hooks_queries: HooksQueryService
+ hooks_mutations: HooksMutationService
db: Database
scan_config_service: ScanConfigService
scan_service: ScanService
@@ -182,6 +192,15 @@ def build_backend_container(
availability_cache=mcp_availability_cache,
)
+ hooks_store = HookStore(paths.hooks_store_manifest)
+ hooks_read_models = HooksReadModelService.from_kernel(store=hooks_store, kernel=harness_kernel)
+ invalidation.register(hooks_read_models)
+ hooks_queries = HooksQueryService(hooks_read_models)
+ hooks_mutations = HooksMutationService(
+ store=hooks_store,
+ read_models=hooks_read_models,
+ )
+
db = Database(paths.db_path)
scan_config_service = ScanConfigService(ScanConfigRepository(db))
scan_service = ScanService(
@@ -216,6 +235,10 @@ def build_backend_container(
mcp_read_models=mcp_read_models,
mcp_queries=mcp_queries,
mcp_mutations=mcp_mutations,
+ hooks_store=hooks_store,
+ hooks_read_models=hooks_read_models,
+ hooks_queries=hooks_queries,
+ hooks_mutations=hooks_mutations,
db=db,
scan_config_service=scan_config_service,
scan_service=scan_service,
diff --git a/skill_manager/application/hooks/__init__.py b/skill_manager/application/hooks/__init__.py
new file mode 100644
index 0000000..6c78e75
--- /dev/null
+++ b/skill_manager/application/hooks/__init__.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from .adapters import FileBackedHooksAdapter, build_hooks_adapters
+from .contracts import (
+ BindingState,
+ HookBinding,
+ HookHarnessAdapter,
+ HookHarnessScan,
+ HookHarnessStatus,
+ HookInventory,
+ HookInventoryEntry,
+ HookInventoryIssue,
+ HookObservedEntry,
+)
+from .mutations import HooksMutationService
+from .query import HooksQueryService
+from .read_models import HooksReadModelService, HooksReadModelSnapshot
+from .store import HookSpec, HookStore
+
+
+__all__ = [
+ "BindingState",
+ "FileBackedHooksAdapter",
+ "HookBinding",
+ "HookHarnessAdapter",
+ "HookHarnessScan",
+ "HookHarnessStatus",
+ "HookInventory",
+ "HookInventoryEntry",
+ "HookInventoryIssue",
+ "HookObservedEntry",
+ "HookSpec",
+ "HookStore",
+ "HooksMutationService",
+ "HooksQueryService",
+ "HooksReadModelService",
+ "HooksReadModelSnapshot",
+ "build_hooks_adapters",
+]
diff --git a/skill_manager/application/hooks/adapters.py b/skill_manager/application/hooks/adapters.py
new file mode 100644
index 0000000..09e04b1
--- /dev/null
+++ b/skill_manager/application/hooks/adapters.py
@@ -0,0 +1,343 @@
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import tomllib
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Mapping
+
+import tomli_w
+
+from skill_manager.errors import MutationError
+from skill_manager.atomic_files import atomic_write_text, file_lock
+from skill_manager.harness import (
+ ConfigSubtreeBindingProfile,
+ HarnessDefinition,
+ HarnessKernelService,
+ ResolutionContext,
+)
+
+from .contracts import HookHarnessAdapter, HookHarnessScan, HookHarnessStatus, HookObservedEntry, BindingState
+from .mappers import HookMapper, RawHookEntry, get_mapper
+from .store import HookSpec
+
+
+class FileBackedHooksAdapter(HookHarnessAdapter):
+ def __init__(
+ self,
+ *,
+ definition: HarnessDefinition,
+ profile: ConfigSubtreeBindingProfile,
+ context: ResolutionContext,
+ ) -> None:
+ self.harness = definition.harness
+ self.label = definition.label
+ self.logo_key = definition.logo_key
+ self.config_path = profile.resolve_config_path(context)
+ self._install_probe = definition.install_probe
+ self._path_env = context.env.get("PATH")
+ self._file_format = profile.file_format
+ self._write_subtree_path = profile.subtree_path
+ self._env = context.env
+ self._mapper: HookMapper = get_mapper(profile.codec)
+
+ def status(self) -> HookHarnessStatus:
+ installed = self._is_installed()
+ config_present = self.config_path.is_file()
+ return HookHarnessStatus(
+ harness=self.harness,
+ label=self.label,
+ logo_key=self.logo_key,
+ installed=installed,
+ config_path=self.config_path,
+ config_present=config_present,
+ hooks_writable=True,
+ )
+
+ def scan(self, specs: tuple[HookSpec, ...]) -> HookHarnessScan:
+ status = self.status()
+ specs_by_id = {spec.id: spec for spec in specs}
+ entries: list[HookObservedEntry] = []
+ seen_ids: set[str] = set()
+ scan_issue: str | None = None
+
+ try:
+ raw_entries = self._read_entries(specs) if status.config_present else ()
+ except MutationError as error:
+ raw_entries = ()
+ scan_issue = str(error)
+
+ for raw in raw_entries:
+ seen_ids.add(raw.id)
+ parsed_spec: HookSpec | None = None
+ parse_issue: str | None = None
+ try:
+ parsed_spec = self._mapper.dict_to_spec(
+ raw.event,
+ raw.match,
+ raw.payload,
+ )
+ except Exception as error: # noqa: BLE001
+ parse_issue = str(error)
+
+ managed_spec = specs_by_id.get(raw.id)
+ if managed_spec is None:
+ entries.append(
+ HookObservedEntry(
+ id=raw.id,
+ event=raw.event,
+ state="unmanaged",
+ raw_payload=dict(raw.payload),
+ parsed_spec=parsed_spec,
+ parse_issue=parse_issue,
+ )
+ )
+ continue
+
+ if parse_issue is not None:
+ entries.append(
+ HookObservedEntry(
+ id=raw.id,
+ event=raw.event,
+ state="drifted",
+ raw_payload=dict(raw.payload),
+ parsed_spec=parsed_spec,
+ drift_detail=parse_issue,
+ parse_issue=parse_issue,
+ )
+ )
+ continue
+
+ is_repr, reason, caveat = self._mapper.representable(managed_spec)
+ if not is_repr:
+ entries.append(
+ HookObservedEntry(
+ id=raw.id,
+ event=raw.event,
+ state="unsupported",
+ raw_payload=dict(raw.payload),
+ parsed_spec=parsed_spec,
+ drift_detail=reason,
+ caveat=caveat,
+ )
+ )
+ continue
+
+ expected = _normalize_payload(self._mapper.spec_to_dict(managed_spec))
+ actual = _normalize_payload(dict(raw.payload))
+ if expected == actual and managed_spec.event == raw.event and managed_spec.match == raw.match:
+ entries.append(
+ HookObservedEntry(
+ id=raw.id,
+ event=raw.event,
+ state="managed",
+ raw_payload=dict(raw.payload),
+ parsed_spec=parsed_spec,
+ caveat=caveat,
+ )
+ )
+ else:
+ drift_parts = []
+ if managed_spec.event != raw.event:
+ drift_parts.append(f"event: expected={managed_spec.event}, actual={raw.event}")
+ if managed_spec.match != raw.match:
+ drift_parts.append(f"match: expected={managed_spec.match}, actual={raw.match}")
+ if expected != actual:
+ drift_parts.append(_drift_detail(expected, actual))
+ entries.append(
+ HookObservedEntry(
+ id=raw.id,
+ event=raw.event,
+ state="drifted",
+ raw_payload=dict(raw.payload),
+ parsed_spec=parsed_spec,
+ drift_detail="; ".join(drift_parts) or "value mismatch",
+ caveat=caveat,
+ )
+ )
+
+ for spec in specs:
+ if spec.id in seen_ids:
+ continue
+ is_repr, reason, caveat = self._mapper.representable(spec)
+ if not is_repr:
+ entries.append(
+ HookObservedEntry(
+ id=spec.id,
+ event=spec.event,
+ state="unsupported",
+ parsed_spec=spec,
+ drift_detail=reason,
+ caveat=caveat,
+ )
+ )
+ else:
+ entries.append(
+ HookObservedEntry(
+ id=spec.id,
+ event=spec.event,
+ state="missing",
+ parsed_spec=spec,
+ caveat=caveat,
+ )
+ )
+
+ return HookHarnessScan(
+ harness=self.harness,
+ label=self.label,
+ logo_key=self.logo_key,
+ installed=status.installed,
+ config_present=status.config_present,
+ config_path=self.config_path,
+ scan_issue=scan_issue,
+ entries=tuple(entries),
+ )
+
+ def has_binding(self, id: str) -> bool:
+ specs = ()
+ try:
+ from skill_manager.paths import resolve_app_paths
+ from skill_manager.application.hooks.store import HookStore
+ app_paths = resolve_app_paths(self._env)
+ store = HookStore(app_paths.hooks_store_manifest)
+ specs = store.list_managed()
+ except Exception:
+ pass
+ return any(raw.id == id for raw in self._read_entries(specs))
+
+ def enable_hook(self, spec: HookSpec) -> None:
+ is_repr, reason, caveat = self._mapper.representable(spec)
+ if not is_repr:
+ raise MutationError(f"Hook not supported on {self.label}: {reason}", status=400)
+
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
+ with file_lock(self._lock_path(self.config_path)):
+ document = self._load_document(self.config_path)
+ self._mapper.enable_hook(document, spec)
+ atomic_write_text(self.config_path, self._dump_document(document))
+
+ def disable_hook(self, id: str) -> None:
+ if not self.config_path.is_file():
+ return
+ with file_lock(self._lock_path(self.config_path)):
+ document = self._load_document(self.config_path)
+ command = self._get_managed_command(id)
+ self._mapper.disable_hook(document, id, command)
+ atomic_write_text(self.config_path, self._dump_document(document))
+
+ def invalidate(self) -> None:
+ return None
+
+ def _is_installed(self) -> bool:
+ return shutil.which(self._install_probe, path=self._path_env) is not None
+
+ def _lock_path(self, config_path: Path) -> Path:
+ return config_path.with_suffix(config_path.suffix + ".lock")
+
+ def _load_document(self, config_path: Path) -> dict[str, object]:
+ if not config_path.is_file():
+ return {}
+ text = config_path.read_text(encoding="utf-8")
+ if not text.strip():
+ return {}
+ if self._file_format in {"json", "jsonc"}:
+ try:
+ payload = json.loads(_strip_jsonc(text) if self._file_format == "jsonc" else text)
+ except json.JSONDecodeError as error:
+ raise MutationError(
+ f"{self.harness} config file is not valid {self._file_format.upper()}: {error}",
+ status=409,
+ ) from error
+ return payload if isinstance(payload, dict) else {}
+ try:
+ payload = tomllib.loads(text)
+ except tomllib.TOMLDecodeError as error:
+ raise MutationError(
+ f"{self.harness} config file is not valid TOML: {error}",
+ status=409,
+ ) from error
+ return payload if isinstance(payload, dict) else {}
+
+ def _dump_document(self, document: dict[str, object]) -> str:
+ if self._file_format in {"json", "jsonc"}:
+ return json.dumps(document, ensure_ascii=False, indent=2) + "\n"
+ return tomli_w.dumps(document)
+
+ def _read_entries(self, specs: tuple[HookSpec, ...] = ()) -> tuple[RawHookEntry, ...]:
+ if not self.config_path.is_file():
+ return ()
+ document = self._load_document(self.config_path)
+ return tuple(self._mapper.read_entries(document, specs))
+
+ def _get_managed_command(self, id: str) -> str | None:
+ try:
+ from skill_manager.paths import resolve_app_paths
+ from skill_manager.application.hooks.store import HookStore
+ app_paths = resolve_app_paths(self._env)
+ store = HookStore(app_paths.hooks_store_manifest)
+ spec = store.get_managed(id)
+ return spec.command if spec else None
+ except Exception:
+ return None
+
+
+def build_hooks_adapters(
+ kernel: HarnessKernelService,
+) -> tuple[FileBackedHooksAdapter, ...]:
+ return tuple(
+ FileBackedHooksAdapter(
+ definition=binding.definition,
+ profile=binding.profile,
+ context=kernel.context,
+ )
+ for binding in kernel.bindings_for_family("hooks")
+ if isinstance(binding.profile, ConfigSubtreeBindingProfile)
+ )
+
+
+def _normalize_payload(value: object) -> object:
+ if isinstance(value, dict):
+ normalized = {
+ key: _normalize_payload(item)
+ for key, item in value.items()
+ if not _is_semantic_default(key, item)
+ }
+ return {key: normalized[key] for key in sorted(normalized)}
+ if isinstance(value, list):
+ return [_normalize_payload(item) for item in value]
+ return value
+
+
+def _is_semantic_default(key: str, value: object) -> bool:
+ if key == "type" and value == "command":
+ return True
+ return False
+
+
+def _strip_jsonc(text: str) -> str:
+ without_block = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
+ without_line = re.sub(r"(^|[^:])//.*$", r"\1", without_block, flags=re.MULTILINE)
+ return re.sub(r",(\s*[}\]])", r"\1", without_line)
+
+
+def _drift_detail(expected: object, actual: object) -> str:
+ if not isinstance(expected, dict) or not isinstance(actual, dict):
+ return "value mismatch"
+ missing = sorted(set(expected) - set(actual))
+ extra = sorted(set(actual) - set(expected))
+ changed = sorted(
+ key for key in set(expected) & set(actual) if expected[key] != actual[key]
+ )
+ parts: list[str] = []
+ if missing:
+ parts.append(f"missing={','.join(missing)}")
+ if extra:
+ parts.append(f"extra={','.join(extra)}")
+ if changed:
+ parts.append(f"changed={','.join(changed)}")
+ return "; ".join(parts) or "value mismatch"
+
+
+__all__ = ["FileBackedHooksAdapter", "build_hooks_adapters"]
diff --git a/skill_manager/application/hooks/contracts.py b/skill_manager/application/hooks/contracts.py
new file mode 100644
index 0000000..a06c1df
--- /dev/null
+++ b/skill_manager/application/hooks/contracts.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal, Protocol
+
+from .store import HookSpec
+
+
+BindingState = Literal["managed", "drifted", "unmanaged", "missing", "unsupported"]
+
+
+@dataclass(frozen=True)
+class HookHarnessStatus:
+ harness: str
+ label: str
+ logo_key: str | None
+ installed: bool
+ config_path: Path
+ config_present: bool
+ hooks_writable: bool = True
+ hooks_unavailable_reason: str | None = None
+
+
+@dataclass(frozen=True)
+class HookObservedEntry:
+ id: str
+ event: str
+ state: BindingState
+ raw_payload: dict[str, object] | None = None
+ parsed_spec: HookSpec | None = None
+ drift_detail: str | None = None
+ parse_issue: str | None = None
+ caveat: str | None = None
+
+
+@dataclass(frozen=True)
+class HookBinding:
+ harness: str
+ id: str
+ state: BindingState
+ drift_detail: str | None = None
+ caveat: str | None = None
+
+
+@dataclass(frozen=True)
+class HookHarnessScan:
+ harness: str
+ label: str
+ logo_key: str | None
+ installed: bool
+ config_present: bool
+ config_path: Path
+ hooks_writable: bool = True
+ hooks_unavailable_reason: str | None = None
+ scan_issue: str | None = None
+ entries: tuple[HookObservedEntry, ...] = ()
+
+
+@dataclass(frozen=True)
+class HookInventoryEntry:
+ id: str
+ display_name: str
+ spec: HookSpec | None
+ sightings: tuple[HookBinding, ...]
+ is_managed: bool
+ can_enable: bool = True
+
+ @property
+ def kind(self) -> str:
+ return "managed" if self.is_managed else "unmanaged"
+
+
+@dataclass(frozen=True)
+class HookInventoryIssue:
+ name: str
+ reason: str
+
+
+@dataclass(frozen=True)
+class HookInventory:
+ columns: tuple[str, ...]
+ entries: tuple[HookInventoryEntry, ...]
+ issues: tuple[HookInventoryIssue, ...] = ()
+
+
+class HookHarnessAdapter(Protocol):
+ harness: str
+ label: str
+ logo_key: str | None
+ config_path: Path
+
+ def status(self) -> HookHarnessStatus: ...
+
+ def scan(self, specs: tuple[HookSpec, ...]) -> HookHarnessScan: ...
+
+ def has_binding(self, id: str) -> bool: ...
+
+ def enable_hook(self, spec: HookSpec) -> None: ...
+
+ def disable_hook(self, id: str) -> None: ...
+
+ def invalidate(self) -> None: ...
+
+
+__all__ = [
+ "BindingState",
+ "HookBinding",
+ "HookHarnessAdapter",
+ "HookHarnessScan",
+ "HookHarnessStatus",
+ "HookInventory",
+ "HookInventoryEntry",
+ "HookInventoryIssue",
+ "HookObservedEntry",
+]
diff --git a/skill_manager/application/hooks/harness_application.py b/skill_manager/application/hooks/harness_application.py
new file mode 100644
index 0000000..14ff721
--- /dev/null
+++ b/skill_manager/application/hooks/harness_application.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Callable, Iterable, Literal
+
+from .contracts import HookHarnessAdapter
+from .read_models import HooksReadModelService
+from .store import HookSpec
+
+
+HarnessAction = Literal["enable", "disable"]
+ManifestCommit = Callable[[], None]
+
+
+@dataclass(frozen=True)
+class HooksHarnessApplicationResult:
+ succeeded: list[str]
+ failed: list[dict[str, str]]
+
+ @property
+ def ok(self) -> bool:
+ return not self.failed
+
+ @property
+ def changed(self) -> bool:
+ return bool(self.succeeded)
+
+ def to_dict(self) -> dict[str, object]:
+ return {
+ "ok": self.ok,
+ "succeeded": self.succeeded,
+ "failed": self.failed,
+ }
+
+
+class HooksHarnessApplication:
+ def __init__(self, read_models: HooksReadModelService) -> None:
+ self.read_models = read_models
+
+ def enable_one(
+ self,
+ adapter: HookHarnessAdapter,
+ spec: HookSpec,
+ *,
+ commit: ManifestCommit | None = None,
+ ) -> HooksHarnessApplicationResult:
+ try:
+ adapter.enable_hook(spec)
+ except Exception as error: # noqa: BLE001
+ return HooksHarnessApplicationResult(
+ succeeded=[],
+ failed=[{"harness": adapter.harness, "error": str(error)}],
+ )
+ if commit is not None:
+ commit()
+ self.read_models.invalidate()
+ return HooksHarnessApplicationResult(succeeded=[adapter.harness], failed=[])
+
+ def enable_many(
+ self,
+ spec: HookSpec,
+ harnesses: Iterable[str],
+ *,
+ skip_harnesses: Iterable[str] = (),
+ commit: ManifestCommit | None = None,
+ ) -> HooksHarnessApplicationResult:
+ targets = set(harnesses)
+ skipped = set(skip_harnesses)
+ adapters = self.read_models.enabled_adapters()
+ succeeded: list[str] = []
+ failed: list[dict[str, str]] = []
+ for adapter in adapters:
+ if adapter.harness not in targets or adapter.harness in skipped:
+ continue
+ try:
+ adapter.enable_hook(spec)
+ except Exception as error: # noqa: BLE001
+ failed.append({"harness": adapter.harness, "error": str(error)})
+ continue
+ succeeded.append(adapter.harness)
+
+ if succeeded:
+ if commit is not None:
+ commit()
+ self.read_models.invalidate()
+ return HooksHarnessApplicationResult(succeeded=succeeded, failed=failed)
+
+ def disable_many(
+ self,
+ id: str,
+ harnesses: Iterable[str],
+ *,
+ remove_after_full_success: Callable[[], None] | None = None,
+ ) -> HooksHarnessApplicationResult:
+ targets = set(harnesses)
+ adapters = self.read_models.enabled_adapters()
+ succeeded: list[str] = []
+ failed: list[dict[str, str]] = []
+ for adapter in adapters:
+ if adapter.harness not in targets:
+ continue
+ try:
+ adapter.disable_hook(id)
+ except Exception as error: # noqa: BLE001
+ failed.append({"harness": adapter.harness, "error": str(error)})
+ continue
+ succeeded.append(adapter.harness)
+
+ if not failed and remove_after_full_success is not None:
+ remove_after_full_success()
+ if succeeded or (not failed and remove_after_full_success is not None):
+ self.read_models.invalidate()
+ return HooksHarnessApplicationResult(succeeded=succeeded, failed=failed)
+
+
+__all__ = ["HooksHarnessApplication", "HooksHarnessApplicationResult"]
diff --git a/skill_manager/application/hooks/inventory.py b/skill_manager/application/hooks/inventory.py
new file mode 100644
index 0000000..ca5fcf2
--- /dev/null
+++ b/skill_manager/application/hooks/inventory.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from typing import Iterable
+
+from .contracts import (
+ HookBinding,
+ HookHarnessScan,
+ HookInventory,
+ HookInventoryEntry,
+ HookInventoryIssue,
+)
+from .store import HookSpec
+
+
+def build_inventory(
+ *,
+ managed_hooks: Iterable[HookSpec],
+ specs: Iterable[HookSpec],
+ scans: Iterable[HookHarnessScan],
+ issues: Iterable[HookInventoryIssue] = (),
+) -> HookInventory:
+ scans_tuple = tuple(scans)
+ specs_tuple = tuple(specs)
+ managed_tuple = tuple(managed_hooks)
+ columns = tuple(scan.harness for scan in scans_tuple)
+
+ bindings_by_id: dict[str, list[HookBinding]] = {}
+ for scan in scans_tuple:
+ for entry in scan.entries:
+ binding = HookBinding(
+ harness=scan.harness,
+ id=entry.id,
+ state=entry.state,
+ drift_detail=entry.drift_detail,
+ caveat=entry.caveat,
+ )
+ bindings_by_id.setdefault(entry.id, []).append(binding)
+
+ spec_by_id = {spec.id: spec for spec in specs_tuple}
+ entries: list[HookInventoryEntry] = []
+ seen: set[str] = set()
+
+ for hook in sorted(managed_tuple, key=lambda h: h.id.lower()):
+ spec = spec_by_id.get(hook.id)
+ bindings = tuple(bindings_by_id.get(hook.id, ()))
+ entries.append(
+ HookInventoryEntry(
+ id=hook.id,
+ display_name=hook.id,
+ spec=spec,
+ sightings=bindings,
+ is_managed=True,
+ can_enable=spec is not None,
+ )
+ )
+ seen.add(hook.id)
+
+ for id in sorted(id for id in bindings_by_id if id not in seen):
+ entries.append(
+ HookInventoryEntry(
+ id=id,
+ display_name=id,
+ spec=spec_by_id.get(id),
+ sightings=tuple(bindings_by_id[id]),
+ is_managed=False,
+ can_enable=True,
+ )
+ )
+
+ return HookInventory(columns=columns, entries=tuple(entries), issues=tuple(issues))
+
+
+__all__ = ["build_inventory"]
diff --git a/skill_manager/application/hooks/managed_state.py b/skill_manager/application/hooks/managed_state.py
new file mode 100644
index 0000000..f6893d9
--- /dev/null
+++ b/skill_manager/application/hooks/managed_state.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from typing import Mapping
+
+from .contracts import HookBinding, HookHarnessScan, HookInventory, HookInventoryEntry
+from .store import HookSpec
+
+
+def inventory_payload(
+ inventory: HookInventory,
+ scans: tuple[HookHarnessScan, ...],
+) -> dict[str, object]:
+ visible_harnesses = {scan.harness for scan in scans}
+ return {
+ "columns": [
+ {
+ "harness": scan.harness,
+ "label": scan.label,
+ "logoKey": scan.logo_key,
+ "installed": scan.installed,
+ "configPresent": scan.config_present,
+ "hooksWritable": scan.hooks_writable,
+ "hooksUnavailableReason": scan.hooks_unavailable_reason,
+ }
+ for scan in scans
+ ],
+ "entries": [
+ entry_payload(
+ entry,
+ scans,
+ )
+ for entry in inventory.entries
+ if entry.kind == "managed"
+ or any(binding.harness in visible_harnesses for binding in entry.sightings)
+ ],
+ "issues": [
+ {"name": issue.name, "reason": issue.reason}
+ for issue in inventory.issues
+ ],
+ }
+
+
+def entry_payload(
+ entry: HookInventoryEntry,
+ scans: tuple[HookHarnessScan, ...],
+) -> dict[str, object]:
+ visible_harnesses = {scan.harness for scan in scans}
+ addressable_harnesses = _addressable_harnesses(scans)
+ spec_payload = entry.spec.to_dict() if entry.spec is not None else None
+ enabled_status = _entry_enabled_status(entry, addressable_harnesses)
+
+ return {
+ "id": entry.id,
+ "displayName": entry.display_name,
+ "kind": entry.kind,
+ "spec": spec_payload,
+ "canEnable": entry.can_enable,
+ "enabledStatus": enabled_status,
+ "sightings": [
+ _binding_to_dict(binding)
+ for binding in entry.sightings
+ if binding.harness in visible_harnesses
+ ],
+ }
+
+
+def _binding_to_dict(binding: HookBinding) -> dict[str, object]:
+ payload: dict[str, object] = {
+ "harness": binding.harness,
+ "state": binding.state,
+ }
+ if binding.drift_detail:
+ payload["driftDetail"] = binding.drift_detail
+ if binding.caveat:
+ payload["caveat"] = binding.caveat
+ return payload
+
+
+def _is_scan_addressable(scan: HookHarnessScan) -> bool:
+ return scan.hooks_writable and (scan.installed or scan.config_present)
+
+
+def _addressable_harnesses(scans: tuple[HookHarnessScan, ...]) -> set[str]:
+ return {
+ scan.harness
+ for scan in scans
+ if _is_scan_addressable(scan)
+ }
+
+
+def _entry_enabled_status(
+ entry: HookInventoryEntry,
+ addressable_harnesses: set[str],
+) -> str:
+ for binding in entry.sightings:
+ if binding.harness in addressable_harnesses and binding.state == "managed":
+ return "enabled"
+ return "disabled"
+
+
+__all__ = [
+ "entry_payload",
+ "inventory_payload",
+]
diff --git a/skill_manager/application/hooks/mappers.py b/skill_manager/application/hooks/mappers.py
new file mode 100644
index 0000000..4e935d6
--- /dev/null
+++ b/skill_manager/application/hooks/mappers.py
@@ -0,0 +1,897 @@
+from __future__ import annotations
+
+import hashlib
+import shlex
+from typing import Iterable, Mapping, Protocol
+
+from skill_manager.errors import MutationError
+from .store import HookSpec
+
+
+class RawHookEntry:
+ def __init__(self, id: str, event: str, match: str | None, payload: dict[str, object]):
+ self.id = id
+ self.event = event
+ self.match = match
+ self.payload = payload
+
+
+class HookMapper(Protocol):
+ """Translates between HookSpec and a single harness's hook configuration shape."""
+
+ def read_entries(self, document: dict[str, object], specs: Iterable[HookSpec] = ()) -> list[RawHookEntry]: ...
+
+ def enable_hook(self, document: dict[str, object], spec: HookSpec) -> None: ...
+
+ def disable_hook(self, document: dict[str, object], id: str, command: str | None = None) -> None: ...
+
+ def spec_to_dict(self, spec: HookSpec) -> dict[str, object]: ...
+
+ def dict_to_spec(
+ self,
+ event: str,
+ match: str | None,
+ raw: Mapping[str, object],
+ ) -> HookSpec: ...
+
+ def representable(self, spec: HookSpec) -> tuple[bool, str | None, str | None]: ...
+
+
+class ClaudeCodeHooksMapper:
+ """Mapper for Claude Code hooks under ~/.claude/settings.json."""
+
+ def representable(self, spec: HookSpec) -> tuple[bool, str | None, str | None]:
+ supported_events = {"pre_tool_use", "post_tool_use", "user_prompt_submit", "session_start", "stop", "pre_compact"}
+ if spec.event not in supported_events:
+ return False, f"Event '{spec.event}' is not supported by Claude Code", None
+ if spec.event in ("pre_tool_use", "post_tool_use"):
+ supported_categories = {"any", "shell", "file_read", "file_write", "mcp", "web"}
+ cat = spec.match or "any"
+ if cat not in supported_categories:
+ return False, f"Tool category '{cat}' is not supported by Claude Code", None
+ return True, None, None
+
+ def spec_to_dict(self, spec: HookSpec) -> dict[str, object]:
+ payload: dict[str, object] = {
+ "type": "command",
+ "command": spec.command,
+ "id": spec.id,
+ }
+ if spec.timeout is not None:
+ payload["timeout"] = spec.timeout
+ return payload
+
+ def dict_to_spec(
+ self,
+ event: str,
+ match: str | None,
+ raw: Mapping[str, object],
+ ) -> HookSpec:
+ command = raw.get("command")
+ if not isinstance(command, str):
+ raise MutationError("Hook command must be a string", status=400)
+
+ timeout_raw = raw.get("timeout")
+ timeout: int | None = None
+ if isinstance(timeout_raw, (int, float)):
+ timeout = int(timeout_raw)
+ elif isinstance(timeout_raw, str) and timeout_raw.isdigit():
+ timeout = int(timeout_raw)
+
+ raw_id = raw.get("id")
+ if isinstance(raw_id, str) and raw_id:
+ hook_id = raw_id
+ else:
+ cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]
+ hook_id = f"manual:{cmd_hash}"
+
+ return HookSpec(
+ id=hook_id,
+ event=event,
+ command=command,
+ match=match,
+ timeout=timeout,
+ )
+
+ def read_entries(self, document: dict[str, object], specs: Iterable[HookSpec] = ()) -> list[RawHookEntry]:
+ hooks_subtree = document.get("hooks")
+ if not isinstance(hooks_subtree, dict):
+ return []
+
+ event_map = {
+ "PreToolUse": "pre_tool_use",
+ "PostToolUse": "post_tool_use",
+ "UserPromptSubmit": "user_prompt_submit",
+ "SessionStart": "session_start",
+ "Stop": "stop",
+ "PreCompact": "pre_compact",
+ }
+
+ matcher_map = {
+ "Bash": "shell",
+ "Read": "file_read",
+ "Edit|Write": "file_write",
+ "Edit": "file_write",
+ "Write": "file_write",
+ "mcp__.*": "mcp",
+ "WebFetch": "web",
+ "*": "any",
+ }
+
+ entries: list[RawHookEntry] = []
+ for native_event, matcher_groups in hooks_subtree.items():
+ if not isinstance(matcher_groups, list):
+ continue
+ canonical_event = event_map.get(native_event, native_event)
+ for group in matcher_groups:
+ if not isinstance(group, dict):
+ continue
+ native_matcher = group.get("matcher")
+ canonical_match = matcher_map.get(native_matcher, native_matcher) if native_matcher is not None else None
+ hooks_list = group.get("hooks", [])
+ if not isinstance(hooks_list, list):
+ continue
+ for hook in hooks_list:
+ if not isinstance(hook, dict):
+ continue
+ command = hook.get("command", "")
+ raw_id = hook.get("id")
+ if isinstance(raw_id, str) and raw_id:
+ hook_id = raw_id
+ else:
+ matched_id = None
+ for s in specs:
+ if s.event == canonical_event and s.match == canonical_match and s.command == command:
+ matched_id = s.id
+ break
+ if matched_id:
+ hook_id = matched_id
+ else:
+ cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]
+ hook_id = f"manual:{cmd_hash}"
+ entries.append(
+ RawHookEntry(
+ id=hook_id,
+ event=canonical_event,
+ match=canonical_match,
+ payload=dict(hook),
+ )
+ )
+ return entries
+
+ def enable_hook(self, document: dict[str, object], spec: HookSpec) -> None:
+ if "hooks" not in document:
+ document["hooks"] = {}
+ hooks_subtree = document["hooks"]
+ if not isinstance(hooks_subtree, dict):
+ raise MutationError("The top-level 'hooks' key is not an object", status=409)
+
+ event_map = {
+ "pre_tool_use": "PreToolUse",
+ "post_tool_use": "PostToolUse",
+ "user_prompt_submit": "UserPromptSubmit",
+ "session_start": "SessionStart",
+ "stop": "Stop",
+ "pre_compact": "PreCompact",
+ }
+ native_event = event_map.get(spec.event, spec.event)
+
+ matcher_map = {
+ "shell": "Bash",
+ "file_read": "Read",
+ "file_write": "Edit|Write",
+ "mcp": "mcp__.*",
+ "web": "WebFetch",
+ "any": "*",
+ }
+ native_matcher = matcher_map.get(spec.match) if spec.match else None
+
+ if native_event not in hooks_subtree:
+ hooks_subtree[native_event] = []
+ event_list = hooks_subtree[native_event]
+ if not isinstance(event_list, list):
+ raise MutationError(f"The hook event '{native_event}' is not an array", status=409)
+
+ target_group = None
+ for group in event_list:
+ if not isinstance(group, dict):
+ continue
+ group_matcher = group.get("matcher")
+ if native_matcher is None:
+ if "matcher" not in group or group_matcher is None:
+ target_group = group
+ break
+ else:
+ if group_matcher == native_matcher:
+ target_group = group
+ break
+
+ if target_group is None:
+ target_group = {"hooks": []}
+ if native_matcher is not None:
+ target_group["matcher"] = native_matcher
+ event_list.append(target_group)
+
+ if "hooks" not in target_group or not isinstance(target_group["hooks"], list):
+ target_group["hooks"] = []
+
+ hook_payload = self.spec_to_dict(spec)
+ hooks_list = target_group["hooks"]
+
+ updated = False
+ for idx, entry in enumerate(hooks_list):
+ if isinstance(entry, dict) and entry.get("id") == spec.id:
+ hooks_list[idx] = hook_payload
+ updated = True
+ break
+ if not updated:
+ hooks_list.append(hook_payload)
+
+ # Cleanup other matcher groups or events
+ for ev, ev_list in list(hooks_subtree.items()):
+ if not isinstance(ev_list, list):
+ continue
+ for grp in list(ev_list):
+ if not isinstance(grp, dict) or "hooks" not in grp or not isinstance(grp["hooks"], list):
+ continue
+ if ev == native_event and grp is target_group:
+ grp["hooks"] = [h for h in grp["hooks"] if not (isinstance(h, dict) and h.get("id") == spec.id and h is not hook_payload)]
+ continue
+ grp["hooks"] = [h for h in grp["hooks"] if not (isinstance(h, dict) and h.get("id") == spec.id)]
+ if not grp["hooks"]:
+ ev_list.remove(grp)
+ if not ev_list:
+ hooks_subtree.pop(ev, None)
+
+ def disable_hook(self, document: dict[str, object], id: str, command: str | None = None) -> None:
+ hooks_subtree = document.get("hooks")
+ if not isinstance(hooks_subtree, dict):
+ return
+
+ removed = False
+ for ev, ev_list in list(hooks_subtree.items()):
+ if not isinstance(ev_list, list):
+ continue
+ for grp in list(ev_list):
+ if not isinstance(grp, dict) or "hooks" not in grp or not isinstance(grp["hooks"], list):
+ continue
+ orig_len = len(grp["hooks"])
+
+ def matches(h: object) -> bool:
+ if not isinstance(h, dict):
+ return False
+ hid = h.get("id")
+ hcmd = h.get("command")
+ if hid == id:
+ return True
+ if command and hcmd == command:
+ return True
+ if id.startswith("manual:") and isinstance(hcmd, str):
+ hcmd_hash = hashlib.sha256(hcmd.encode("utf-8")).hexdigest()[:16]
+ if f"manual:{hcmd_hash}" == id:
+ return True
+ return False
+
+ grp["hooks"] = [h for h in grp["hooks"] if not matches(h)]
+ if len(grp["hooks"]) < orig_len:
+ removed = True
+ if not grp["hooks"]:
+ ev_list.remove(grp)
+ if not ev_list:
+ hooks_subtree.pop(ev, None)
+
+ if not hooks_subtree:
+ document.pop("hooks", None)
+
+
+class CodexHooksMapper(ClaudeCodeHooksMapper):
+ """Mapper for OpenAI Codex hooks under ~/.codex/config.toml."""
+
+ def representable(self, spec: HookSpec) -> tuple[bool, str | None, str | None]:
+ supported_events = {"pre_tool_use", "post_tool_use", "user_prompt_submit", "session_start", "stop", "pre_compact"}
+ if spec.event not in supported_events:
+ return False, f"Event '{spec.event}' is not supported by Codex", None
+ if spec.event in ("pre_tool_use", "post_tool_use"):
+ supported_categories = {"any", "shell", "file_read", "file_write", "mcp", "web"}
+ cat = spec.match or "any"
+ if cat not in supported_categories:
+ return False, f"Tool category '{cat}' is not supported by Codex", None
+ return True, None, None
+
+
+class CursorHooksMapper:
+ """Mapper for Cursor hooks under ~/.cursor/hooks.json."""
+
+ def representable(self, spec: HookSpec) -> tuple[bool, str | None, str | None]:
+ supported_events = {"pre_tool_use", "post_tool_use", "user_prompt_submit", "session_start", "stop", "pre_compact"}
+ if spec.event not in supported_events:
+ return False, f"Event '{spec.event}' is not supported by Cursor", None
+ if spec.event in ("pre_tool_use", "post_tool_use"):
+ supported_categories = {"any", "shell", "file_read", "file_write", "mcp"}
+ cat = spec.match or "any"
+ if cat not in supported_categories:
+ return False, f"Tool category '{cat}' is not supported by Cursor", None
+ return True, None, None
+
+ def spec_to_dict(self, spec: HookSpec) -> dict[str, object]:
+ return {"command": spec.command}
+
+ def dict_to_spec(
+ self,
+ event: str,
+ match: str | None,
+ raw: Mapping[str, object],
+ ) -> HookSpec:
+ command = raw.get("command")
+ if not isinstance(command, str):
+ raise MutationError("Hook command must be a string", status=400)
+ cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]
+ return HookSpec(
+ id=f"manual:{cmd_hash}",
+ event=event,
+ command=command,
+ match=match,
+ )
+
+ def _event_mapping(self) -> dict[tuple[str, str | None], str]:
+ return {
+ ("pre_tool_use", "shell"): "beforeShellExecution",
+ ("post_tool_use", "shell"): "afterShellExecution",
+ ("pre_tool_use", "file_read"): "beforeReadFile",
+ ("post_tool_use", "file_write"): "afterFileEdit",
+ ("pre_tool_use", "mcp"): "beforeMCPExecution",
+ ("post_tool_use", "mcp"): "afterMCPExecution",
+ ("pre_tool_use", "any"): "preToolUse",
+ ("post_tool_use", "any"): "postToolUse",
+ ("user_prompt_submit", None): "beforeSubmitPrompt",
+ ("session_start", None): "sessionStart",
+ ("stop", None): "stop",
+ ("pre_compact", None): "preCompact",
+ }
+
+ def read_entries(self, document: dict[str, object], specs: Iterable[HookSpec] = ()) -> list[RawHookEntry]:
+ hooks_subtree = document.get("hooks")
+ if not isinstance(hooks_subtree, dict):
+ return []
+
+ rev_map: dict[str, tuple[str, str | None]] = {}
+ for (ev, cat), native in self._event_mapping().items():
+ rev_map[native] = (ev, cat)
+
+ entries: list[RawHookEntry] = []
+ for native_event, hook_list in hooks_subtree.items():
+ if not isinstance(hook_list, list) or native_event not in rev_map:
+ continue
+ canonical_event, canonical_match = rev_map[native_event]
+ for hook in hook_list:
+ if not isinstance(hook, dict):
+ continue
+ command = hook.get("command", "")
+
+ hook_id = None
+ for s in specs:
+ if s.event == canonical_event and s.match == canonical_match and s.command == command:
+ hook_id = s.id
+ break
+ if not hook_id:
+ cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]
+ hook_id = f"manual:{cmd_hash}"
+
+ entries.append(
+ RawHookEntry(
+ id=hook_id,
+ event=canonical_event,
+ match=canonical_match,
+ payload=dict(hook),
+ )
+ )
+ return entries
+
+ def enable_hook(self, document: dict[str, object], spec: HookSpec) -> None:
+ document["version"] = 1
+ if "hooks" not in document:
+ document["hooks"] = {}
+ hooks_subtree = document["hooks"]
+ if not isinstance(hooks_subtree, dict):
+ raise MutationError("The 'hooks' key is not an object", status=409)
+
+ cat = spec.match or "any" if spec.event in ("pre_tool_use", "post_tool_use") else None
+ native_event = self._event_mapping().get((spec.event, cat))
+ if not native_event:
+ raise MutationError(f"Event ({spec.event}, {cat}) is not supportable on Cursor", status=400)
+
+ if native_event not in hooks_subtree:
+ hooks_subtree[native_event] = []
+ hook_list = hooks_subtree[native_event]
+ if not isinstance(hook_list, list):
+ raise MutationError(f"The hook event '{native_event}' is not an array", status=409)
+
+ hook_list = [h for h in hook_list if not (isinstance(h, dict) and h.get("command") == spec.command)]
+ hook_list.append(self.spec_to_dict(spec))
+ hooks_subtree[native_event] = hook_list
+
+ for ev, ev_list in list(hooks_subtree.items()):
+ if ev == native_event:
+ continue
+ if isinstance(ev_list, list):
+ hooks_subtree[ev] = [h for h in ev_list if not (isinstance(h, dict) and h.get("command") == spec.command)]
+ if not hooks_subtree[ev]:
+ hooks_subtree.pop(ev, None)
+
+ def disable_hook(self, document: dict[str, object], id: str, command: str | None = None) -> None:
+ hooks_subtree = document.get("hooks")
+ if not isinstance(hooks_subtree, dict):
+ return
+
+ removed = False
+ for ev, ev_list in list(hooks_subtree.items()):
+ if not isinstance(ev_list, list):
+ continue
+ orig_len = len(ev_list)
+
+ def matches(h: object) -> bool:
+ if not isinstance(h, dict):
+ return False
+ hcmd = h.get("command")
+ if command and hcmd == command:
+ return True
+ if isinstance(hcmd, str):
+ hcmd_hash = hashlib.sha256(hcmd.encode("utf-8")).hexdigest()[:16]
+ if f"manual:{hcmd_hash}" == id:
+ return True
+ return False
+
+ ev_list = [h for h in ev_list if not matches(h)]
+ if len(ev_list) < orig_len:
+ removed = True
+ if ev_list:
+ hooks_subtree[ev] = ev_list
+ else:
+ hooks_subtree.pop(ev, None)
+
+ if not hooks_subtree:
+ document.pop("hooks", None)
+
+
+class OpenCodeHooksMapper:
+ """Mapper for OpenCode hooks nested under experimental.hook in JSON."""
+
+ def representable(self, spec: HookSpec) -> tuple[bool, str | None, str | None]:
+ if spec.event == "stop":
+ return True, None, None
+ if spec.event == "post_tool_use" and spec.match == "file_write":
+ return True, None, None
+ return False, "Only 'stop' and 'post_tool_use' with 'file_write' match are supported on OpenCode", None
+
+ def spec_to_dict(self, spec: HookSpec) -> dict[str, object]:
+ return {"command": ["/bin/sh", "-c", spec.command]}
+
+ def dict_to_spec(
+ self,
+ event: str,
+ match: str | None,
+ raw: Mapping[str, object],
+ ) -> HookSpec:
+ argv = raw.get("command")
+ if not isinstance(argv, list):
+ raise MutationError("OpenCode hook command must be an argv list", status=400)
+
+ if len(argv) == 3 and argv[0] == "/bin/sh" and argv[1] == "-c":
+ command = argv[2]
+ else:
+ command = " ".join(str(arg) for arg in argv)
+
+ cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]
+ return HookSpec(
+ id=f"manual:{cmd_hash}",
+ event=event,
+ command=command,
+ match=match,
+ )
+
+ def read_entries(self, document: dict[str, object], specs: Iterable[HookSpec] = ()) -> list[RawHookEntry]:
+ exp = document.get("experimental")
+ if not isinstance(exp, dict):
+ return []
+ hook_subtree = exp.get("hook")
+ if not isinstance(hook_subtree, dict):
+ return []
+
+ entries: list[RawHookEntry] = []
+
+ file_edited = hook_subtree.get("file_edited")
+ if isinstance(file_edited, dict):
+ for glob, hook_list in file_edited.items():
+ if not isinstance(hook_list, list):
+ continue
+ for hook in hook_list:
+ if not isinstance(hook, dict):
+ continue
+ argv = hook.get("command")
+ if not isinstance(argv, list):
+ continue
+ if len(argv) == 3 and argv[0] == "/bin/sh" and argv[1] == "-c":
+ command = argv[2]
+ else:
+ command = " ".join(str(arg) for arg in argv)
+
+ hook_id = None
+ for s in specs:
+ if s.event == "post_tool_use" and s.match == "file_write" and s.command == command:
+ hook_id = s.id
+ break
+ if not hook_id:
+ cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]
+ hook_id = f"manual:{cmd_hash}"
+
+ entries.append(
+ RawHookEntry(
+ id=hook_id,
+ event="post_tool_use",
+ match="file_write",
+ payload=dict(hook),
+ )
+ )
+
+ session_completed = hook_subtree.get("session_completed")
+ if isinstance(session_completed, list):
+ for hook in session_completed:
+ if not isinstance(hook, dict):
+ continue
+ argv = hook.get("command")
+ if not isinstance(argv, list):
+ continue
+ if len(argv) == 3 and argv[0] == "/bin/sh" and argv[1] == "-c":
+ command = argv[2]
+ else:
+ command = " ".join(str(arg) for arg in argv)
+
+ hook_id = None
+ for s in specs:
+ if s.event == "stop" and s.command == command:
+ hook_id = s.id
+ break
+ if not hook_id:
+ cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]
+ hook_id = f"manual:{cmd_hash}"
+
+ entries.append(
+ RawHookEntry(
+ id=hook_id,
+ event="stop",
+ match=None,
+ payload=dict(hook),
+ )
+ )
+
+ return entries
+
+ def enable_hook(self, document: dict[str, object], spec: HookSpec) -> None:
+ if "experimental" not in document:
+ document["experimental"] = {}
+ exp = document["experimental"]
+ if not isinstance(exp, dict):
+ raise MutationError("The 'experimental' key is not an object", status=409)
+
+ if "hook" not in exp:
+ exp["hook"] = {}
+ hook_subtree = exp["hook"]
+ if not isinstance(hook_subtree, dict):
+ raise MutationError("The 'experimental.hook' key is not an object", status=409)
+
+ hook_payload = self.spec_to_dict(spec)
+
+ if spec.event == "stop":
+ if "session_completed" not in hook_subtree:
+ hook_subtree["session_completed"] = []
+ session_completed = hook_subtree["session_completed"]
+ if not isinstance(session_completed, list):
+ raise MutationError("The 'experimental.hook.session_completed' key is not an array", status=409)
+ session_completed = [h for h in session_completed if not (isinstance(h, dict) and h.get("command") == hook_payload["command"])]
+ session_completed.append(hook_payload)
+ hook_subtree["session_completed"] = session_completed
+
+ file_edited = hook_subtree.get("file_edited")
+ if isinstance(file_edited, dict):
+ for glob, hook_list in list(file_edited.items()):
+ if isinstance(hook_list, list):
+ file_edited[glob] = [h for h in hook_list if not (isinstance(h, dict) and h.get("command") == hook_payload["command"])]
+ if not file_edited[glob]:
+ file_edited.pop(glob, None)
+ if not file_edited:
+ hook_subtree.pop("file_edited", None)
+
+ elif spec.event == "post_tool_use" and spec.match == "file_write":
+ if "file_edited" not in hook_subtree:
+ hook_subtree["file_edited"] = {}
+ file_edited = hook_subtree["file_edited"]
+ if not isinstance(file_edited, dict):
+ raise MutationError("The 'experimental.hook.file_edited' key is not an object", status=409)
+ if "*" not in file_edited:
+ file_edited["*"] = []
+ hook_list = file_edited["*"]
+ if not isinstance(hook_list, list):
+ raise MutationError("The 'experimental.hook.file_edited.*' key is not an array", status=409)
+ hook_list = [h for h in hook_list if not (isinstance(h, dict) and h.get("command") == hook_payload["command"])]
+ hook_list.append(hook_payload)
+ file_edited["*"] = hook_list
+
+ session_completed = hook_subtree.get("session_completed")
+ if isinstance(session_completed, list):
+ hook_subtree["session_completed"] = [h for h in session_completed if not (isinstance(h, dict) and h.get("command") == hook_payload["command"])]
+ if not hook_subtree["session_completed"]:
+ hook_subtree.pop("session_completed", None)
+ else:
+ raise MutationError(f"Unsupported event for OpenCode: {spec.event}", status=400)
+
+ def disable_hook(self, document: dict[str, object], id: str, command: str | None = None) -> None:
+ exp = document.get("experimental")
+ if not isinstance(exp, dict):
+ return
+ hook_subtree = exp.get("hook")
+ if not isinstance(hook_subtree, dict):
+ return
+
+ command_argv = ["/bin/sh", "-c", command] if command else None
+
+ def matches(h: object) -> bool:
+ if not isinstance(h, dict):
+ return False
+ argv = h.get("command")
+ if not isinstance(argv, list):
+ return False
+ if command_argv and argv == command_argv:
+ return True
+ if len(argv) == 3 and argv[0] == "/bin/sh" and argv[1] == "-c":
+ hcmd = argv[2]
+ else:
+ hcmd = " ".join(str(arg) for arg in argv)
+ hcmd_hash = hashlib.sha256(hcmd.encode("utf-8")).hexdigest()[:16]
+ if f"manual:{hcmd_hash}" == id:
+ return True
+ return False
+
+ file_edited = hook_subtree.get("file_edited")
+ if isinstance(file_edited, dict):
+ for glob, hook_list in list(file_edited.items()):
+ if isinstance(hook_list, list):
+ file_edited[glob] = [h for h in hook_list if not matches(h)]
+ if not file_edited[glob]:
+ file_edited.pop(glob, None)
+ if not file_edited:
+ hook_subtree.pop("file_edited", None)
+
+ session_completed = hook_subtree.get("session_completed")
+ if isinstance(session_completed, list):
+ hook_subtree["session_completed"] = [h for h in session_completed if not matches(h)]
+ if not hook_subtree["session_completed"]:
+ hook_subtree.pop("session_completed", None)
+
+ if not hook_subtree:
+ exp.pop("hook", None)
+ if not exp:
+ document.pop("experimental", None)
+
+
+class AntigravityHooksMapper:
+ """Mapper for Antigravity hooks under ~/.gemini/config/hooks.json."""
+
+ def representable(self, spec: HookSpec) -> tuple[bool, str | None, str | None]:
+ supported_events = {"pre_tool_use", "post_tool_use", "stop", "user_prompt_submit"}
+ if spec.event not in supported_events:
+ return False, f"Event '{spec.event}' is not supported by Antigravity", None
+ if spec.event in ("pre_tool_use", "post_tool_use"):
+ supported_categories = {"any", "shell", "file_read", "file_write", "web"}
+ cat = spec.match or "any"
+ if cat not in supported_categories:
+ return False, f"Tool category '{cat}' is not supported by Antigravity", None
+ if spec.event == "user_prompt_submit":
+ return (
+ True,
+ None,
+ "On Antigravity this maps to PreInvocation, which fires before every model invocation, not only on user-prompt submit.",
+ )
+ return True, None, None
+
+ def spec_to_dict(self, spec: HookSpec) -> dict[str, object]:
+ payload: dict[str, object] = {
+ "type": "command",
+ "command": spec.command,
+ }
+ if spec.timeout is not None:
+ payload["timeout"] = spec.timeout
+ return payload
+
+ def dict_to_spec(
+ self,
+ event: str,
+ match: str | None,
+ raw: Mapping[str, object],
+ ) -> HookSpec:
+ command = raw.get("command")
+ if not isinstance(command, str):
+ raise MutationError("Hook command must be a string", status=400)
+
+ timeout_raw = raw.get("timeout")
+ timeout: int | None = None
+ if isinstance(timeout_raw, (int, float)):
+ timeout = int(timeout_raw)
+ elif isinstance(timeout_raw, str) and timeout_raw.isdigit():
+ timeout = int(timeout_raw)
+
+ cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16]
+ return HookSpec(
+ id=f"manual:{cmd_hash}",
+ event=event,
+ command=command,
+ match=match,
+ timeout=timeout,
+ )
+
+ def read_entries(self, document: dict[str, object], specs: Iterable[HookSpec] = ()) -> list[RawHookEntry]:
+ event_map = {
+ "PreToolUse": "pre_tool_use",
+ "PostToolUse": "post_tool_use",
+ "Stop": "stop",
+ "PreInvocation": "user_prompt_submit",
+ }
+ matcher_map = {
+ "run_command": "shell",
+ "view_file": "file_read",
+ "write_to_file|replace_file_content|multi_replace_file_content": "file_write",
+ "read_url_content|search_web": "web",
+ "*": "any",
+ }
+
+ entries: list[RawHookEntry] = []
+ for hook_id, hook_entry in document.items():
+ if not isinstance(hook_entry, dict):
+ continue
+ for native_event, val in hook_entry.items():
+ if native_event == "enabled":
+ continue
+ canonical_event = event_map.get(native_event)
+ if not canonical_event:
+ continue
+
+ if native_event in ("Stop", "PreInvocation"):
+ if isinstance(val, list):
+ for hook in val:
+ if not isinstance(hook, dict):
+ continue
+ entries.append(
+ RawHookEntry(
+ id=hook_id,
+ event=canonical_event,
+ match=None,
+ payload=dict(hook),
+ )
+ )
+ else:
+ if isinstance(val, list):
+ for group in val:
+ if not isinstance(group, dict):
+ continue
+ native_matcher = group.get("matcher")
+ canonical_match = matcher_map.get(native_matcher, native_matcher) if native_matcher is not None else None
+ hooks_list = group.get("hooks", [])
+ if isinstance(hooks_list, list):
+ for hook in hooks_list:
+ if not isinstance(hook, dict):
+ continue
+ entries.append(
+ RawHookEntry(
+ id=hook_id,
+ event=canonical_event,
+ match=canonical_match,
+ payload=dict(hook),
+ )
+ )
+ return entries
+
+ def enable_hook(self, document: dict[str, object], spec: HookSpec) -> None:
+ if spec.id not in document:
+ document[spec.id] = {}
+ entry = document[spec.id]
+ if not isinstance(entry, dict):
+ raise MutationError(f"Antigravity hook entry '{spec.id}' is not an object", status=409)
+
+ entry["enabled"] = True
+
+ for k in list(entry.keys()):
+ if k != "enabled":
+ entry.pop(k)
+
+ hook_payload = self.spec_to_dict(spec)
+
+ if spec.event == "stop":
+ entry["Stop"] = [hook_payload]
+ elif spec.event == "user_prompt_submit":
+ entry["PreInvocation"] = [hook_payload]
+ elif spec.event in ("pre_tool_use", "post_tool_use"):
+ native_event = "PreToolUse" if spec.event == "pre_tool_use" else "PostToolUse"
+ matcher_map = {
+ "shell": "run_command",
+ "file_read": "view_file",
+ "file_write": "write_to_file|replace_file_content|multi_replace_file_content",
+ "web": "read_url_content|search_web",
+ "any": "*",
+ }
+ native_matcher = matcher_map.get(spec.match or "any", "*")
+ entry[native_event] = [
+ {
+ "matcher": native_matcher,
+ "hooks": [hook_payload],
+ }
+ ]
+ else:
+ raise MutationError(f"Unsupported event for Antigravity: {spec.event}", status=400)
+
+ def disable_hook(self, document: dict[str, object], id: str, command: str | None = None) -> None:
+ if id in document:
+ document.pop(id, None)
+ return
+
+ for hook_id, entry in list(document.items()):
+ if not isinstance(entry, dict):
+ continue
+ matches = False
+ for native_event, val in entry.items():
+ if native_event == "enabled":
+ continue
+ if native_event in ("Stop", "PreInvocation"):
+ if isinstance(val, list):
+ for hook in val:
+ if isinstance(hook, dict) and hook.get("command") == command:
+ matches = True
+ elif isinstance(hook, dict) and id.startswith("manual:"):
+ hcmd = hook.get("command")
+ if isinstance(hcmd, str):
+ hcmd_hash = hashlib.sha256(hcmd.encode("utf-8")).hexdigest()[:16]
+ if f"manual:{hcmd_hash}" == id:
+ matches = True
+ else:
+ if isinstance(val, list):
+ for group in val:
+ if not isinstance(group, dict):
+ continue
+ hooks_list = group.get("hooks", [])
+ if isinstance(hooks_list, list):
+ for hook in hooks_list:
+ if isinstance(hook, dict) and hook.get("command") == command:
+ matches = True
+ elif isinstance(hook, dict) and id.startswith("manual:"):
+ hcmd = hook.get("command")
+ if isinstance(hcmd, str):
+ hcmd_hash = hashlib.sha256(hcmd.encode("utf-8")).hexdigest()[:16]
+ if f"manual:{hcmd_hash}" == id:
+ matches = True
+ if matches:
+ document.pop(hook_id, None)
+
+
+_MAPPERS: dict[str, HookMapper] = {
+ "claude-code-hooks": ClaudeCodeHooksMapper(),
+ "codex-hooks": CodexHooksMapper(),
+ "cursor-hooks": CursorHooksMapper(),
+ "opencode-hooks": OpenCodeHooksMapper(),
+ "antigravity-hooks": AntigravityHooksMapper(),
+}
+
+
+def get_mapper(kind: str) -> HookMapper:
+ if kind not in _MAPPERS:
+ raise ValueError(f"unknown hooks mapper kind: {kind}")
+ return _MAPPERS[kind]
+
+
+__all__ = [
+ "ClaudeCodeHooksMapper",
+ "CodexHooksMapper",
+ "CursorHooksMapper",
+ "OpenCodeHooksMapper",
+ "AntigravityHooksMapper",
+ "HookMapper",
+ "RawHookEntry",
+ "get_mapper",
+]
diff --git a/skill_manager/application/hooks/mutations.py b/skill_manager/application/hooks/mutations.py
new file mode 100644
index 0000000..5ca3090
--- /dev/null
+++ b/skill_manager/application/hooks/mutations.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from typing import Iterable
+
+from skill_manager.errors import MutationError
+
+from .harness_application import HooksHarnessApplication
+from .read_models import HooksReadModelService
+from .store import HookSpec, HookStore
+
+
+class HooksMutationService:
+ """Mutations for observed Hooks."""
+
+ def __init__(
+ self,
+ *,
+ store: HookStore,
+ read_models: HooksReadModelService,
+ ) -> None:
+ self.store = store
+ self.read_models = read_models
+ self.harness_application = HooksHarnessApplication(read_models)
+
+ def create_hook(self, spec: HookSpec) -> HookSpec:
+ if not spec.id:
+ raise MutationError("id is required", status=400)
+ if self.store.get_managed(spec.id) is not None:
+ raise MutationError(
+ f"a hook named '{spec.id}' is already registered",
+ status=409,
+ )
+ stored = self.store.upsert_managed(spec)
+ self.read_models.invalidate()
+ return stored
+
+ def delete_hook(self, id: str) -> dict[str, object]:
+ if self.store.get_managed(id) is None:
+ raise MutationError(f"unknown hook: {id}", status=404)
+ bound_harnesses = self._harnesses_in_states(id, {"managed", "drifted"})
+ return self.harness_application.disable_many(
+ id,
+ bound_harnesses,
+ remove_after_full_success=lambda: self.store.remove(id),
+ ).to_dict()
+
+ def enable_hook(
+ self,
+ id: str,
+ harness: str,
+ ) -> dict[str, bool]:
+ spec = self._require_spec(id)
+ adapter = self.read_models.require_enabled_adapter(harness)
+ if adapter.has_binding(id):
+ return {"ok": True}
+ result = self.harness_application.enable_one(
+ adapter,
+ spec,
+ )
+ if result.failed:
+ raise MutationError(result.failed[0]["error"], status=400)
+ return {"ok": True}
+
+ def disable_hook(self, id: str, harness: str) -> dict[str, bool]:
+ if self.store.get_managed(id) is None:
+ raise MutationError(f"unknown hook: {id}", status=404)
+ adapter = self.read_models.require_enabled_adapter(harness)
+ adapter.disable_hook(id)
+ self.read_models.invalidate()
+ return {"ok": True}
+
+ def set_hook_all_harnesses(
+ self,
+ id: str,
+ target: str,
+ ) -> dict[str, object]:
+ if target not in ("enabled", "disabled"):
+ raise MutationError("target must be 'enabled' or 'disabled'", status=400)
+ spec = self._require_spec(id)
+ bound_now = self._harnesses_in_states(id, {"managed", "drifted"})
+
+ if target == "enabled":
+ return self.harness_application.enable_many(
+ spec,
+ self.read_models.enabled_harnesses(),
+ skip_harnesses=bound_now,
+ ).to_dict()
+ return self.harness_application.disable_many(
+ id,
+ bound_now,
+ ).to_dict()
+
+ def reconcile_hook(
+ self,
+ id: str,
+ *,
+ source_kind: str,
+ observed_harness: str | None = None,
+ harnesses: list[str] | None = None,
+ ) -> dict[str, object]:
+ current = self._require_spec(id)
+ target_harnesses = (
+ set(harnesses)
+ if harnesses is not None
+ else self._harnesses_in_states(id, {"managed", "drifted"})
+ )
+
+ if source_kind == "managed":
+ source_spec = current
+ elif source_kind == "harness":
+ if not observed_harness:
+ raise MutationError("observedHarness is required when sourceKind is 'harness'", status=400)
+ observed_spec = self._observed_spec(id, observed_harness)
+ source_spec = replace(
+ observed_spec,
+ id=current.id,
+ description=current.description,
+ )
+ self.store.upsert_managed(source_spec)
+ else:
+ raise MutationError("sourceKind must be 'managed' or 'harness'", status=400)
+
+ result = self.harness_application.enable_many(
+ source_spec,
+ target_harnesses,
+ )
+ stored = self.store.get_managed(id) or source_spec
+ return {
+ "ok": result.ok,
+ "hook": stored.to_dict(),
+ "succeeded": result.succeeded,
+ "failed": result.failed,
+ }
+
+ # Internal helpers -----------------------------------------------------
+
+ def _harnesses_in_states(
+ self,
+ id: str,
+ states: Iterable[str],
+ ) -> set[str]:
+ allowed_states = set(states)
+ addressable = set(self.read_models.enabled_harnesses())
+ snapshot = self.read_models.snapshot()
+ result: set[str] = set()
+ for scan in snapshot.harness_scans:
+ if scan.harness not in addressable:
+ continue
+ for entry in scan.entries:
+ if entry.id == id and entry.state in allowed_states:
+ result.add(scan.harness)
+ return result
+
+ def _observed_spec(self, id: str, harness: str) -> HookSpec:
+ snapshot = self.read_models.snapshot()
+ for scan in snapshot.harness_scans:
+ if scan.harness == harness:
+ for entry in scan.entries:
+ if entry.id == id and entry.parsed_spec is not None:
+ return entry.parsed_spec
+ raise MutationError(f"hook '{id}' was not observed in harness '{harness}'", status=400)
+
+ def _require_spec(self, id: str) -> HookSpec:
+ spec = self.store.get_managed(id)
+ if spec is None:
+ raise MutationError(f"unknown hook: {id}", status=404)
+ return spec
+
+
+__all__ = ["HooksMutationService"]
diff --git a/skill_manager/application/hooks/query.py b/skill_manager/application/hooks/query.py
new file mode 100644
index 0000000..56dcb64
--- /dev/null
+++ b/skill_manager/application/hooks/query.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from skill_manager.errors import MutationError
+
+from .contracts import HookHarnessScan, HookInventory, HookInventoryIssue
+from .inventory import build_inventory
+from .managed_state import entry_payload, inventory_payload
+from .read_models import HooksReadModelService
+
+
+class HooksQueryService:
+ """Read-side service exposing canonical hooks config and inventory views."""
+
+ def __init__(self, read_models: HooksReadModelService) -> None:
+ self.read_models = read_models
+
+ def list_hooks(self) -> dict[str, object]:
+ snapshot = self.read_models.snapshot()
+ inventory = self._inventory(snapshot.harness_scans)
+ return inventory_payload(
+ inventory,
+ self.read_models.visible_scans(snapshot),
+ )
+
+ def get_hook(self, id: str) -> dict[str, object]:
+ snapshot = self.read_models.snapshot()
+ inventory = self._inventory(snapshot.harness_scans)
+ visible_scans = self.read_models.visible_scans(snapshot)
+ for entry in inventory.entries:
+ if entry.id == id:
+ return entry_payload(
+ entry,
+ visible_scans,
+ )
+ raise MutationError(f"unknown hook: {id}", status=404)
+
+ def _inventory(self, scans: tuple[HookHarnessScan, ...]) -> HookInventory:
+ issues = [
+ HookInventoryIssue(name=issue.name, reason=issue.reason)
+ for issue in self.read_models.store.manifest_issues()
+ ]
+ issues.extend(
+ HookInventoryIssue(name=f"{scan.label} config", reason=scan.scan_issue)
+ for scan in scans
+ if scan.scan_issue
+ )
+ return build_inventory(
+ managed_hooks=self.read_models.store.list_managed(),
+ specs=self.read_models.store.list_managed(),
+ scans=scans,
+ issues=issues,
+ )
+
+
+__all__ = ["HooksQueryService"]
diff --git a/skill_manager/application/hooks/read_models.py b/skill_manager/application/hooks/read_models.py
new file mode 100644
index 0000000..6cdb049
--- /dev/null
+++ b/skill_manager/application/hooks/read_models.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import time
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
+from threading import Lock
+
+from skill_manager.errors import MutationError
+from skill_manager.harness import HarnessKernelService
+
+from .adapters import build_hooks_adapters
+from .contracts import HookHarnessAdapter, HookHarnessScan, HookHarnessStatus
+from .store import HookStore, HookSpec
+
+
+@dataclass(frozen=True)
+class HooksReadModelSnapshot:
+ harness_scans: tuple[HookHarnessScan, ...]
+
+
+@dataclass(frozen=True)
+class _CachedSnapshot:
+ snapshot: HooksReadModelSnapshot
+ captured_at: float
+
+
+class HooksReadModelService:
+ def __init__(
+ self,
+ *,
+ store: HookStore,
+ adapters: tuple[HookHarnessAdapter, ...],
+ kernel: HarnessKernelService,
+ snapshot_ttl_seconds: float = 1.0,
+ ) -> None:
+ self.store = store
+ self.adapters = adapters
+ self.kernel = kernel
+ self.snapshot_ttl_seconds = snapshot_ttl_seconds
+ self._cache: _CachedSnapshot | None = None
+ self._lock = Lock()
+
+ @classmethod
+ def from_kernel(
+ cls,
+ *,
+ store: HookStore,
+ kernel: HarnessKernelService,
+ ) -> "HooksReadModelService":
+ return cls(store=store, adapters=build_hooks_adapters(kernel), kernel=kernel)
+
+ def find_adapter(self, harness: str) -> HookHarnessAdapter | None:
+ return next((adapter for adapter in self.adapters if adapter.harness == harness), None)
+
+ def enabled_harnesses(self) -> tuple[str, ...]:
+ return self.kernel.enabled_harness_ids_for_family("hooks")
+
+ def visible_harnesses(self) -> tuple[str, ...]:
+ return self.enabled_harnesses()
+
+ def enabled_adapters(self) -> tuple[HookHarnessAdapter, ...]:
+ enabled = set(self.enabled_harnesses())
+ return tuple(adapter for adapter in self.adapters if adapter.harness in enabled)
+
+ def visible_scans(
+ self,
+ snapshot: HooksReadModelSnapshot | None = None,
+ ) -> tuple[HookHarnessScan, ...]:
+ current = snapshot or self.snapshot()
+ visible = set(self.visible_harnesses())
+ return tuple(scan for scan in current.harness_scans if scan.harness in visible)
+
+ def require_enabled_adapter(self, harness: str) -> HookHarnessAdapter:
+ adapter = self.find_adapter(harness)
+ if adapter is None:
+ raise MutationError(f"unknown harness: {harness}", status=400)
+ if harness not in self.enabled_harnesses():
+ raise MutationError(f"harness support is disabled: {harness}", status=400)
+ status = adapter.status()
+ if not status.installed and not status.config_present:
+ raise MutationError(
+ f"{adapter.label} is not installed and has no hooks config file",
+ status=400,
+ )
+ return adapter
+
+ def harness_statuses(self) -> tuple[HookHarnessStatus, ...]:
+ return tuple(adapter.status() for adapter in self.adapters)
+
+ def snapshot(self) -> HooksReadModelSnapshot:
+ with self._lock:
+ cached = self._cache
+ if cached is not None and (time.time() - cached.captured_at) < self.snapshot_ttl_seconds:
+ return cached.snapshot
+
+ specs = self.store.list_managed()
+ if not self.adapters:
+ scans: tuple[HookHarnessScan, ...] = ()
+ else:
+ with ThreadPoolExecutor(max_workers=max(2, len(self.adapters))) as executor:
+ scans = tuple(executor.map(lambda adapter: adapter.scan(specs), self.adapters))
+ snapshot = HooksReadModelSnapshot(harness_scans=scans)
+ with self._lock:
+ self._cache = _CachedSnapshot(snapshot=snapshot, captured_at=time.time())
+ return snapshot
+
+ def invalidate(self) -> None:
+ with self._lock:
+ self._cache = None
+ for adapter in self.adapters:
+ adapter.invalidate()
+
+
+__all__ = ["HooksReadModelService", "HooksReadModelSnapshot"]
diff --git a/skill_manager/application/hooks/store.py b/skill_manager/application/hooks/store.py
new file mode 100644
index 0000000..f9567e5
--- /dev/null
+++ b/skill_manager/application/hooks/store.py
@@ -0,0 +1,238 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass, field, replace
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Mapping
+
+from skill_manager.atomic_files import atomic_write_text, file_lock
+
+
+CURRENT_HOOKS_MANIFEST_VERSION = 1
+
+
+@dataclass(frozen=True)
+class HookManifestIssue:
+ name: str
+ reason: str
+
+ def to_dict(self) -> dict[str, str]:
+ return {"name": self.name, "reason": self.reason}
+
+
+@dataclass(frozen=True)
+class HookSpec:
+ id: str
+ event: str
+ command: str
+ match: str | None = None
+ timeout: int | None = None
+ description: str = ""
+ installed_at: str = ""
+ revision: str = ""
+
+ def to_dict(self) -> dict[str, object]:
+ payload: dict[str, object] = {
+ "id": self.id,
+ "event": self.event,
+ "command": self.command,
+ "description": self.description,
+ "installedAt": self.installed_at,
+ "revision": self.revision,
+ }
+ if self.match is not None:
+ payload["match"] = self.match
+ if self.timeout is not None:
+ payload["timeout"] = self.timeout
+ return payload
+
+ @classmethod
+ def from_dict(cls, payload: Mapping[str, object]) -> HookSpec:
+ event = str(payload["event"])
+ event_map = {
+ "PreToolUse": "pre_tool_use",
+ "PostToolUse": "post_tool_use",
+ "UserPromptSubmit": "user_prompt_submit",
+ "SessionStart": "session_start",
+ "Stop": "stop",
+ "PreCompact": "pre_compact",
+ }
+ event = event_map.get(event, event)
+
+ match = _optional_str(payload.get("match"))
+ matcher = _optional_str(payload.get("matcher"))
+ if match is None and matcher is not None:
+ matcher_map = {
+ "Bash": "shell",
+ "Read": "file_read",
+ "Edit|Write": "file_write",
+ "Edit": "file_write",
+ "Write": "file_write",
+ "mcp__.*": "mcp",
+ "WebFetch": "web",
+ "*": "any",
+ }
+ match = matcher_map.get(matcher, matcher)
+
+ return cls(
+ id=str(payload["id"]),
+ event=event,
+ command=str(payload["command"]),
+ match=match,
+ timeout=_optional_int(payload.get("timeout")),
+ description=str(payload.get("description", "")),
+ installed_at=str(payload.get("installedAt", "")),
+ revision=str(payload.get("revision", "")),
+ )
+
+
+@dataclass(frozen=True)
+class HookManagedManifest:
+ entries: tuple[HookSpec, ...] = field(default_factory=tuple)
+
+ def to_dict(self) -> dict[str, object]:
+ return {
+ "version": CURRENT_HOOKS_MANIFEST_VERSION,
+ "hooks": [entry.to_dict() for entry in self.entries],
+ }
+
+
+@dataclass(frozen=True)
+class _ManifestLoadResult:
+ manifest: HookManagedManifest
+ issues: tuple[HookManifestIssue, ...] = ()
+
+
+def _optional_str(value: object) -> str | None:
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
+def _optional_int(value: object) -> int | None:
+ if isinstance(value, (int, float)):
+ return int(value)
+ if isinstance(value, str) and value.isdigit():
+ return int(value)
+ return None
+
+
+def compute_revision(spec: HookSpec) -> str:
+ payload = {
+ "id": spec.id,
+ "event": spec.event,
+ "command": spec.command,
+ "match": spec.match,
+ "timeout": spec.timeout,
+ }
+ digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
+ return digest[:16]
+
+
+def now_iso() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def prepare_managed_spec(spec: HookSpec) -> HookSpec:
+ stamped = spec if spec.installed_at else replace(spec, installed_at=now_iso())
+ return replace(stamped, revision=compute_revision(stamped))
+
+
+def write_hooks_manifest(path: Path, manifest: HookManagedManifest) -> None:
+ atomic_write_text(
+ path,
+ json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2, sort_keys=False) + "\n",
+ )
+
+
+class HookStore:
+ """Cleartext local manifest of canonical observed Hooks."""
+
+ def __init__(self, manifest_path: Path) -> None:
+ self.manifest_path = manifest_path
+
+ @property
+ def _lock_path(self) -> Path:
+ return self.manifest_path.with_suffix(".lock")
+
+ def list_managed(self) -> tuple[HookSpec, ...]:
+ return self._load_manifest_result().manifest.entries
+
+ def get_managed(self, id: str) -> HookSpec | None:
+ for spec in self.list_managed():
+ if spec.id == id:
+ return spec
+ return None
+
+ def upsert_managed(self, spec: HookSpec) -> HookSpec:
+ with file_lock(self._lock_path):
+ manifest = self._load_manifest_result().manifest
+ stamped = prepare_managed_spec(spec)
+ new_entries = tuple(
+ stamped if entry.id == stamped.id else entry for entry in manifest.entries
+ )
+ if not any(entry.id == stamped.id for entry in manifest.entries):
+ new_entries = manifest.entries + (stamped,)
+ write_hooks_manifest(self.manifest_path, HookManagedManifest(entries=new_entries))
+ return stamped
+
+ def remove(self, id: str) -> bool:
+ with file_lock(self._lock_path):
+ manifest = self._load_manifest_result().manifest
+ new_entries = tuple(entry for entry in manifest.entries if entry.id != id)
+ if len(new_entries) == len(manifest.entries):
+ return False
+ write_hooks_manifest(self.manifest_path, HookManagedManifest(entries=new_entries))
+ return True
+
+ def manifest_issues(self) -> tuple[HookManifestIssue, ...]:
+ return self._load_manifest_result().issues
+
+ def _load_manifest_result(self) -> _ManifestLoadResult:
+ if not self.manifest_path.is_file():
+ return _ManifestLoadResult(HookManagedManifest())
+ try:
+ payload = json.loads(self.manifest_path.read_text(encoding="utf-8"))
+ except Exception as error:
+ return _ManifestLoadResult(
+ HookManagedManifest(),
+ issues=(HookManifestIssue(name="", reason=str(error)),),
+ )
+ raw_entries = payload.get("hooks", [])
+ if not isinstance(raw_entries, list):
+ return _ManifestLoadResult(
+ HookManagedManifest(),
+ issues=(HookManifestIssue(name="", reason="'hooks' must be a list"),),
+ )
+ records = []
+ issues: list[HookManifestIssue] = []
+ for item in raw_entries:
+ if not isinstance(item, dict):
+ issues.append(HookManifestIssue(name="", reason="hook entry must be an object"))
+ continue
+ id_ = str(item.get("id", ""))
+ try:
+ record = HookSpec.from_dict(item)
+ records.append(record)
+ except (KeyError, TypeError, ValueError) as error:
+ issues.append(HookManifestIssue(name=id_, reason=str(error) or error.__class__.__name__))
+ continue
+ return _ManifestLoadResult(
+ HookManagedManifest(entries=tuple(records)),
+ issues=tuple(issues),
+ )
+
+
+__all__ = [
+ "CURRENT_HOOKS_MANIFEST_VERSION",
+ "HookManagedManifest",
+ "HookManifestIssue",
+ "HookSpec",
+ "HookStore",
+ "compute_revision",
+ "now_iso",
+ "prepare_managed_spec",
+ "write_hooks_manifest",
+]
diff --git a/skill_manager/application/mcp/adapters.py b/skill_manager/application/mcp/adapters.py
index f7e25d4..8c60a6e 100644
--- a/skill_manager/application/mcp/adapters.py
+++ b/skill_manager/application/mcp/adapters.py
@@ -275,6 +275,8 @@ def _load_document(self, config_path: Path) -> dict[str, object]:
if not config_path.is_file():
return {}
text = config_path.read_text(encoding="utf-8")
+ if not text.strip():
+ return {}
if self._file_format in {"json", "jsonc"}:
try:
payload = json.loads(_strip_jsonc(text) if self._file_format == "jsonc" else text)
diff --git a/skill_manager/application/mcp/mappers.py b/skill_manager/application/mcp/mappers.py
index 77ba29c..385bb0e 100644
--- a/skill_manager/application/mcp/mappers.py
+++ b/skill_manager/application/mcp/mappers.py
@@ -271,6 +271,57 @@ def dict_to_spec(
)
+# Antigravity CLI -----------------------------------------------------------
+
+
+class AntigravityCliMapper:
+ """Used by Antigravity CLI (agy). Uses serverUrl for HTTP, and command/args/env for stdio."""
+
+ def spec_to_dict(self, spec: McpServerSpec) -> dict[str, object]:
+ if spec.transport == "stdio":
+ payload: dict[str, object] = {}
+ if spec.command is not None:
+ payload["command"] = spec.command
+ if spec.args:
+ payload["args"] = list(spec.args)
+ if spec.env:
+ payload["env"] = dict(spec.env)
+ return payload
+ payload = {}
+ if spec.url is not None:
+ payload["serverUrl"] = spec.url
+ if spec.headers:
+ payload["headers"] = dict(spec.headers)
+ return payload
+
+ def dict_to_spec(
+ self, name: str, raw: Mapping[str, object], *, source: McpSource | None = None
+ ) -> McpServerSpec:
+ if "command" in raw or "args" in raw:
+ return McpServerSpec(
+ name=name,
+ display_name=name,
+ source=source or McpSource.adopted("agy", name),
+ transport="stdio",
+ command=_str_or_none(raw.get("command")),
+ args=_str_tuple(raw.get("args")),
+ env=_str_pairs(raw.get("env")),
+ )
+ if "serverUrl" in raw or "url" in raw:
+ return McpServerSpec(
+ name=name,
+ display_name=name,
+ source=source or McpSource.adopted("agy", name),
+ transport="http",
+ url=_str_or_none(raw.get("serverUrl") or raw.get("url")),
+ headers=_str_pairs(raw.get("headers")),
+ )
+ raise MutationError(
+ f"unsupported agy mcp entry '{name}': missing 'command' and 'serverUrl'",
+ status=400,
+ )
+
+
# Helpers ------------------------------------------------------------------
@@ -298,6 +349,7 @@ def _str_pairs(value: object) -> tuple[tuple[str, str], ...] | None:
"opencode": OpenCodeMapper(),
"codex": CodexMapper(),
"openclaw": OpenClawMapper(),
+ "antigravity-cli": AntigravityCliMapper(),
}
@@ -308,6 +360,7 @@ def get_mapper(kind: str) -> TransportMapper:
__all__ = [
+ "AntigravityCliMapper",
"ClaudeCodeMapper",
"CodexMapper",
"CursorMapper",
@@ -316,3 +369,4 @@ def get_mapper(kind: str) -> TransportMapper:
"TransportMapper",
"get_mapper",
]
+
diff --git a/skill_manager/harness/catalog.py b/skill_manager/harness/catalog.py
index a546e29..4ab5c93 100644
--- a/skill_manager/harness/catalog.py
+++ b/skill_manager/harness/catalog.py
@@ -57,6 +57,12 @@ def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition
subtree_path=("mcp_servers",),
codec="codex",
),
+ "hooks": ConfigSubtreeBindingProfile(
+ config_path_resolver=lambda context: context.home / ".codex" / "config.toml",
+ file_format="toml",
+ subtree_path=("hooks",),
+ codec="codex-hooks",
+ ),
"slash_commands": CommandFileBindingProfile(
root_path_resolver=lambda context: context.home / ".codex",
output_dir_resolver=lambda context: context.home / ".codex" / "prompts",
@@ -90,6 +96,12 @@ def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition
),
codec="claude-code",
),
+ "hooks": ConfigSubtreeBindingProfile(
+ config_path_resolver=lambda context: context.home / ".claude" / "settings.json",
+ file_format="json",
+ subtree_path=("hooks",),
+ codec="claude-code-hooks",
+ ),
"slash_commands": CommandFileBindingProfile(
root_path_resolver=lambda context: context.home / ".claude",
output_dir_resolver=lambda context: context.home / ".claude" / "commands",
@@ -124,6 +136,12 @@ def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition
subtree_path=("mcpServers",),
codec="cursor",
),
+ "hooks": ConfigSubtreeBindingProfile(
+ config_path_resolver=lambda context: context.home / ".cursor" / "hooks.json",
+ file_format="json",
+ subtree_path=("hooks",),
+ codec="cursor-hooks",
+ ),
"slash_commands": CommandFileBindingProfile(
root_path_resolver=lambda context: context.home / ".cursor",
output_dir_resolver=lambda context: context.home / ".cursor" / "commands",
@@ -174,6 +192,15 @@ def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition
subtree_path=("mcp",),
codec="opencode",
),
+ "hooks": ConfigSubtreeBindingProfile(
+ config_path_resolver=lambda context: context.home / ".opencode" / "opencode.jsonc",
+ discovery_config_path_resolvers=(
+ lambda context: context.xdg_config_home / "opencode" / "opencode.json",
+ ),
+ file_format="jsonc",
+ subtree_path=("experimental", "hook"),
+ codec="opencode-hooks",
+ ),
"slash_commands": CommandFileBindingProfile(
root_path_resolver=lambda context: context.xdg_config_home / "opencode",
output_dir_resolver=lambda context: context.xdg_config_home / "opencode" / "commands",
@@ -215,6 +242,49 @@ def harness_definitions_for_family(family: FamilyKey) -> tuple[HarnessDefinition
),
},
),
+ HarnessDefinition(
+ harness="agy",
+ label="Antigravity",
+ logo_key="agy",
+ install_probe="agy",
+ bindings={
+ "skills": FileTreeBindingProfile(
+ managed_env="SKILL_MANAGER_AGY_ROOT",
+ managed_default=lambda context: context.home / ".gemini" / "antigravity-cli" / "skills",
+ discovery_roots=(
+ FileTreeDiscoveryRoot(
+ kind="compat-root",
+ scope="agents-compat",
+ label="Agents compatibility root",
+ path_resolver=lambda context: context.home / ".agents" / "skills",
+ ),
+ FileTreeDiscoveryRoot(
+ kind="legacy-root",
+ scope="legacy",
+ label="Legacy import root",
+ path_resolver=lambda context: context.home / ".gemini" / "skills",
+ ),
+ ),
+ ),
+ "mcp": ConfigSubtreeBindingProfile(
+ config_path_resolver=lambda context: context.home / ".gemini" / "config" / "mcp_config.json",
+ discovery_config_path_resolvers=(
+ lambda context: context.home / ".gemini" / "antigravity-cli" / "mcp_config.json",
+ lambda context: context.home / ".gemini" / "antigravity" / "mcp_config.json",
+ lambda context: context.home / ".gemini" / "antigravity-ide" / "mcp_config.json",
+ ),
+ file_format="json",
+ subtree_path=("mcpServers",),
+ codec="antigravity-cli",
+ ),
+ "hooks": ConfigSubtreeBindingProfile(
+ config_path_resolver=lambda context: context.home / ".gemini" / "config" / "hooks.json",
+ file_format="json",
+ subtree_path=(),
+ codec="antigravity-hooks",
+ ),
+ },
+ ),
)
diff --git a/skill_manager/harness/contracts.py b/skill_manager/harness/contracts.py
index e9b7c46..ff02b7d 100644
--- a/skill_manager/harness/contracts.py
+++ b/skill_manager/harness/contracts.py
@@ -7,7 +7,7 @@
from .resolution import ResolutionContext
-FamilyKey = Literal["skills", "mcp", "slash_commands"]
+FamilyKey = Literal["skills", "mcp", "slash_commands", "hooks"]
CommandFileRenderFormat = Literal["frontmatter_markdown", "cursor_plaintext"]
CommandFileScope = Literal["global", "project"]
FileTreeAvailability = Literal["cli", "cli_or_app"]
diff --git a/skill_manager/paths.py b/skill_manager/paths.py
index 9154b70..7ad3568 100644
--- a/skill_manager/paths.py
+++ b/skill_manager/paths.py
@@ -22,6 +22,7 @@ class AppPaths:
skills_store_manifest: Path
marketplace_cache_root: Path
mcp_store_manifest: Path
+ hooks_store_manifest: Path
slash_command_store_root: Path
slash_command_commands_dir: Path
slash_command_sync_state_path: Path
@@ -45,6 +46,7 @@ def resolve_app_paths(env: dict[str, str] | None = None) -> AppPaths:
skills_store_manifest=data_dir / "manifest.json",
marketplace_cache_root=data_dir / "marketplace",
mcp_store_manifest=data_dir / "mcp" / "manifest.json",
+ hooks_store_manifest=data_dir / "hooks" / "manifest.json",
slash_command_store_root=data_dir / "slash-commands",
slash_command_commands_dir=data_dir / "slash-commands" / "commands",
slash_command_sync_state_path=data_dir / "slash-commands" / "sync-state.json",
diff --git a/tests/integration/test_hooks_routes.py b/tests/integration/test_hooks_routes.py
new file mode 100644
index 0000000..caffada
--- /dev/null
+++ b/tests/integration/test_hooks_routes.py
@@ -0,0 +1,178 @@
+from __future__ import annotations
+
+import json
+import unittest
+from pathlib import Path
+
+import tomli_w
+import tomllib
+
+from tests.support.app_harness import AppTestHarness
+
+
+class HookRoutesTests(unittest.TestCase):
+ def test_list_all_harnesses_present(self) -> None:
+ with AppTestHarness() as harness:
+ payload = harness.get_json("/api/hooks")
+ self.assertEqual(payload["entries"], [])
+ harness_names = {col["harness"] for col in payload["columns"]}
+ # All unified hook harnesses must be surfaced in columns
+ self.assertIn("claude", harness_names)
+ self.assertIn("codex", harness_names)
+ self.assertIn("cursor", harness_names)
+ self.assertIn("opencode", harness_names)
+ self.assertIn("agy", harness_names)
+
+ def test_create_and_delete_hook(self) -> None:
+ with AppTestHarness() as harness:
+ response = harness.post_json(
+ "/api/hooks",
+ {
+ "id": "my-hook",
+ "event": "pre_tool_use",
+ "command": "echo hello",
+ "match": "shell",
+ "timeout": 30,
+ "description": "Say hello",
+ },
+ )
+ self.assertTrue(response["ok"])
+ self.assertEqual(response["hook"]["id"], "my-hook")
+ self.assertEqual(response["hook"]["event"], "pre_tool_use")
+ self.assertEqual(response["hook"]["match"], "shell")
+
+ # Check listing contains it
+ payload = harness.get_json("/api/hooks")
+ entry_ids = [entry["id"] for entry in payload["entries"]]
+ self.assertIn("my-hook", entry_ids)
+
+ # Get details
+ detail = harness.get_json("/api/hooks/my-hook")
+ self.assertEqual(detail["id"], "my-hook")
+
+ # Delete
+ deleted = harness.delete_json("/api/hooks/my-hook")
+ self.assertTrue(deleted["ok"])
+
+ # Check listing is empty again
+ payload2 = harness.get_json("/api/hooks")
+ self.assertEqual(payload2["entries"], [])
+
+ def test_enable_and_disable_across_harnesses(self) -> None:
+ with AppTestHarness() as harness:
+ # Create a managed hook
+ harness.post_json(
+ "/api/hooks",
+ {
+ "id": "my-hook",
+ "event": "pre_tool_use",
+ "command": "echo hello",
+ "match": "shell",
+ "timeout": 30,
+ "description": "Say hello",
+ },
+ )
+
+ # 1. Enable on Claude
+ enabled = harness.post_json(
+ "/api/hooks/my-hook/enable",
+ {"harness": "claude"},
+ )
+ self.assertTrue(enabled["ok"])
+
+ settings_path = harness.spec.home / ".claude" / "settings.json"
+ self.assertTrue(settings_path.is_file())
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
+ self.assertEqual(settings["hooks"]["PreToolUse"][0]["hooks"][0]["id"], "my-hook")
+
+ # 2. Enable on Codex
+ enabled_codex = harness.post_json(
+ "/api/hooks/my-hook/enable",
+ {"harness": "codex"},
+ )
+ self.assertTrue(enabled_codex["ok"])
+
+ codex_path = harness.spec.home / ".codex" / "config.toml"
+ self.assertTrue(codex_path.is_file())
+ with open(codex_path, "rb") as f:
+ codex_cfg = tomllib.load(f)
+ self.assertEqual(codex_cfg["hooks"]["PreToolUse"][0]["hooks"][0]["id"], "my-hook")
+
+ # 3. Enable on Cursor
+ enabled_cursor = harness.post_json(
+ "/api/hooks/my-hook/enable",
+ {"harness": "cursor"},
+ )
+ self.assertTrue(enabled_cursor["ok"])
+
+ cursor_path = harness.spec.home / ".cursor" / "hooks.json"
+ self.assertTrue(cursor_path.is_file())
+ cursor_cfg = json.loads(cursor_path.read_text(encoding="utf-8"))
+ self.assertEqual(cursor_cfg["hooks"]["beforeShellExecution"][0]["command"], "echo hello")
+
+ # Disable on all three
+ self.assertTrue(harness.post_json("/api/hooks/my-hook/disable", {"harness": "claude"})["ok"])
+ self.assertTrue(harness.post_json("/api/hooks/my-hook/disable", {"harness": "codex"})["ok"])
+ self.assertTrue(harness.post_json("/api/hooks/my-hook/disable", {"harness": "cursor"})["ok"])
+
+ # Verify files are cleaned up or empty
+ self.assertNotIn("hooks", json.loads(settings_path.read_text(encoding="utf-8")))
+ self.assertNotIn("hooks", json.loads(cursor_path.read_text(encoding="utf-8")))
+
+ def test_opencode_argv_wrapping_routing(self) -> None:
+ with AppTestHarness() as harness:
+ # OpenCode only supports stop and file_write post_tool_use
+ harness.post_json(
+ "/api/hooks",
+ {
+ "id": "opencode-hook",
+ "event": "stop",
+ "command": "npm run build",
+ "description": "build on stop",
+ },
+ )
+
+ # Enable on OpenCode
+ enabled = harness.post_json(
+ "/api/hooks/opencode-hook/enable",
+ {"harness": "opencode"},
+ )
+ self.assertTrue(enabled["ok"])
+
+ opencode_path = harness.spec.home / ".opencode" / "opencode.jsonc"
+ self.assertTrue(opencode_path.is_file())
+ opencode_cfg = json.loads(opencode_path.read_text(encoding="utf-8"))
+ self.assertEqual(
+ opencode_cfg["experimental"]["hook"]["session_completed"][0]["command"],
+ ["/bin/sh", "-c", "npm run build"],
+ )
+
+ def test_antigravity_name_keyed_routing(self) -> None:
+ with AppTestHarness() as harness:
+ harness.post_json(
+ "/api/hooks",
+ {
+ "id": "agy-hook",
+ "event": "stop",
+ "command": "git push",
+ "description": "push on stop",
+ },
+ )
+
+ # Enable on Antigravity (agy)
+ enabled = harness.post_json(
+ "/api/hooks/agy-hook/enable",
+ {"harness": "agy"},
+ )
+ self.assertTrue(enabled["ok"])
+
+ agy_path = harness.spec.home / ".gemini" / "config" / "hooks.json"
+ self.assertTrue(agy_path.is_file())
+ agy_cfg = json.loads(agy_path.read_text(encoding="utf-8"))
+ self.assertIn("agy-hook", agy_cfg)
+ self.assertTrue(agy_cfg["agy-hook"]["enabled"])
+ self.assertEqual(agy_cfg["agy-hook"]["Stop"][0]["command"], "git push")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/integration/test_http_api.py b/tests/integration/test_http_api.py
index 7f50ded..6f32429 100644
--- a/tests/integration/test_http_api.py
+++ b/tests/integration/test_http_api.py
@@ -28,7 +28,7 @@ def test_empty_fixture_returns_skills_settings_and_health(self) -> None:
settings["storage"]["settingsPath"],
str(harness.spec.xdg_config_home / "skill-manager" / "settings.json"),
)
- self.assertEqual(len(settings["harnesses"]), 5)
+ self.assertEqual(len(settings["harnesses"]), 6)
openclaw = next(item for item in settings["harnesses"] if item["harness"] == "openclaw")
self.assertTrue(openclaw["installed"])
self.assertTrue(openclaw["supportEnabled"])
@@ -84,7 +84,7 @@ def test_mixed_fixture_returns_skills_page_and_detail(self) -> None:
self.assertEqual(detail["displayStatus"], "Managed")
self.assertEqual(
[cell["label"] for cell in detail["harnessCells"]],
- ["Codex", "Claude", "Cursor", "OpenCode", "OpenClaw"],
+ ["Codex", "Claude", "Cursor", "OpenCode", "OpenClaw", "Antigravity"],
)
self.assertNotIn("updateStatus", detail["actions"])
self.assertEqual(source_status["updateStatus"], "no_update_available")
@@ -108,7 +108,7 @@ def test_managed_detail_returns_shared_store_location_before_tool_links(self) ->
shared_audit = next(row for row in skills["rows"] if row["name"] == "Shared Audit")
detail = harness.get_json(f"/api/skills/{shared_audit['skillRef']}")
- self.assertEqual([location["label"] for location in detail["locations"]], ["Shared Store", "Codex", "OpenClaw", "OpenCode"])
+ self.assertEqual([location["label"] for location in detail["locations"]], ["Shared Store", "Antigravity", "Codex", "OpenClaw", "OpenCode"])
self.assertEqual(detail["actions"]["stopManagingStatus"], "available")
self.assertEqual(detail["actions"]["stopManagingHarnessLabels"], ["Codex"])
self.assertEqual(detail["actions"]["deleteHarnessLabels"], ["Codex"])
diff --git a/tests/integration/test_mcp_routes.py b/tests/integration/test_mcp_routes.py
index 7410816..0f140b3 100644
--- a/tests/integration/test_mcp_routes.py
+++ b/tests/integration/test_mcp_routes.py
@@ -404,7 +404,7 @@ def test_set_harnesses_fan_out(self) -> None:
"/api/mcp/servers/exa/set-harnesses", {"target": "enabled"}
)
self.assertTrue(response["ok"])
- self.assertEqual(set(response["succeeded"]), {"codex", "claude", "cursor", "opencode", "openclaw"})
+ self.assertEqual(set(response["succeeded"]), {"codex", "claude", "cursor", "opencode", "openclaw", "agy"})
# Verify each config file
self.assertTrue((harness.spec.home / ".cursor" / "mcp.json").is_file())
@@ -412,6 +412,7 @@ def test_set_harnesses_fan_out(self) -> None:
self.assertTrue((harness.spec.home / ".codex" / "config.toml").is_file())
self.assertTrue((harness.spec.home / ".opencode" / "opencode.jsonc").is_file())
self.assertTrue((harness.spec.home / ".openclaw" / "openclaw.json").is_file())
+ self.assertTrue((harness.spec.home / ".gemini" / "config" / "mcp_config.json").is_file())
def test_uninstall_cleans_all_harnesses_and_central(self) -> None:
with AppTestHarness() as harness:
diff --git a/tests/integration/test_skills_mutations.py b/tests/integration/test_skills_mutations.py
index 460e383..d4bcaba 100644
--- a/tests/integration/test_skills_mutations.py
+++ b/tests/integration/test_skills_mutations.py
@@ -163,7 +163,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None:
with AppTestHarness(fixture_factory=seed_shared_only_fixture) as harness:
# Simulate missing non-core CLIs by removing their stubs from the
# fake PATH. Cursor may still be available through its app probe.
- for cli in ("cursor-agent", "opencode", "openclaw"):
+ for cli in ("cursor-agent", "opencode", "openclaw", "agy"):
stub = harness.spec.bin_dir / cli
if stub.exists():
stub.unlink()
@@ -179,6 +179,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None:
self.assertTrue(installed_by_harness["claude"])
self.assertFalse(installed_by_harness["opencode"])
self.assertFalse(installed_by_harness["openclaw"])
+ self.assertFalse(installed_by_harness["agy"])
result = harness.post_json(
f"/api/skills/{shared_entry['skillRef']}/set-harnesses",
@@ -198,6 +199,7 @@ def test_set_skill_harnesses_only_targets_available_harnesses(self) -> None:
self.assertTrue((harness.spec.cursor_root / "shared-audit").is_symlink())
else:
self.assertFalse((harness.spec.cursor_root / "shared-audit").exists())
+ self.assertFalse((harness.spec.agy_root / "shared-audit").exists())
# Unavailable harness folders remain untouched.
self.assertFalse((harness.spec.opencode_root / "shared-audit").exists())
self.assertFalse((harness.spec.openclaw_managed_root / "shared-audit").exists())
diff --git a/tests/support/fake_home.py b/tests/support/fake_home.py
index 1b155ae..50f69be 100644
--- a/tests/support/fake_home.py
+++ b/tests/support/fake_home.py
@@ -55,6 +55,10 @@ def openclaw_home(self) -> Path:
def openclaw_managed_root(self) -> Path:
return self.openclaw_home / "skills"
+ @property
+ def agy_root(self) -> Path:
+ return self.home / ".gemini" / "antigravity-cli" / "skills"
+
@property
def bin_dir(self) -> Path:
return self.root / "bin"
@@ -85,12 +89,13 @@ def create_fake_home_spec(root: Path, *, seed_openclaw_state: bool = True) -> Fa
spec.cursor_root,
spec.opencode_root,
spec.openclaw_managed_root,
+ spec.agy_root,
spec.xdg_state_home,
spec.bin_dir,
):
path.mkdir(parents=True, exist_ok=True)
- for executable in ("codex", "claude", "cursor-agent", "opencode"):
+ for executable in ("codex", "claude", "cursor-agent", "opencode", "agy"):
write_cli_stub(spec.bin_dir / executable, executable)
if seed_openclaw_state:
write_cli_stub(spec.bin_dir / "openclaw", "openclaw")
diff --git a/tests/unit/test_backend_container.py b/tests/unit/test_backend_container.py
index 64f289a..79da9c4 100644
--- a/tests/unit/test_backend_container.py
+++ b/tests/unit/test_backend_container.py
@@ -143,7 +143,7 @@ def test_settings_surface_store_issues(self) -> None:
settings["storage"]["marketplaceCachePath"],
str(spec.xdg_data_home / "skill-manager" / "marketplace"),
)
- self.assertEqual(len(settings["harnesses"]), 5)
+ self.assertEqual(len(settings["harnesses"]), 6)
codex = next(item for item in settings["harnesses"] if item["harness"] == "codex")
self.assertIn("managedLocation", codex)
self.assertIn("installed", codex)
@@ -189,7 +189,7 @@ def test_skill_detail_orders_managed_locations_with_shared_store_first(self) ->
assert detail is not None
- self.assertEqual([location["label"] for location in detail["locations"]], ["Shared Store", "Codex", "OpenClaw", "OpenCode"])
+ self.assertEqual([location["label"] for location in detail["locations"]], ["Shared Store", "Antigravity", "Codex", "OpenClaw", "OpenCode"])
self.assertEqual(detail["locations"][0]["path"], str(spec.skills_store_root / "shared-audit"))
self.assertEqual(detail["locations"][1]["path"], str(spec.codex_root / "shared-audit"))
self.assertEqual(detail["actions"]["stopManagingStatus"], "available")
diff --git a/tests/unit/test_hooks_adapters.py b/tests/unit/test_hooks_adapters.py
new file mode 100644
index 0000000..20a7989
--- /dev/null
+++ b/tests/unit/test_hooks_adapters.py
@@ -0,0 +1,214 @@
+from __future__ import annotations
+
+import json
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from skill_manager.application.hooks.adapters import FileBackedHooksAdapter
+from skill_manager.application.hooks.store import HookSpec, HookStore
+from skill_manager.errors import MutationError
+from skill_manager.harness import HarnessKernelService, HarnessSupportStore
+
+
+def _spec(id: str = "test-hook", **overrides) -> HookSpec:
+ base = dict(
+ id=id,
+ event="pre_tool_use",
+ command="echo hello",
+ match="shell",
+ timeout=30,
+ description="A test hook",
+ )
+ base.update(overrides)
+ return HookSpec(**base)
+
+
+def _adapter(
+ harness: str,
+ *,
+ home: Path,
+) -> FileBackedHooksAdapter:
+ env = {
+ "HOME": str(home),
+ "PATH": "",
+ }
+ kernel = HarnessKernelService.from_environment(
+ env,
+ support_store=HarnessSupportStore(home / "settings.json"),
+ )
+ binding = next(
+ binding for binding in kernel.bindings_for_family("hooks") if binding.definition.harness == harness
+ )
+ return FileBackedHooksAdapter(
+ definition=binding.definition,
+ profile=binding.profile,
+ context=kernel.context,
+ )
+
+
+class FileBackedHooksAdapterTests(unittest.TestCase):
+ def test_classifies_managed_when_content_matches(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp)
+ store = HookStore(home / "manifest.json")
+ store.upsert_managed(_spec("hook1"))
+ adapter = _adapter("claude", home=home)
+
+ adapter.enable_hook(store.get_managed("hook1")) # type: ignore[arg-type]
+ scan = adapter.scan(store.list_managed())
+
+ states = {entry.id: entry.state for entry in scan.entries}
+ self.assertEqual(states.get("hook1"), "managed")
+
+ def test_classifies_drifted_when_user_edits_entry(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp)
+ store = HookStore(home / "manifest.json")
+ store.upsert_managed(_spec("hook1"))
+ adapter = _adapter("claude", home=home)
+ adapter.config_path.parent.mkdir(parents=True, exist_ok=True)
+ adapter.config_path.write_text(
+ json.dumps(
+ {
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "echo modified",
+ "id": "hook1",
+ }
+ ],
+ }
+ ]
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ scan = adapter.scan(store.list_managed())
+ states = {entry.id: entry.state for entry in scan.entries}
+ self.assertEqual(states.get("hook1"), "drifted")
+
+ def test_classifies_unmanaged_when_no_central_spec(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp)
+ store = HookStore(home / "manifest.json")
+ adapter = _adapter("claude", home=home)
+ adapter.config_path.parent.mkdir(parents=True, exist_ok=True)
+ adapter.config_path.write_text(
+ json.dumps(
+ {
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "echo unmanaged",
+ "id": "legacy-hook",
+ }
+ ],
+ }
+ ]
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ scan = adapter.scan(store.list_managed())
+ unmanaged = [entry for entry in scan.entries if entry.state == "unmanaged"]
+ self.assertEqual(len(unmanaged), 1)
+ self.assertEqual(unmanaged[0].id, "legacy-hook")
+
+ def test_managed_spec_with_no_binding_is_missing(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp)
+ store = HookStore(home / "manifest.json")
+ store.upsert_managed(_spec("hook1"))
+ adapter = _adapter("claude", home=home)
+ adapter.config_path.parent.mkdir(parents=True, exist_ok=True)
+ adapter.config_path.write_text(json.dumps({"hooks": {}}), encoding="utf-8")
+
+ scan = adapter.scan(store.list_managed())
+ states = {entry.id: entry.state for entry in scan.entries}
+ self.assertEqual(states.get("hook1"), "missing")
+
+ def test_enable_preserves_non_hooks_keys_for_json(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp)
+ adapter = _adapter("claude", home=home)
+ adapter.config_path.parent.mkdir(parents=True, exist_ok=True)
+ adapter.config_path.write_text(
+ json.dumps(
+ {
+ "theme": "dark",
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {"type": "command", "command": "echo existing", "id": "existing"}
+ ],
+ }
+ ]
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ adapter.enable_hook(_spec("hook1"))
+ payload = json.loads(adapter.config_path.read_text(encoding="utf-8"))
+ self.assertEqual(payload["theme"], "dark")
+
+ groups = payload["hooks"]["PreToolUse"]
+ self.assertEqual(len(groups), 1)
+ self.assertEqual(groups[0]["matcher"], "Bash")
+ hooks = groups[0]["hooks"]
+ self.assertEqual(len(hooks), 2)
+ self.assertEqual({h["id"] for h in hooks}, {"existing", "hook1"})
+
+ def test_has_binding_after_enable(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp)
+ adapter = _adapter("claude", home=home)
+
+ self.assertFalse(adapter.has_binding("hook1"))
+ adapter.enable_hook(_spec("hook1"))
+ self.assertTrue(adapter.has_binding("hook1"))
+
+ def test_invalid_json_raises_mutation_error(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp)
+ adapter = _adapter("claude", home=home)
+ adapter.config_path.parent.mkdir(parents=True, exist_ok=True)
+ adapter.config_path.write_text("{not json", encoding="utf-8")
+
+ with self.assertRaises(MutationError):
+ adapter.enable_hook(_spec("hook1"))
+
+ def test_scan_reports_malformed_config_without_raising(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp)
+ store = HookStore(home / "manifest.json")
+ store.upsert_managed(_spec("hook1"))
+ adapter = _adapter("claude", home=home)
+ adapter.config_path.parent.mkdir(parents=True, exist_ok=True)
+ adapter.config_path.write_text("{not json", encoding="utf-8")
+
+ scan = adapter.scan(store.list_managed())
+
+ self.assertIn("not valid JSON", scan.scan_issue or "")
+ states = {entry.id: entry.state for entry in scan.entries}
+ self.assertEqual(states["hook1"], "missing")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_hooks_mappers.py b/tests/unit/test_hooks_mappers.py
new file mode 100644
index 0000000..8417913
--- /dev/null
+++ b/tests/unit/test_hooks_mappers.py
@@ -0,0 +1,252 @@
+from __future__ import annotations
+
+import unittest
+from skill_manager.application.hooks.mappers import (
+ ClaudeCodeHooksMapper,
+ CodexHooksMapper,
+ CursorHooksMapper,
+ OpenCodeHooksMapper,
+ AntigravityHooksMapper,
+)
+from skill_manager.application.hooks.store import HookSpec
+from skill_manager.errors import MutationError
+
+
+class ClaudeCodeHooksMapperTests(unittest.TestCase):
+ def test_representable(self) -> None:
+ mapper = ClaudeCodeHooksMapper()
+ # Supported event and match
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "pre_tool_use", "echo", match="shell"))
+ self.assertTrue(is_repr)
+ # Unsupported event
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "invalid_event", "echo"))
+ self.assertFalse(is_repr)
+ # Unsupported match
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "pre_tool_use", "echo", match="invalid_match"))
+ self.assertFalse(is_repr)
+
+ def test_spec_to_dict_and_dict_to_spec(self) -> None:
+ mapper = ClaudeCodeHooksMapper()
+ spec = HookSpec("h1", "pre_tool_use", "echo hello", match="shell", timeout=12)
+
+ # Spec to dict
+ d = mapper.spec_to_dict(spec)
+ self.assertEqual(d["type"], "command")
+ self.assertEqual(d["command"], "echo hello")
+ self.assertEqual(d["id"], "h1")
+ self.assertEqual(d["timeout"], 12)
+
+ # Dict to spec
+ parsed = mapper.dict_to_spec("pre_tool_use", "shell", d)
+ self.assertEqual(parsed.id, "h1")
+ self.assertEqual(parsed.event, "pre_tool_use")
+ self.assertEqual(parsed.match, "shell")
+ self.assertEqual(parsed.command, "echo hello")
+ self.assertEqual(parsed.timeout, 12)
+
+ def test_read_enable_disable_lifecycle(self) -> None:
+ mapper = ClaudeCodeHooksMapper()
+ doc = {}
+ spec = HookSpec("h1", "pre_tool_use", "echo hello", match="shell")
+
+ # Enable
+ mapper.enable_hook(doc, spec)
+ self.assertIn("hooks", doc)
+ self.assertIn("PreToolUse", doc["hooks"])
+ group = doc["hooks"]["PreToolUse"][0]
+ self.assertEqual(group["matcher"], "Bash")
+ self.assertEqual(group["hooks"][0]["command"], "echo hello")
+
+ # Read
+ entries = mapper.read_entries(doc, [spec])
+ self.assertEqual(len(entries), 1)
+ self.assertEqual(entries[0].id, "h1")
+ self.assertEqual(entries[0].event, "pre_tool_use")
+ self.assertEqual(entries[0].match, "shell")
+
+ # Disable
+ mapper.disable_hook(doc, "h1")
+ self.assertNotIn("hooks", doc)
+
+
+class CodexHooksMapperTests(unittest.TestCase):
+ def test_representable(self) -> None:
+ mapper = CodexHooksMapper()
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "pre_tool_use", "echo", match="shell"))
+ self.assertTrue(is_repr)
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "invalid_event", "echo"))
+ self.assertFalse(is_repr)
+
+
+class CursorHooksMapperTests(unittest.TestCase):
+ def test_representable(self) -> None:
+ mapper = CursorHooksMapper()
+ # Shell is representable
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "pre_tool_use", "echo", match="shell"))
+ self.assertTrue(is_repr)
+ # Web is not representable on Cursor
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "pre_tool_use", "echo", match="web"))
+ self.assertFalse(is_repr)
+
+ def test_enable_and_read_event_mapping(self) -> None:
+ mapper = CursorHooksMapper()
+ doc = {}
+ spec = HookSpec("h1", "pre_tool_use", "echo hello", match="shell")
+
+ # Enable maps (pre_tool_use, shell) -> beforeShellExecution
+ mapper.enable_hook(doc, spec)
+ self.assertEqual(doc["version"], 1)
+ self.assertIn("beforeShellExecution", doc["hooks"])
+ self.assertEqual(doc["hooks"]["beforeShellExecution"][0]["command"], "echo hello")
+
+ # Read resolves ID
+ entries = mapper.read_entries(doc, [spec])
+ self.assertEqual(len(entries), 1)
+ self.assertEqual(entries[0].id, "h1")
+ self.assertEqual(entries[0].event, "pre_tool_use")
+ self.assertEqual(entries[0].match, "shell")
+
+ def test_id_by_hash_identity_for_unmanaged(self) -> None:
+ mapper = CursorHooksMapper()
+ doc = {
+ "version": 1,
+ "hooks": {
+ "beforeShellExecution": [
+ {"command": "echo unmanaged"}
+ ]
+ }
+ }
+ # Read without spec resolves to manual:
+ entries = mapper.read_entries(doc, [])
+ self.assertEqual(len(entries), 1)
+ self.assertTrue(entries[0].id.startswith("manual:"))
+
+ # Disable by hash ID
+ mapper.disable_hook(doc, entries[0].id)
+ self.assertNotIn("hooks", doc)
+
+
+class OpenCodeHooksMapperTests(unittest.TestCase):
+ def test_representable(self) -> None:
+ mapper = OpenCodeHooksMapper()
+ # Stop is representable
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "stop", "echo"))
+ self.assertTrue(is_repr)
+ # file_write is representable under post_tool_use
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "post_tool_use", "echo", match="file_write"))
+ self.assertTrue(is_repr)
+ # shell is not representable on OpenCode
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "pre_tool_use", "echo", match="shell"))
+ self.assertFalse(is_repr)
+
+ def test_enable_argv_wrapping_and_read(self) -> None:
+ mapper = OpenCodeHooksMapper()
+ doc = {}
+ spec = HookSpec("h1", "stop", "echo hello")
+
+ # Enable wraps command in ["/bin/sh", "-c", command]
+ mapper.enable_hook(doc, spec)
+ session_completed = doc["experimental"]["hook"]["session_completed"]
+ self.assertEqual(session_completed[0]["command"], ["/bin/sh", "-c", "echo hello"])
+
+ # Read unwraps argv array to command string
+ entries = mapper.read_entries(doc, [spec])
+ self.assertEqual(len(entries), 1)
+ self.assertEqual(entries[0].id, "h1")
+ self.assertEqual(entries[0].event, "stop")
+ self.assertEqual(entries[0].payload["command"], ["/bin/sh", "-c", "echo hello"])
+
+
+class AntigravityHooksMapperTests(unittest.TestCase):
+ def test_representable(self) -> None:
+ mapper = AntigravityHooksMapper()
+ # stop is representable
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "stop", "echo"))
+ self.assertTrue(is_repr)
+ # shell is representable
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "pre_tool_use", "echo", match="shell"))
+ self.assertTrue(is_repr)
+ # mcp is not representable
+ is_repr, _, _ = mapper.representable(HookSpec("h1", "pre_tool_use", "echo", match="mcp"))
+ self.assertFalse(is_repr)
+
+ def test_enable_name_keyed_merge_and_read(self) -> None:
+ mapper = AntigravityHooksMapper()
+ doc = {}
+ spec = HookSpec("my-hook-id", "stop", "echo hello")
+
+ # Enable stores under top-level spec.id
+ mapper.enable_hook(doc, spec)
+ self.assertIn("my-hook-id", doc)
+ self.assertTrue(doc["my-hook-id"]["enabled"])
+ self.assertEqual(doc["my-hook-id"]["Stop"][0]["command"], "echo hello")
+
+ # Read
+ entries = mapper.read_entries(doc, [spec])
+ self.assertEqual(len(entries), 1)
+ self.assertEqual(entries[0].id, "my-hook-id")
+ self.assertEqual(entries[0].event, "stop")
+
+ # Disable
+ mapper.disable_hook(doc, "my-hook-id")
+ self.assertNotIn("my-hook-id", doc)
+
+ def test_representable_caveat(self) -> None:
+ mapper = AntigravityHooksMapper()
+ is_repr, reason, caveat = mapper.representable(HookSpec("h1", "user_prompt_submit", "echo"))
+ self.assertTrue(is_repr)
+ self.assertIsNone(reason)
+ self.assertEqual(
+ caveat,
+ "On Antigravity this maps to PreInvocation, which fires before every model invocation, not only on user-prompt submit."
+ )
+
+ def test_preinvocation_round_trip(self) -> None:
+ mapper = AntigravityHooksMapper()
+ doc = {}
+ spec = HookSpec("user-hook", "user_prompt_submit", "echo 'hello world'", timeout=45)
+
+ # Enable hook
+ mapper.enable_hook(doc, spec)
+ self.assertIn("user-hook", doc)
+ self.assertTrue(doc["user-hook"]["enabled"])
+ self.assertIn("PreInvocation", doc["user-hook"])
+ self.assertEqual(doc["user-hook"]["PreInvocation"][0]["command"], "echo 'hello world'")
+ self.assertEqual(doc["user-hook"]["PreInvocation"][0]["timeout"], 45)
+
+ # Read back
+ entries = mapper.read_entries(doc, [spec])
+ self.assertEqual(len(entries), 1)
+ self.assertEqual(entries[0].id, "user-hook")
+ self.assertEqual(entries[0].event, "user_prompt_submit")
+ self.assertIsNone(entries[0].match)
+ self.assertEqual(entries[0].payload["command"], "echo 'hello world'")
+
+ # Disable hook
+ mapper.disable_hook(doc, "user-hook")
+ self.assertNotIn("user-hook", doc)
+
+ def test_foreign_preservation(self) -> None:
+ mapper = AntigravityHooksMapper()
+ doc = {
+ "foreign-hook": {
+ "enabled": True,
+ "PreToolUse": [
+ {
+ "matcher": "*",
+ "hooks": [{"type": "command", "command": "echo foreign"}]
+ }
+ ]
+ }
+ }
+ spec = HookSpec("user-hook", "user_prompt_submit", "echo 'hello'")
+ mapper.enable_hook(doc, spec)
+
+ self.assertIn("foreign-hook", doc)
+ self.assertIn("user-hook", doc)
+ self.assertEqual(doc["foreign-hook"]["PreToolUse"][0]["hooks"][0]["command"], "echo foreign")
+ self.assertEqual(doc["user-hook"]["PreInvocation"][0]["command"], "echo 'hello'")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_hooks_store.py b/tests/unit/test_hooks_store.py
new file mode 100644
index 0000000..d2c5ec6
--- /dev/null
+++ b/tests/unit/test_hooks_store.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import json
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from skill_manager.application.hooks.store import HookSpec, HookStore
+
+
+def _spec(hook_id: str = "test-hook", **overrides) -> HookSpec:
+ base = dict(
+ id=hook_id,
+ event="pre_tool_use",
+ command="echo hello",
+ match="shell",
+ timeout=10,
+ description="A test hook",
+ )
+ base.update(overrides)
+ return HookSpec(**base)
+
+
+class HookStoreTests(unittest.TestCase):
+ def test_upsert_then_list(self) -> None:
+ with TemporaryDirectory() as tmp:
+ store = HookStore(Path(tmp) / "manifest.json")
+ store.upsert_managed(_spec("hook1"))
+ store.upsert_managed(_spec("hook2", event="post_tool_use", command="echo post"))
+
+ entries = store.list_managed()
+
+ self.assertEqual({entry.id for entry in entries}, {"hook1", "hook2"})
+
+ def test_upsert_replaces_existing(self) -> None:
+ with TemporaryDirectory() as tmp:
+ store = HookStore(Path(tmp) / "manifest.json")
+ store.upsert_managed(_spec("hook1", command="old command"))
+ store.upsert_managed(_spec("hook1", command="new command"))
+
+ entries = store.list_managed()
+
+ self.assertEqual(len(entries), 1)
+ self.assertEqual(entries[0].command, "new command")
+
+ def test_get_returns_none_when_missing(self) -> None:
+ with TemporaryDirectory() as tmp:
+ store = HookStore(Path(tmp) / "manifest.json")
+
+ self.assertIsNone(store.get_managed("hook1"))
+
+ def test_remove_returns_false_when_missing(self) -> None:
+ with TemporaryDirectory() as tmp:
+ store = HookStore(Path(tmp) / "manifest.json")
+
+ self.assertFalse(store.remove("hook1"))
+
+ def test_remove_returns_true_and_drops_entry(self) -> None:
+ with TemporaryDirectory() as tmp:
+ store = HookStore(Path(tmp) / "manifest.json")
+ store.upsert_managed(_spec("hook1"))
+
+ self.assertTrue(store.remove("hook1"))
+ self.assertEqual(store.list_managed(), ())
+
+ def test_revision_changes_when_payload_differs(self) -> None:
+ with TemporaryDirectory() as tmp:
+ store = HookStore(Path(tmp) / "manifest.json")
+ store.upsert_managed(_spec("hook1"))
+ stored = store.get_managed("hook1")
+ assert stored is not None
+
+ store.upsert_managed(_spec("hook1", command="echo changed"))
+ stored2 = store.get_managed("hook1")
+ assert stored2 is not None
+
+ self.assertTrue(stored.revision)
+ self.assertNotEqual(stored.revision, stored2.revision)
+
+ def test_manifest_is_valid_json(self) -> None:
+ with TemporaryDirectory() as tmp:
+ manifest_path = Path(tmp) / "manifest.json"
+ store = HookStore(manifest_path)
+ store.upsert_managed(_spec("hook1"))
+
+ payload = json.loads(manifest_path.read_text(encoding="utf-8"))
+
+ self.assertEqual(payload["version"], 1)
+ self.assertEqual(len(payload["hooks"]), 1)
+ self.assertEqual(payload["hooks"][0]["id"], "hook1")
+
+ def test_manifest_issues_report_malformed_entries_without_dropping_valid_entries(self) -> None:
+ with TemporaryDirectory() as tmp:
+ manifest_path = Path(tmp) / "manifest.json"
+ manifest_path.write_text(
+ json.dumps(
+ {
+ "hooks": [
+ {
+ "id": "valid",
+ "event": "PreToolUse",
+ "command": "echo valid",
+ },
+ {"event": "Missing ID"},
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ store = HookStore(manifest_path)
+
+ self.assertEqual([hook.id for hook in store.list_managed()], ["valid"])
+ self.assertEqual(len(store.manifest_issues()), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()