Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Note>` 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).

Expand Down
99 changes: 99 additions & 0 deletions __tests__/actions/get-hooks-config.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean> = {
claude: true,
codex: false,
copilot: false,
cursor: false,
opencode: false,
pi: false,
gemini: false,
};

const detectedFlags: Record<string, boolean> = {
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");
});
});
26 changes: 25 additions & 1 deletion app/actions/get-hooks-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -74,6 +89,14 @@ export async function getHooksConfigAction(): Promise<HooksConfigPayload> {
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,
Expand All @@ -98,6 +121,7 @@ export async function getHooksConfigAction(): Promise<HooksConfigPayload> {
enabledPolicies: config.enabledPolicies,
installedScopes,
settingsPath,
clis,
policies,
customPoliciesPath: config.customPoliciesPath,
customPolicies: customPolicies?.length ? customPolicies : undefined,
Expand Down
Loading