diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a219a7a..bcf995f8 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 (#344). + ### 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/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, diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 64dad82e..092e10fa 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,66 @@ 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 cliCheckboxesInitializedRef = useRef(false); 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 first load only, pre-check + // detected-but-not-installed CLIs so a fresh user lands ready for one-click + // install. After that, sync strictly to `installed` so unchecking a still- + // detected CLI and clicking Apply doesn't re-tick the box on reload. + useEffect(() => { + if (!config) return; + if (!cliCheckboxesInitializedRef.current) { + cliCheckboxesInitializedRef.current = true; + setCheckedClis(new Set(config.clis.filter((c) => c.installed || c.detected).map((c) => c.id))); + } else { + setCheckedClis(new Set(config.clis.filter((c) => c.installed).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 +1039,39 @@ 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"); - await reload(); + if (toInstall.length > 0) await installHooksWebAction("user", toInstall); + if (toRemove.length > 0) await removeHooksWebAction("user", toRemove); } catch (e) { - setActionError(e instanceof Error ? e.message : "Failed to install hooks."); + 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 handleRemove = () => { + const handleReinstall = () => { + // 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 removeHooksWebAction("user"); - await reload(); + await installHooksWebAction("user", targets); } catch (e) { - setActionError(e instanceof Error ? e.message : "Failed to remove hooks."); + setActionError(e instanceof Error ? e.message : "Failed to reinstall."); + } finally { + await reload(); } }); }; @@ -1042,7 +1099,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 +1116,170 @@ 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 && ( - - )} +
+ {/* 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 ( +