From ee702013ed8c518246efbd1d4de3481d0bf6495c Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 12:55:41 -0700 Subject: [PATCH 01/12] feat: integrate Antigravity CLI (agy) harness support --- assets/harness-logos/agy-logo.svg | 19 +++++++ .../src/assets/harness-logos/agy-logo.svg | 19 +++++++ .../components/harness/harnessPresentation.ts | 8 ++- .../skills/screens/ScanConfigPage.test.tsx | 2 +- skill_manager/application/mcp/mappers.py | 54 +++++++++++++++++++ skill_manager/harness/catalog.py | 37 +++++++++++++ tests/integration/test_http_api.py | 6 +-- tests/integration/test_mcp_routes.py | 3 +- tests/integration/test_skills_mutations.py | 4 +- tests/support/fake_home.py | 7 ++- tests/unit/test_backend_container.py | 4 +- 11 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 assets/harness-logos/agy-logo.svg create mode 100644 frontend/src/assets/harness-logos/agy-logo.svg diff --git a/assets/harness-logos/agy-logo.svg b/assets/harness-logos/agy-logo.svg new file mode 100644 index 0000000..24a080a --- /dev/null +++ b/assets/harness-logos/agy-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + 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..24a080a --- /dev/null +++ b/frontend/src/assets/harness-logos/agy-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + 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/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/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..77a8bd9 100644 --- a/skill_manager/harness/catalog.py +++ b/skill_manager/harness/catalog.py @@ -215,6 +215,43 @@ 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", + ), + }, + ), ) 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") From dd151e90e66515e8a2f920e5855fc7e01e7ae683 Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 13:01:32 -0700 Subject: [PATCH 02/12] fix: handle empty or whitespace-only configuration files in load_document --- skill_manager/application/mcp/adapters.py | 2 ++ 1 file changed, 2 insertions(+) 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) From 8b71aa500abd0d8da9df8092e1b8a71b2394b47d Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 13:09:18 -0700 Subject: [PATCH 03/12] style: update agy harness icon to official Antigravity CLI Full Color logo --- assets/harness-logos/agy-logo.svg | 26 ++++++------------- .../src/assets/harness-logos/agy-logo.svg | 26 ++++++------------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/assets/harness-logos/agy-logo.svg b/assets/harness-logos/agy-logo.svg index 24a080a..fe5fd4c 100644 --- a/assets/harness-logos/agy-logo.svg +++ b/assets/harness-logos/agy-logo.svg @@ -1,19 +1,9 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/frontend/src/assets/harness-logos/agy-logo.svg b/frontend/src/assets/harness-logos/agy-logo.svg index 24a080a..fe5fd4c 100644 --- a/frontend/src/assets/harness-logos/agy-logo.svg +++ b/frontend/src/assets/harness-logos/agy-logo.svg @@ -1,19 +1,9 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + From 33cb0ce19fd742403b0a57a4b7f1d250221d9ebf Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 17:37:08 -0700 Subject: [PATCH 04/12] backend: implement hooks capability and add tests - Add hooks router, schemas, container setup and business logic - Add comprehensive integration and unit tests for the hooks capability Co-Authored-By: agy --- skill_manager/api/app.py | 3 +- skill_manager/api/routers/__init__.py | 4 +- skill_manager/api/routers/hooks.py | 101 +++++ skill_manager/api/schemas/__init__.py | 32 ++ skill_manager/api/schemas/hooks.py | 132 ++++++ skill_manager/application/container.py | 23 + skill_manager/application/hooks/__init__.py | 39 ++ skill_manager/application/hooks/adapters.py | 406 ++++++++++++++++++ skill_manager/application/hooks/contracts.py | 114 +++++ .../application/hooks/harness_application.py | 116 +++++ skill_manager/application/hooks/inventory.py | 72 ++++ .../application/hooks/managed_state.py | 102 +++++ skill_manager/application/hooks/mappers.py | 84 ++++ skill_manager/application/hooks/mutations.py | 171 ++++++++ skill_manager/application/hooks/query.py | 55 +++ .../application/hooks/read_models.py | 114 +++++ skill_manager/application/hooks/store.py | 212 +++++++++ skill_manager/harness/catalog.py | 6 + skill_manager/harness/contracts.py | 2 +- skill_manager/paths.py | 2 + tests/integration/test_hooks_routes.py | 196 +++++++++ tests/unit/test_hooks_adapters.py | 215 ++++++++++ tests/unit/test_hooks_mappers.py | 72 ++++ tests/unit/test_hooks_store.py | 118 +++++ 24 files changed, 2387 insertions(+), 4 deletions(-) create mode 100644 skill_manager/api/routers/hooks.py create mode 100644 skill_manager/api/schemas/hooks.py create mode 100644 skill_manager/application/hooks/__init__.py create mode 100644 skill_manager/application/hooks/adapters.py create mode 100644 skill_manager/application/hooks/contracts.py create mode 100644 skill_manager/application/hooks/harness_application.py create mode 100644 skill_manager/application/hooks/inventory.py create mode 100644 skill_manager/application/hooks/managed_state.py create mode 100644 skill_manager/application/hooks/mappers.py create mode 100644 skill_manager/application/hooks/mutations.py create mode 100644 skill_manager/application/hooks/query.py create mode 100644 skill_manager/application/hooks/read_models.py create mode 100644 skill_manager/application/hooks/store.py create mode 100644 tests/integration/test_hooks_routes.py create mode 100644 tests/unit/test_hooks_adapters.py create mode 100644 tests/unit/test_hooks_mappers.py create mode 100644 tests/unit/test_hooks_store.py 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..575cb7b --- /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, + matcher=body.matcher, + 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..403ba7a --- /dev/null +++ b/skill_manager/api/schemas/hooks.py @@ -0,0 +1,132 @@ +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) + matcher: 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 + matcher: 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"] + driftDetail: 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..c3c88f2 --- /dev/null +++ b/skill_manager/application/hooks/adapters.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +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, get_mapper +from .store import HookSpec + + +@dataclass(frozen=True) +class _RawHookEntry: + id: str + event: str + matcher: str | None + payload: dict[str, object] + group_index: int + hook_index: int + + +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._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() 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.matcher, + 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 + + 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.matcher == raw.matcher: + entries.append( + HookObservedEntry( + id=raw.id, + event=raw.event, + state="managed", + raw_payload=dict(raw.payload), + parsed_spec=parsed_spec, + ) + ) + else: + drift_parts = [] + if managed_spec.event != raw.event: + drift_parts.append(f"event: expected={managed_spec.event}, actual={raw.event}") + if managed_spec.matcher != raw.matcher: + drift_parts.append(f"matcher: expected={managed_spec.matcher}, actual={raw.matcher}") + 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", + ) + ) + + for spec in specs: + if spec.id in seen_ids: + continue + entries.append( + HookObservedEntry( + id=spec.id, + event=spec.event, + state="missing", + parsed_spec=spec, + ) + ) + + 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: + return any(raw.id == id for raw in self._read_entries()) + + def enable_hook(self, spec: HookSpec) -> None: + 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) + + # Ensure "hooks" exists and is a dictionary + 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) + + # Ensure the specific event list exists + if spec.event not in hooks_subtree: + hooks_subtree[spec.event] = [] + event_list = hooks_subtree[spec.event] + if not isinstance(event_list, list): + raise MutationError(f"The hook event '{spec.event}' is not an array", status=409) + + # Find or create the matcher group + target_group = None + for group in event_list: + if not isinstance(group, dict): + continue + group_matcher = group.get("matcher") + if spec.matcher is None: + # Look for matcher group with no matcher, or empty/omitted matcher + if "matcher" not in group or group_matcher is None: + target_group = group + break + else: + if group_matcher == spec.matcher: + target_group = group + break + + if target_group is None: + target_group = {"hooks": []} + if spec.matcher is not None: + target_group["matcher"] = spec.matcher + event_list.append(target_group) + + if "hooks" not in target_group or not isinstance(target_group["hooks"], list): + target_group["hooks"] = [] + + # Write the hook entry + hook_payload = self._mapper.spec_to_dict(spec) + hooks_list = target_group["hooks"] + + # Find if it already exists + 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 in case the hook changed event or matcher + # i.e., remove old entries of this hook with the same ID + 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 + # Don't clean up the one we just wrote to + if ev == spec.event and grp is target_group: + # Clean up duplicates inside the same group if any + grp["hooks"] = [h for idx, h in enumerate(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) + + 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) + 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"]) + grp["hooks"] = [h for h in grp["hooks"] if not (isinstance(h, dict) and h.get("id") == id)] + 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) + + if removed: + 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 {} + try: + payload = json.loads(text) + except json.JSONDecodeError as error: + raise MutationError( + f"{self.harness} settings file is not valid JSON: {error}", + status=409, + ) from error + return payload if isinstance(payload, dict) else {} + + def _dump_document(self, document: dict[str, object]) -> str: + return json.dumps(document, ensure_ascii=False, indent=2) + "\n" + + def _read_entries(self) -> tuple[_RawHookEntry, ...]: + if not self.config_path.is_file(): + return () + document = self._load_document(self.config_path) + hooks_subtree = document.get("hooks", {}) + if not isinstance(hooks_subtree, dict): + raise MutationError("The top-level 'hooks' key is not an object", status=409) + + entries: list[_RawHookEntry] = [] + for event, matcher_groups in hooks_subtree.items(): + if not isinstance(matcher_groups, list): + continue + for group_idx, group in enumerate(matcher_groups): + if not isinstance(group, dict): + continue + matcher = group.get("matcher") + hooks_list = group.get("hooks", []) + if not isinstance(hooks_list, list): + continue + for hook_idx, hook in enumerate(hooks_list): + if not isinstance(hook, dict): + continue + hook_id = hook.get("id") + if not isinstance(hook_id, str) or not hook_id: + # Fallback for unmanaged/manually created hooks + command = hook.get("command", "") + cmd_hash = hashlib.sha256(command.encode("utf-8")).hexdigest()[:16] + hook_id = f"manual:{cmd_hash}" + entries.append( + _RawHookEntry( + id=hook_id, + event=event, + matcher=matcher, + payload=dict(hook), + group_index=group_idx, + hook_index=hook_idx, + ) + ) + return tuple(entries) + + +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 _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" + + +import hashlib + +__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..d83f5c1 --- /dev/null +++ b/skill_manager/application/hooks/contracts.py @@ -0,0 +1,114 @@ +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"] + + +@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 + + +@dataclass(frozen=True) +class HookBinding: + harness: str + id: str + state: BindingState + drift_detail: 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..ae33aa2 --- /dev/null +++ b/skill_manager/application/hooks/inventory.py @@ -0,0 +1,72 @@ +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, + ) + 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..86cf63b --- /dev/null +++ b/skill_manager/application/hooks/managed_state.py @@ -0,0 +1,102 @@ +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 + 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..76fb3c7 --- /dev/null +++ b/skill_manager/application/hooks/mappers.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Mapping, Protocol + +from skill_manager.errors import MutationError + +from .store import HookSpec + + +class HookMapper(Protocol): + """Translates between HookSpec and a single harness's per-hook payload dict.""" + + def spec_to_dict(self, spec: HookSpec) -> dict[str, object]: ... + + def dict_to_spec( + self, + event: str, + matcher: str | None, + raw: Mapping[str, object], + ) -> HookSpec: ... + + +class ClaudeCodeHooksMapper: + """Mapper for Claude Code hooks under ~/.claude/settings.json.""" + + 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, + matcher: 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) + + # Retrieve or generate a stable id + raw_id = raw.get("id") + if isinstance(raw_id, str) and raw_id: + hook_id = raw_id + else: + # Fallback for unmanaged/manually created hooks: generate a stable hash from command + 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, + matcher=matcher, + timeout=timeout, + ) + + +_MAPPERS: dict[str, HookMapper] = { + "claude-code-hooks": ClaudeCodeHooksMapper(), +} + + +def get_mapper(kind: str) -> HookMapper: + if kind not in _MAPPERS: + raise ValueError(f"unknown hooks mapper kind: {kind}") + return _MAPPERS[kind] + + +import hashlib + +__all__ = ["ClaudeCodeHooksMapper", "HookMapper", "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..0e0a54d --- /dev/null +++ b/skill_manager/application/hooks/store.py @@ -0,0 +1,212 @@ +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 + matcher: 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.matcher is not None: + payload["matcher"] = self.matcher + if self.timeout is not None: + payload["timeout"] = self.timeout + return payload + + @classmethod + def from_dict(cls, payload: Mapping[str, object]) -> HookSpec: + return cls( + id=str(payload["id"]), + event=str(payload["event"]), + command=str(payload["command"]), + matcher=_optional_str(payload.get("matcher")), + 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, + "matcher": spec.matcher, + "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/harness/catalog.py b/skill_manager/harness/catalog.py index 77a8bd9..96670eb 100644 --- a/skill_manager/harness/catalog.py +++ b/skill_manager/harness/catalog.py @@ -90,6 +90,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", 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..c9908bb --- /dev/null +++ b/tests/integration/test_hooks_routes.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from tests.support.app_harness import AppTestHarness + + +class HookRoutesTests(unittest.TestCase): + def test_list_starts_empty(self) -> None: + with AppTestHarness() as harness: + payload = harness.get_json("/api/hooks") + self.assertEqual(payload["entries"], []) + # Only claude should support hooks + harness_names = [col["harness"] for col in payload["columns"]] + self.assertIn("claude", harness_names) + self.assertNotIn("cursor", harness_names) + + def test_create_and_delete_hook(self) -> None: + with AppTestHarness() as harness: + # Create + response = harness.post_json( + "/api/hooks", + { + "id": "my-hook", + "event": "PreToolUse", + "command": "echo hello", + "matcher": "Bash", + "timeout": 30, + "description": "Say hello", + }, + ) + self.assertTrue(response["ok"]) + self.assertEqual(response["hook"]["id"], "my-hook") + self.assertEqual(response["hook"]["command"], "echo hello") + + # 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_hook(self) -> None: + with AppTestHarness() as harness: + # Create + harness.post_json( + "/api/hooks", + { + "id": "my-hook", + "event": "PreToolUse", + "command": "echo hello", + "matcher": "Bash", + "timeout": 30, + "description": "Say hello", + }, + ) + + # Enable on Claude + enabled = harness.post_json( + "/api/hooks/my-hook/enable", + {"harness": "claude"}, + ) + self.assertTrue(enabled["ok"]) + + # Verify Claude settings file exists and contains the hook + 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.assertIn("hooks", settings) + self.assertIn("PreToolUse", settings["hooks"]) + groups = settings["hooks"]["PreToolUse"] + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["matcher"], "Bash") + hooks = groups[0]["hooks"] + self.assertEqual(len(hooks), 1) + self.assertEqual(hooks[0]["id"], "my-hook") + self.assertEqual(hooks[0]["command"], "echo hello") + self.assertEqual(hooks[0]["timeout"], 30) + + # Disable on Claude + disabled = harness.post_json( + "/api/hooks/my-hook/disable", + {"harness": "claude"}, + ) + self.assertTrue(disabled["ok"]) + + # Verify Claude settings file no longer contains the hook + settings_after = json.loads(settings_path.read_text(encoding="utf-8")) + self.assertNotIn("hooks", settings_after) + + def test_reconcile_hook(self) -> None: + with AppTestHarness() as harness: + # 1. Manually write an unmanaged hook to the Claude config + settings_path = harness.spec.home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + settings_path.write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo manual", + "id": "manual-hook", + } + ], + } + ] + } + } + ), + encoding="utf-8", + ) + + # 2. Check that it is detected as unmanaged + payload = harness.get_json("/api/hooks") + entry_ids = [entry["id"] for entry in payload["entries"]] + self.assertIn("manual-hook", entry_ids) + entry = next(e for e in payload["entries"] if e["id"] == "manual-hook") + self.assertEqual(entry["kind"], "unmanaged") + + # 3. Create a managed hook with the same ID + response = harness.post_json( + "/api/hooks", + { + "id": "manual-hook", + "event": "PreToolUse", + "command": "echo managed", + "matcher": "Bash", + "timeout": 30, + "description": "Say managed", + }, + ) + self.assertTrue(response["ok"]) + + # 4. Reconcile keeping "managed" configuration + reconcile_resp = harness.post_json( + "/api/hooks/manual-hook/reconcile", + { + "sourceKind": "managed", + "harnesses": ["claude"], + }, + ) + self.assertTrue(reconcile_resp["ok"]) + + # 5. Verify the Claude settings have been updated to the managed version + settings = json.loads(settings_path.read_text(encoding="utf-8")) + hook_entry = settings["hooks"]["PreToolUse"][0]["hooks"][0] + self.assertEqual(hook_entry["command"], "echo managed") + self.assertEqual(hook_entry["timeout"], 30) + + def test_set_hook_harnesses(self) -> None: + with AppTestHarness() as harness: + harness.post_json( + "/api/hooks", + { + "id": "my-hook", + "event": "PreToolUse", + "command": "echo hello", + "matcher": "Bash", + "timeout": 30, + "description": "Say hello", + }, + ) + + # Set enabled harnesses to "enabled" (which will enable it on Claude) + set_resp = harness.post_json( + "/api/hooks/my-hook/set-harnesses", + {"target": "enabled"}, + ) + self.assertTrue(set_resp["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") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_hooks_adapters.py b/tests/unit/test_hooks_adapters.py new file mode 100644 index 0000000..101f054 --- /dev/null +++ b/tests/unit/test_hooks_adapters.py @@ -0,0 +1,215 @@ +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="PreToolUse", + command="echo hello", + matcher="Bash", + 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") + + # Verify both existing and new hooks exist under PreToolUse/Bash + 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..f0c5fdb --- /dev/null +++ b/tests/unit/test_hooks_mappers.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import unittest +from skill_manager.application.hooks.mappers import ClaudeCodeHooksMapper +from skill_manager.application.hooks.store import HookSpec +from skill_manager.errors import MutationError + + +class ClaudeCodeHooksMapperTests(unittest.TestCase): + def test_spec_to_dict_without_timeout(self) -> None: + mapper = ClaudeCodeHooksMapper() + spec = HookSpec( + id="test-hook", + event="PreToolUse", + command="echo hello", + matcher="Bash", + ) + d = mapper.spec_to_dict(spec) + self.assertEqual(d["type"], "command") + self.assertEqual(d["command"], "echo hello") + self.assertEqual(d["id"], "test-hook") + self.assertNotIn("timeout", d) + + def test_spec_to_dict_with_timeout(self) -> None: + mapper = ClaudeCodeHooksMapper() + spec = HookSpec( + id="test-hook", + event="PreToolUse", + command="echo hello", + matcher="Bash", + timeout=30, + ) + d = mapper.spec_to_dict(spec) + self.assertEqual(d["type"], "command") + self.assertEqual(d["command"], "echo hello") + self.assertEqual(d["id"], "test-hook") + self.assertEqual(d["timeout"], 30) + + def test_dict_to_spec_preserves_id(self) -> None: + mapper = ClaudeCodeHooksMapper() + raw = { + "type": "command", + "command": "echo hello", + "id": "my-id", + "timeout": 45, + } + spec = mapper.dict_to_spec("PreToolUse", "Bash", raw) + self.assertEqual(spec.id, "my-id") + self.assertEqual(spec.event, "PreToolUse") + self.assertEqual(spec.command, "echo hello") + self.assertEqual(spec.matcher, "Bash") + self.assertEqual(spec.timeout, 45) + + def test_dict_to_spec_generates_stable_id_when_missing(self) -> None: + mapper = ClaudeCodeHooksMapper() + raw = { + "type": "command", + "command": "echo hello", + } + spec1 = mapper.dict_to_spec("PreToolUse", "Bash", raw) + spec2 = mapper.dict_to_spec("PreToolUse", "Bash", raw) + self.assertTrue(spec1.id.startswith("manual:")) + self.assertEqual(spec1.id, spec2.id) + + def test_dict_to_spec_errors_if_command_not_string(self) -> None: + mapper = ClaudeCodeHooksMapper() + with self.assertRaises(MutationError): + mapper.dict_to_spec("PreToolUse", None, {"type": "command", "command": 123}) + + +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..0e2e9e0 --- /dev/null +++ b/tests/unit/test_hooks_store.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import json +import unittest +import hashlib +from pathlib import Path +from tempfile import TemporaryDirectory + +from skill_manager.application.hooks.store import HookSpec, HookStore, compute_revision + + +def _spec(hook_id: str = "test-hook", **overrides) -> HookSpec: + base = dict( + id=hook_id, + event="PreToolUse", + command="echo hello", + matcher="Bash", + 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="PostToolUse", 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() From 8e2951cef48e28aa68c2c96d46cdb0102a035e4a Mon Sep 17 00:00:00 2001 From: execsumo Date: Sat, 13 Jun 2026 17:37:40 -0700 Subject: [PATCH 05/12] frontend: implement hooks dashboard and integrate with app capability registry - Create hooks feature with components, sheets, API queries, models and localizations - Add routes and wire hooks into App.tsx and capability registries (overview, sidebar, invalidation) - Update README with Antigravity (agy) integration details Co-Authored-By: agy --- README.md | 10 +- frontend/src/App.tsx | 20 + frontend/src/api/generated.ts | 534 ++++++++++ frontend/src/api/openapi.json | 959 ++++++++++++++++-- .../app/capability-registry/invalidation.ts | 2 + .../app/capability-registry/overview.test.ts | 3 +- .../src/app/capability-registry/overview.ts | 55 +- .../src/app/capability-registry/sidebar.ts | 42 +- .../src/features/hooks/api/invalidation.ts | 7 + frontend/src/features/hooks/api/keys.ts | 9 + .../features/hooks/api/management-client.ts | 76 ++ .../features/hooks/api/management-queries.ts | 88 ++ .../features/hooks/api/management-types.ts | 13 + .../features/hooks/components/HookCard.tsx | 147 +++ .../hooks/components/HookCardList.tsx | 44 + .../hooks/components/HooksFilterMenu.tsx | 38 + .../components/HooksHarnessLogoStack.tsx | 57 ++ .../hooks/components/HooksMatrixView.tsx | 243 +++++ .../hooks/components/HooksStatusChip.tsx | 24 + .../components/detail/HookDetailSheet.tsx | 242 +++++ .../hooks/components/edit/HookFormDialog.tsx | 228 +++++ frontend/src/features/hooks/i18n.ts | 85 ++ .../src/features/hooks/model/selectors.ts | 194 ++++ .../model/use-hooks-management-controller.ts | 165 +++ .../hooks/model/useHooksInUseViewMode.ts | 17 + frontend/src/features/hooks/public.ts | 26 + .../features/hooks/screens/HooksInUsePage.tsx | 291 ++++++ .../hooks/screens/HooksNeedsReviewPage.tsx | 24 + 28 files changed, 3563 insertions(+), 80 deletions(-) create mode 100644 frontend/src/features/hooks/api/invalidation.ts create mode 100644 frontend/src/features/hooks/api/keys.ts create mode 100644 frontend/src/features/hooks/api/management-client.ts create mode 100644 frontend/src/features/hooks/api/management-queries.ts create mode 100644 frontend/src/features/hooks/api/management-types.ts create mode 100644 frontend/src/features/hooks/components/HookCard.tsx create mode 100644 frontend/src/features/hooks/components/HookCardList.tsx create mode 100644 frontend/src/features/hooks/components/HooksFilterMenu.tsx create mode 100644 frontend/src/features/hooks/components/HooksHarnessLogoStack.tsx create mode 100644 frontend/src/features/hooks/components/HooksMatrixView.tsx create mode 100644 frontend/src/features/hooks/components/HooksStatusChip.tsx create mode 100644 frontend/src/features/hooks/components/detail/HookDetailSheet.tsx create mode 100644 frontend/src/features/hooks/components/edit/HookFormDialog.tsx create mode 100644 frontend/src/features/hooks/i18n.ts create mode 100644 frontend/src/features/hooks/model/selectors.ts create mode 100644 frontend/src/features/hooks/model/use-hooks-management-controller.ts create mode 100644 frontend/src/features/hooks/model/useHooksInUseViewMode.ts create mode 100644 frontend/src/features/hooks/public.ts create mode 100644 frontend/src/features/hooks/screens/HooksInUsePage.tsx create mode 100644 frontend/src/features/hooks/screens/HooksNeedsReviewPage.tsx diff --git a/README.md b/README.md index ddeb90f..7be5493 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,11 @@ The npm wrapper downloads the native release artifact for the current platform a OpenClaw
Docs + + Antigravity CLI
+ Antigravity (agy)
+ Docs + @@ -174,6 +179,7 @@ The npm wrapper downloads the native release artifact for the current platform a | Cursor | Yes | Yes | Yes | | OpenCode | Yes | Yes | Yes | | OpenClaw | Yes | Not Yet | Not Yet | +| Antigravity (agy) | Yes | Yes | Not Yet | ## Local-first safety @@ -218,6 +224,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,7 +239,7 @@ 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. @@ -273,6 +280,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. 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; + /** Matcher */ + matcher?: 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,127 @@ 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: { + /** Driftdetail */ + driftDetail?: string | null; + /** Harness */ + harness: string; + /** + * State + * @enum {string} + */ + state: "managed" | "drifted" | "unmanaged" | "missing"; + }; + /** 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; + /** Matcher */ + matcher?: string | null; + /** Revision */ + revision: string; + /** Timeout */ + timeout?: number | null; + }; /** InstallMarketplaceSkillRequest */ InstallMarketplaceSkillRequest: { /** Installtoken */ @@ -1614,6 +1873,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 +2145,14 @@ export interface components { /** Enabled */ enabled: boolean; }; + /** SetHookHarnessesRequest */ + SetHookHarnessesRequest: { + /** + * Target + * @enum {string} + */ + target: "enabled" | "disabled"; + }; /** SetMcpServerHarnessesRequest */ SetMcpServerHarnessesRequest: { /** Config */ @@ -2293,6 +2572,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..95de517 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" + }, + "matcher": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Matcher" + }, + "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,34 +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": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Defaultmodel" - }, - "defaultProvider": { + "driftDetail": { "anyOf": [ { "type": "string" @@ -691,30 +783,41 @@ "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" + ], + "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 +826,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 +851,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,43 +933,315 @@ } }, "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": "HookInventoryResponse", + "type": "object" + }, + "HookMutationFailureResponse": { + "properties": { + "error": { + "title": "Error", + "type": "string" + }, + "harness": { + "title": "Harness", + "type": "string" + } + }, + "required": [ + "harness", + "error" + ], + "title": "HookMutationFailureResponse", + "type": "object" + }, + "HookMutationResponse": { + "properties": { + "hook": { + "$ref": "#/components/schemas/HookSpecResponse" + }, + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok", + "hook" + ], + "title": "HookMutationResponse", + "type": "object" + }, + "HookSetHarnessesResultResponse": { + "properties": { + "failed": { + "items": { + "$ref": "#/components/schemas/HookMutationFailureResponse" + }, + "title": "Failed", + "type": "array" + }, + "ok": { + "title": "Ok", + "type": "boolean" + }, + "succeeded": { + "items": { + "type": "string" + }, + "title": "Succeeded", + "type": "array" + } + }, + "required": [ + "ok", + "succeeded", + "failed" + ], + "title": "HookSetHarnessesResultResponse", + "type": "object" + }, + "HookSpecResponse": { + "properties": { + "command": { + "title": "Command", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "event": { + "title": "Event", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "installedAt": { + "title": "Installedat", + "type": "string" + }, + "matcher": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Matcher" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "timeout": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timeout" + } + }, + "required": [ + "id", + "event", + "command", + "description", + "installedAt", + "revision" + ], + "title": "HookSpecResponse", + "type": "object" + }, + "InstallMarketplaceSkillRequest": { + "properties": { + "installToken": { + "minLength": 1, + "title": "Installtoken", + "type": "string" + } + }, + "required": [ + "installToken" + ], + "title": "InstallMarketplaceSkillRequest", + "type": "object" + }, + "LLMDetectionResponse": { + "properties": { + "defaultModel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Defaultmodel" + }, + "defaultProvider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Defaultprovider" + }, + "hasAnyAvailable": { + "title": "Hasanyavailable", + "type": "boolean" + }, + "providers": { + "items": { + "$ref": "#/components/schemas/DetectedProviderResponse" + }, + "title": "Providers", + "type": "array" + } + }, + "required": [ + "providers", + "hasAnyAvailable" + ], + "title": "LLMDetectionResponse", + "type": "object" + }, + "McpAdoptionIssueResponse": { + "properties": { + "configPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Configpath" + }, + "harness": { + "title": "Harness", + "type": "string" + }, + "label": { + "title": "Label", + "type": "string" + }, + "logoKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logokey" + }, + "name": { + "title": "Name", + "type": "string" + }, + "payloadPreview": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Payloadpreview" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "harness", + "label", + "name", + "reason" + ], + "title": "McpAdoptionIssueResponse", + "type": "object" + }, + "McpApplyConfigResponse": { + "properties": { + "failed": { + "items": { + "$ref": "#/components/schemas/McpMutationFailureResponse" + }, + "title": "Failed", + "type": "array" + }, + "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" @@ -2548,7 +2987,50 @@ "title": "OkResponse", "type": "object" }, - "ReconcileMcpServerRequest": { + "ReconcileHookRequest": { + "additionalProperties": false, + "properties": { + "harnesses": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Harnesses" + }, + "observedHarness": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Observed harness" + }, + "sourceKind": { + "enum": [ + "managed", + "harness" + ], + "title": "Sourcekind", + "type": "string" + } + }, + "required": [ + "sourceKind" + ], + "title": "ReconcileHookRequest", + "type": "object" + }, + "ReconcileMcpServerRequest": { "additionalProperties": false, "properties": { "harnesses": { @@ -3198,6 +3680,23 @@ "title": "SetHarnessSupportRequest", "type": "object" }, + "SetHookHarnessesRequest": { + "properties": { + "target": { + "enum": [ + "enabled", + "disabled" + ], + "title": "Target", + "type": "string" + } + }, + "required": [ + "target" + ], + "title": "SetHookHarnessesRequest", + "type": "object" + }, "SetMcpServerHarnessesRequest": { "properties": { "config": { @@ -4430,6 +4929,332 @@ "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}": { "get": { "operationId": "get_cli_marketplace_detail_api_marketplace_clis_items__slug__get", 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/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..e402a9c --- /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; + matcher?: 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: