From 81f498bdd76314fa03eda73f1cfb7d62ed1e2b7b Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 22:50:06 -0700 Subject: [PATCH 1/5] [luv-343] feat: surface per-CLI install status in HooksConfigPayload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends getHooksConfigAction() with a `clis` array (one entry per INTEGRATION_TYPES) carrying the installed/detected/settingsPath state for all 7 agent CLIs. Backend prep for the upcoming multi-CLI selector in the Policies → Configure tab; the legacy installedScopes/settingsPath fields remain for back-compat. Co-Authored-By: Claude Opus 4.7 --- app/actions/get-hooks-config.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/actions/get-hooks-config.ts b/app/actions/get-hooks-config.ts index b2acd414..f3ebfc77 100644 --- a/app/actions/get-hooks-config.ts +++ b/app/actions/get-hooks-config.ts @@ -3,8 +3,10 @@ import { readHooksConfig } from "@/src/hooks/hooks-config"; import { hooksInstalledInSettings, getSettingsPath } from "@/src/hooks/manager"; import { BUILTIN_POLICIES } from "@/src/hooks/builtin-policies"; +import { listIntegrations } from "@/src/hooks/integrations"; import { HOOK_SCOPES } from "@/src/hooks/types"; -import type { HookScope } from "@/src/hooks/types"; +import type { HookScope, IntegrationType } from "@/src/hooks/types"; +import { getCliLabel } from "@/lib/cli-registry"; import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; @@ -32,10 +34,23 @@ export interface CustomPolicyInfo { eventScope?: string; } +export interface CliInstallStatus { + id: IntegrationType; + label: string; + installed: boolean; + settingsPath: string; + /** Whether the agent CLI's binary was found on PATH. */ + detected: boolean; +} + export interface HooksConfigPayload { enabledPolicies: string[]; + /** Claude-only legacy field; kept for back-compat. New UI should consume `clis`. */ installedScopes: HookScope[]; + /** Claude-only legacy field; kept for back-compat. New UI should consume `clis`. */ settingsPath: string; + /** Per-CLI install state at user scope, in `INTEGRATION_TYPES` order. */ + clis: CliInstallStatus[]; policies: PolicyInfo[]; customPoliciesPath?: string; customPolicies?: CustomPolicyInfo[]; @@ -74,6 +89,14 @@ export async function getHooksConfigAction(): Promise { const primaryScope: HookScope = installedScopes[0] ?? "user"; const settingsPath = getSettingsPath(primaryScope); + const clis: CliInstallStatus[] = listIntegrations().map((integration) => ({ + id: integration.id, + label: getCliLabel(integration.id), + installed: integration.hooksInstalledInSettings("user"), + settingsPath: integration.getSettingsPath("user"), + detected: integration.detectInstalled(), + })); + const policies: PolicyInfo[] = BUILTIN_POLICIES.map((p) => ({ name: p.name, description: p.description, @@ -98,6 +121,7 @@ export async function getHooksConfigAction(): Promise { enabledPolicies: config.enabledPolicies, installedScopes, settingsPath, + clis, policies, customPoliciesPath: config.customPoliciesPath, customPolicies: customPolicies?.length ? customPolicies : undefined, From 258f9fcca8411d103db0ec03f202e74a1035674d Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 22:55:24 -0700 Subject: [PATCH 2/5] =?UTF-8?q?[luv-343]=20feat:=20per-CLI=20multi-select?= =?UTF-8?q?=20control=20panel=20in=20Policies=20=E2=86=92=20Configure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Claude-only install banner with a multi-CLI control panel that covers all 7 supported agent CLIs. Each CLI gets a checkbox row with brand- colored accent rail, install status pill (`Active` / `Detected` / `Inactive`), and user-scope settings path. A 7-segment coverage strip across the top and a glowing-LED status header give a glanceable view of which CLIs are protected. Users multi-select CLIs and hit `Apply changes` to install/uninstall the diff in one round-trip; pending changes are flagged in pink (`+ install` / `− remove`) before commit. Detected CLIs are pre-checked on first load so a fresh user is one click from full coverage. Backend: getHooksConfigAction() now returns a `clis` array populated from listIntegrations() in src/hooks/integrations.ts; the existing per-CLI install actions (which already accepted a cli list) are wired up by the UI for the first time. Legacy installedScopes / settingsPath payload fields kept for back-compat. Adds an action-layer unit test that asserts the clis payload shape, ordering, and per-CLI flag mapping. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 3 + __tests__/actions/get-hooks-config.test.ts | 99 ++++++++ app/policies/hooks-client.tsx | 272 +++++++++++++++++---- docs/dashboard.mdx | 4 +- 4 files changed, 334 insertions(+), 44 deletions(-) create mode 100644 __tests__/actions/get-hooks-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a219a7a..352032e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.0.10-beta.13 — 2026-05-10 +### Features +- Dashboard `/policies` Configure tab: replace the single Claude-only install banner with a per-CLI control panel covering all 7 supported agent CLIs (Claude Code, OpenAI Codex, GitHub Copilot, Cursor Agent, OpenCode, Pi, Gemini CLI). The panel shows a numbered slot list with brand-colored accent rails, per-row install status (`Active` / `Detected` / `Inactive` pills using the same palette as the Activity-tab badges), the user-scope settings path in mono, a 7-segment coverage strip across the top, and a glowing-LED status header. Users multi-select CLIs via checkboxes and click `Apply changes` to install/uninstall the diff in one round-trip; pending changes are flagged with `+ install` / `− remove` pills and a pulsing pink counter so the diff is visible before commit. Detected-but-not-installed CLIs are pre-checked so a fresh user lands ready for one-click adoption. Backend: `getHooksConfigAction()` now returns `clis: { id, label, installed, settingsPath, detected }[]` populated from `listIntegrations()` (`src/hooks/integrations.ts`), and the existing `installHooksWebAction(scope, cli?)` / `removeHooksWebAction(scope, cli?)` server actions (which already accepted a per-CLI list) are wired up by the UI for the first time. Legacy `installedScopes` / `settingsPath` payload fields kept for back-compat (#343). + ### Docs - Document the per-CLI `Stop` semantics in `docs/built-in-policies.mdx`. Adds a "Per-CLI Stop semantics" subsection at the top of the Workflow chapter with a 7-row table showing how each supported CLI (Claude / Codex / Copilot / Cursor / Gemini / OpenCode / Pi) enforces `require-*-before-stop` policies, plus a dedicated `` callout explaining the Pi limitation: Pi's `AgentEndEvent` has no Result type so failproofai cannot force-retry the same agent loop, and the gate fires on the **next user turn** via `before_agent_start` system-prompt injection instead. Six other CLIs retry the same loop and look identical to the Claude experience; only Pi visibly stops between turns. Bounds, lifetime, and the `session_shutdown` cleanup contract are all spelled out so users enabling `require-commit-before-stop` etc. on Pi understand what they're seeing before they file a bug. No code changes — pure docs PR (#342). diff --git a/__tests__/actions/get-hooks-config.test.ts b/__tests__/actions/get-hooks-config.test.ts new file mode 100644 index 00000000..72079bcc --- /dev/null +++ b/__tests__/actions/get-hooks-config.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/src/hooks/hooks-config", () => ({ + readHooksConfig: () => ({ enabledPolicies: [] }), +})); + +vi.mock("@/src/hooks/manager", () => ({ + hooksInstalledInSettings: () => false, + getSettingsPath: () => "/tmp/.claude/settings.json", +})); + +const installedFlags: Record = { + claude: true, + codex: false, + copilot: false, + cursor: false, + opencode: false, + pi: false, + gemini: false, +}; + +const detectedFlags: Record = { + claude: true, + codex: true, + copilot: false, + cursor: false, + opencode: false, + pi: false, + gemini: false, +}; + +vi.mock("@/src/hooks/integrations", () => { + const ids = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "gemini"] as const; + const make = (id: (typeof ids)[number]) => ({ + id, + displayName: id, + hooksInstalledInSettings: () => installedFlags[id], + getSettingsPath: () => `/tmp/${id}/settings.json`, + detectInstalled: () => detectedFlags[id], + }); + return { + listIntegrations: () => ids.map(make), + }; +}); + +import { getHooksConfigAction } from "@/app/actions/get-hooks-config"; + +describe("getHooksConfigAction — clis payload", () => { + beforeEach(() => { + // reset to baseline + Object.assign(installedFlags, { + claude: true, codex: false, copilot: false, cursor: false, + opencode: false, pi: false, gemini: false, + }); + Object.assign(detectedFlags, { + claude: true, codex: true, copilot: false, cursor: false, + opencode: false, pi: false, gemini: false, + }); + }); + + it("returns one entry per CLI in registry order", async () => { + const config = await getHooksConfigAction(); + expect(config.clis.map((c) => c.id)).toEqual([ + "claude", "codex", "copilot", "cursor", "opencode", "pi", "gemini", + ]); + }); + + it("reflects installed and detected flags from each integration", async () => { + const config = await getHooksConfigAction(); + const claude = config.clis.find((c) => c.id === "claude")!; + const codex = config.clis.find((c) => c.id === "codex")!; + const gemini = config.clis.find((c) => c.id === "gemini")!; + + expect(claude.installed).toBe(true); + expect(claude.detected).toBe(true); + expect(codex.installed).toBe(false); + expect(codex.detected).toBe(true); + expect(gemini.installed).toBe(false); + expect(gemini.detected).toBe(false); + }); + + it("carries the per-CLI user-scope settingsPath", async () => { + const config = await getHooksConfigAction(); + expect(config.clis.find((c) => c.id === "codex")!.settingsPath).toBe( + "/tmp/codex/settings.json", + ); + expect(config.clis.find((c) => c.id === "pi")!.settingsPath).toBe( + "/tmp/pi/settings.json", + ); + }); + + it("uses cli-registry display labels (not raw ids)", async () => { + const config = await getHooksConfigAction(); + expect(config.clis.find((c) => c.id === "claude")!.label).toBe("Claude Code"); + expect(config.clis.find((c) => c.id === "codex")!.label).toBe("OpenAI Codex"); + expect(config.clis.find((c) => c.id === "gemini")!.label).toBe("Gemini CLI"); + }); +}); diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 64dad82e..ce071f0d 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef, useTransition } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef, useTransition } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; import { @@ -21,6 +21,7 @@ import { getHookActivityAction, searchHookActivityAction } from "@/app/actions/g import type { HookActivityPayload } from "@/app/actions/get-hook-activity"; import { getHooksConfigAction } from "@/app/actions/get-hooks-config"; import type { HooksConfigPayload, PolicyInfo, CustomPolicyInfo } from "@/app/actions/get-hooks-config"; +import type { IntegrationType } from "@/src/hooks/types"; import { togglePolicyAction } from "@/app/actions/update-hooks-config"; import { installHooksWebAction, removeHooksWebAction } from "@/app/actions/install-hooks-web"; import { updatePolicyParamsAction } from "@/app/actions/update-policy-params"; @@ -950,23 +951,59 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install const [actionError, setActionError] = useState(null); const [hooksWarning, setHooksWarning] = useState(null); const [configuringPolicy, setConfiguringPolicy] = useState(null); + const [checkedClis, setCheckedClis] = useState>(() => new Set()); const reload = useCallback(async () => { try { const data = await getHooksConfigAction(); setConfig(data); - onHooksInstallChange?.(data.installedScopes.length > 0); + onHooksInstallChange?.(data.clis.some((c) => c.installed)); } catch { // Non-critical } }, [onHooksInstallChange]); - // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { reload(); }, [reload]); + // Sync the checkbox set with payload on each reload so the UI starts from + // "what's installed now". Pre-check detected-but-not-installed CLIs so a + // fresh user lands ready for one-click install. + useEffect(() => { + if (!config) return; + setCheckedClis(new Set(config.clis.filter((c) => c.installed || c.detected).map((c) => c.id))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config?.clis]); + + const installedCliSet = useMemo( + () => new Set((config?.clis ?? []).filter((c) => c.installed).map((c) => c.id)), + [config?.clis], + ); + + const pendingChanges = useMemo(() => { + const toInstall: IntegrationType[] = []; + const toRemove: IntegrationType[] = []; + for (const cli of config?.clis ?? []) { + const isChecked = checkedClis.has(cli.id); + if (isChecked && !installedCliSet.has(cli.id)) toInstall.push(cli.id); + if (!isChecked && installedCliSet.has(cli.id)) toRemove.push(cli.id); + } + return { toInstall, toRemove }; + }, [config?.clis, checkedClis, installedCliSet]); + + const hasPendingChanges = pendingChanges.toInstall.length > 0 || pendingChanges.toRemove.length > 0; + + const toggleCli = (id: IntegrationType) => { + setCheckedClis((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + const handleToggle = (name: string, currentlyEnabled: boolean) => { if (!config) return; - const installed = config.installedScopes.length > 0; + const installed = config.clis.some((c) => c.installed); if (!installed) { setHooksWarning("Policies are not installed. Install policies to continue."); return; @@ -995,26 +1032,31 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install }); }; - const handleInstall = () => { + const handleApply = () => { + const { toInstall, toRemove } = pendingChanges; + if (toInstall.length === 0 && toRemove.length === 0) return; startTransition(async () => { try { setActionError(null); - await installHooksWebAction("user"); + if (toInstall.length > 0) await installHooksWebAction("user", toInstall); + if (toRemove.length > 0) await removeHooksWebAction("user", toRemove); await reload(); } catch (e) { - setActionError(e instanceof Error ? e.message : "Failed to install hooks."); + setActionError(e instanceof Error ? e.message : "Failed to apply changes."); } }); }; - const handleRemove = () => { + const handleReinstall = () => { + const targets = Array.from(checkedClis); + if (targets.length === 0) return; startTransition(async () => { try { setActionError(null); - await removeHooksWebAction("user"); + await installHooksWebAction("user", targets); await reload(); } catch (e) { - setActionError(e instanceof Error ? e.message : "Failed to remove hooks."); + setActionError(e instanceof Error ? e.message : "Failed to reinstall."); } }); }; @@ -1042,7 +1084,9 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install ); } - const installed = config.installedScopes.length > 0; + const installed = config.clis.some((c) => c.installed); + const installedCount = installedCliSet.size; + const checkedCount = checkedClis.size; // Group policies by category const categories = Array.from(new Set(config.policies.map((p) => p.category))); @@ -1057,45 +1101,189 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install /> )}
- {/* Install status banner */} -
-
- - - {installed ? "Policies installed" : "Policies not installed"} - - {installed && ( - - · {config.installedScopes.join(", ")} scope · {config.settingsPath} + {/* CLI control panel — header */} +
+
+
+ + + Integrations + +
+ · +
+ + {installedCount.toString().padStart(2, "0")} + / 0{config.clis.length} active +
+ {hasPendingChanges && ( + <> + · + + + {pendingChanges.toInstall.length + pendingChanges.toRemove.length} pending + + )}
- {installed && ( - - )} +
+ {/* 7-segment coverage strip */} +
+ {config.clis.map((cli) => { + const isActive = installedCliSet.has(cli.id); + const accentClass = + getCliBadgeClasses(cli.id).split(" ").find((c) => c.startsWith("text-")) ?? + "text-foreground"; + return ( +
+ ); + })} +
+ + {/* CLI rows */} +
+ {config.clis.map((cli, i) => { + const isChecked = checkedClis.has(cli.id); + const isInstalled = installedCliSet.has(cli.id); + const willChange = isChecked !== isInstalled; + const badge = getCliBadgeClasses(cli.id); + const accentClass = + badge.split(" ").find((c) => c.startsWith("text-")) ?? "text-foreground"; + + return ( +
- {/* 7-segment coverage strip */} -
- {config.clis.map((cli) => { - const isActive = installedCliSet.has(cli.id); - const accentClass = - getCliBadgeClasses(cli.id).split(" ").find((c) => c.startsWith("text-")) ?? - "text-foreground"; - return ( -
- ); - })} -
- {/* CLI rows */}
{config.clis.map((cli, i) => { From f086c38362b517a54b257817d882707751d771ff Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 23:09:49 -0700 Subject: [PATCH 5/5] [luv-343] fix: address CodeRabbit review on Apply / Reinstall semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues flagged on PR #344: 1. handleApply skipped reload() on partial-failure paths — if install succeeded but remove failed, the next click would compute the diff against stale UI state. Move reload() into a finally block so the page resyncs after every batch attempt regardless of partial outcomes. 2. handleReinstall iterated over Array.from(checkedClis), which includes detected-but-not-installed CLIs that the UI pre-checks as a one-click adoption hint. Clicking Reinstall could therefore silently install brand- new CLIs from a button labeled "Reinstall". Filter to the intersection of checked × installedCliSet so the action matches its label; first-time installs go through Apply. 3. Re-wire the "Policies are not installed" toast button from handleReinstall to handleApply so the first-install flow (where installedCliSet is empty) still works from the toast. Co-Authored-By: Claude Opus 4.7 --- app/policies/hooks-client.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index c91ef2de..092e10fa 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -1047,23 +1047,31 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install setActionError(null); if (toInstall.length > 0) await installHooksWebAction("user", toInstall); if (toRemove.length > 0) await removeHooksWebAction("user", toRemove); - await reload(); } catch (e) { setActionError(e instanceof Error ? e.message : "Failed to apply changes."); + } finally { + // Always resync so a partial-success batch (install OK, remove failed) + // doesn't leave the UI showing stale install state on the next click. + await reload(); } }); }; const handleReinstall = () => { - const targets = Array.from(checkedClis); + // Reinstall acts on the intersection of checked × installed. Detected-but- + // not-installed CLIs are pre-checked as a one-click install hint, so a raw + // Array.from(checkedClis) would silently install brand-new CLIs from the + // Reinstall button. Use Apply for first-time installs. + const targets = Array.from(installedCliSet).filter((id) => checkedClis.has(id)); if (targets.length === 0) return; startTransition(async () => { try { setActionError(null); await installHooksWebAction("user", targets); - await reload(); } catch (e) { setActionError(e instanceof Error ? e.message : "Failed to reinstall."); + } finally { + await reload(); } }); }; @@ -1296,7 +1304,7 @@ function PoliciesTab({ onHooksInstallChange }: { onHooksInstallChange?: (install setHooksWarning(null)} - onInstall={handleReinstall} + onInstall={handleApply} isPending={isPending} /> )}