diff --git a/CHANGELOG.md b/CHANGELOG.md index 199fb3b5..16324f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Features +- `policies --install`: redesign the multi-CLI selection menu in `src/hooks/install-prompt.ts` so it groups options into a `Detected (N)` section (with a `★ Install for all N detected` aggregate row) and, for the install action only, a `Not installed (M) · install hooks ahead of time` section listing every undetected supported CLI as a forward-install option. Markers are colored — yellow `★` for the aggregate row, green `●` for detected rows, dim `○` for undetected — and labels for undetected CLIs render dim so the visual hierarchy matches the semantic one. Replaces the previous flat "All / Claude Code only / Codex only / …" list whose lone right-aligned description on the "All" row left odd column widths. The uninstall flow continues to show only detected CLIs (you cannot remove from what was never installed) and now reads "Remove from all N detected" on its aggregate row. Refactor extracts the option-building logic into a new exported `buildCliMenuOptions(detected, action)` helper so the layout rules (aggregate row only when `detected.length > 1`, undetected only when `action === "install"`) are unit-testable without driving the keypress loop. Also syncs `docs/configuration.mdx` to describe the new sectioned layout (#302). + ### Fixes - `scripts/translate-docs`: switch the Tier 2/3 default from the dated snapshot ID `claude-haiku-4-5-20251001` to the alias `claude-haiku-4-5` (so model access matches the CI key's scope), and lower `MAX_CONCURRENT` from 10 to 2 to stop the gateway behind `ANTHROPIC_BASE_URL` from dropping most parallel requests with `Connection error`. Empirically observed: at concurrency 10, a 6-request Korean batch returned 2 ok + 4 connection-resets; per-language CI matrix already parallelizes across the 14 languages, so the lower per-language limit doesn't meaningfully extend wall-clock time (#300). diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index f4d97d42..7c2c2e2f 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -132,4 +132,72 @@ describe("hooks/install-prompt", () => { expect(uninstallResult).toEqual(["claude", "codex", "copilot", "cursor"]); }); }); + + describe("buildCliMenuOptions", () => { + it("install action: detected first with aggregate row, then every undetected CLI", async () => { + const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); + const { options, undetected } = buildCliMenuOptions( + ["claude", "codex"], + "install", + ); + + // 1 aggregate "all" + 2 detected + 5 undetected + expect(options).toHaveLength(8); + expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "gemini"]); + + expect(options[0]).toMatchObject({ isAll: true, detected: true, value: ["claude", "codex"] }); + expect(options[0].label).toBe("Install for all 2 detected"); + + // Detected rows preserve order and carry detected=true + expect(options.slice(1, 3)).toEqual([ + { label: "Claude Code", value: ["claude"], detected: true, isAll: false }, + { label: "OpenAI Codex", value: ["codex"], detected: true, isAll: false }, + ]); + + // Undetected rows carry detected=false + const undetectedRows = options.slice(3); + expect(undetectedRows.every((o) => !o.detected && !o.isAll)).toBe(true); + expect(undetectedRows.map((o) => o.label)).toEqual([ + "GitHub Copilot", + "Cursor Agent", + "OpenCode", + "Pi", + "Gemini CLI", + ]); + }); + + it("uninstall action: only detected rows, no undetected (and verb is 'Remove from')", async () => { + const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); + const { options, undetected } = buildCliMenuOptions( + ["claude", "codex", "copilot"], + "uninstall", + ); + + expect(undetected).toEqual([]); + expect(options).toHaveLength(4); // 1 aggregate + 3 detected + expect(options[0].label).toBe("Remove from all 3 detected"); + expect(options.every((o) => o.detected)).toBe(true); + }); + + it("install with all 7 detected: no aggregate-row needed beyond the standard one, no undetected section", async () => { + const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); + const { options, undetected } = buildCliMenuOptions( + ["claude", "codex", "copilot", "cursor", "opencode", "pi", "gemini"], + "install", + ); + + expect(undetected).toEqual([]); + expect(options).toHaveLength(8); // aggregate + 7 detected + expect(options[0].label).toBe("Install for all 7 detected"); + }); + + it("install with 1 detected + many undetected: skips aggregate row (1 ≯ 1)", async () => { + const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); + const { options } = buildCliMenuOptions(["claude"], "install"); + + // No aggregate when only 1 detected — first row is the detected CLI itself. + expect(options[0]).toMatchObject({ label: "Claude Code", isAll: false }); + expect(options.filter((o) => o.isAll)).toEqual([]); + }); + }); }); diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 4aea96de..90ad0a87 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -216,7 +216,7 @@ failproofai policies --install --cli claude codex copilot cursor opencode pi gem When `--cli` is omitted, `failproofai` detects which agent CLIs are installed (`which claude` / `which codex` / `which copilot` / `which cursor-agent` / `which opencode` / `which pi` / `which gemini`): - **One CLI detected** — auto-selects that CLI without prompting. -- **Multiple CLIs detected** in an interactive terminal — shows an arrow-key single-select prompt: when two CLIs are present the choices are `Both`, ` only`, ` only`; with three CLIs the first option becomes `All` (↑↓ to move, Enter to select, ^C to quit). +- **Multiple CLIs detected** in an interactive terminal — shows an arrow-key single-select prompt grouped into a `Detected (N)` section (with an `Install for all N detected` aggregate row + each detected CLI individually) and a `Not installed (M) · install hooks ahead of time` section listing every undetected supported CLI as a forward-install option (↑↓ to move, Enter to select, ^C to quit). The uninstall flow shows only the Detected section. - **Multiple CLIs detected** in a non-interactive run (CI, no TTY) — installs for all detected CLIs without prompting. - **None detected** — falls back to `claude`, with a warning that no agent binary was found in PATH; the hook command is still written so it activates as soon as you install one. diff --git a/src/hooks/install-prompt.ts b/src/hooks/install-prompt.ts index 356b482f..4b6817c3 100644 --- a/src/hooks/install-prompt.ts +++ b/src/hooks/install-prompt.ts @@ -12,7 +12,7 @@ import * as readline from "node:readline"; import { BUILTIN_POLICIES } from "./builtin-policies"; import { detectInstalledClis, getIntegration } from "./integrations"; -import type { IntegrationType } from "./types"; +import { INTEGRATION_TYPES, type IntegrationType } from "./types"; interface SelectItem { name: string; @@ -85,24 +85,115 @@ export async function resolveTargetClis( return promptCliTargetSelection(detected, action); } +/** Selectable row in the CLI target menu. Exported for unit tests. */ +export interface CliMenuOption { + label: string; + value: IntegrationType[]; + /** True when the underlying CLI was found on PATH. */ + detected: boolean; + /** True for the aggregated "Install for all detected" row. */ + isAll: boolean; +} + +/** + * Build the option list for the CLI target menu. + * + * • install action → detected first (with optional aggregate row), then + * every undetected CLI as a forward-install option. + * • uninstall action → detected only (you cannot remove from what was never + * installed); aggregate row says "Remove from all N". + */ +export function buildCliMenuOptions( + detected: IntegrationType[], + action: CliPromptAction, +): { options: CliMenuOption[]; undetected: IntegrationType[] } { + const undetected: IntegrationType[] = + action === "install" + ? INTEGRATION_TYPES.filter((id) => !detected.includes(id)) + : []; + + const options: CliMenuOption[] = []; + if (detected.length > 1) { + const verb = action === "uninstall" ? "Remove from" : "Install for"; + options.push({ + label: `${verb} all ${detected.length} detected`, + value: detected, + detected: true, + isAll: true, + }); + } + for (const id of detected) { + options.push({ + label: getIntegration(id).displayName, + value: [id], + detected: true, + isAll: false, + }); + } + for (const id of undetected) { + options.push({ + label: getIntegration(id).displayName, + value: [id], + detected: false, + isAll: false, + }); + } + return { options, undetected }; +} + /** * Interactive arrow-key single-select for "install/remove for which CLI?" when - * multiple agent CLIs are detected. Visual style mirrors promptPolicySelection. + * multiple agent CLIs are detected. + * + * Layout: + * • DETECTED section: an "Install for all detected" option (only when >1 + * detected) followed by each detected CLI individually. + * • NOT INSTALLED section (install action only): each undetected CLI as a + * forward-install option, so users can prep hooks before adding the CLI. + * + * Cursor skips section headers — it only lands on selectable item rows. */ async function promptCliTargetSelection( detected: IntegrationType[], action: CliPromptAction = "install", ): Promise { - const labels = detected.map((id) => getIntegration(id).displayName).join(" + "); - const allLabel = detected.length > 2 ? "All" : "Both"; - const options: Array<{ label: string; description: string; value: IntegrationType[] }> = [ - { label: allLabel, description: labels, value: detected }, - ...detected.map((id) => ({ - label: `${getIntegration(id).displayName} only`, - description: "", - value: [id] as IntegrationType[], - })), - ]; + const { options, undetected } = buildCliMenuOptions(detected, action); + + type DisplayRow = + | { kind: "header"; title: string; hint?: string } + | { kind: "blank" } + | { kind: "item"; option: CliMenuOption; itemIndex: number }; + + function buildDisplayRows(): DisplayRow[] { + const rows: DisplayRow[] = []; + let itemIndex = 0; + + rows.push({ + kind: "header", + title: `Detected (${detected.length})`, + }); + for (const opt of options) { + if (opt.detected) { + rows.push({ kind: "item", option: opt, itemIndex: itemIndex++ }); + } + } + + if (undetected.length > 0) { + rows.push({ kind: "blank" }); + rows.push({ + kind: "header", + title: `Not installed (${undetected.length})`, + hint: "install hooks ahead of time", + }); + for (const opt of options) { + if (!opt.detected) { + rows.push({ kind: "item", option: opt, itemIndex: itemIndex++ }); + } + } + } + + return rows; + } let cursor = 0; let lastLineCount = 0; @@ -143,26 +234,45 @@ async function promptCliTargetSelection( } const heading = action === "uninstall" ? "Remove Hooks" : "Install Hooks"; - const verb = action === "uninstall" ? "remove from" : "install"; function render(): void { const cols = process.stdout.columns || 120; hideCursor(); const lines: string[] = []; - lines.push(` Failproof AI — ${heading}`); - lines.push(""); - lines.push(` \x1B[2mDetected ${labels}. Choose where to ${verb}:\x1B[0m`); + lines.push(` \x1B[1mFailproof AI\x1B[0m \x1B[2m—\x1B[0m ${heading}`); lines.push(""); - for (let i = 0; i < options.length; i++) { - const opt = options[i]; - const isActive = i === cursor; + for (const row of buildDisplayRows()) { + if (row.kind === "blank") { + lines.push(""); + continue; + } + if (row.kind === "header") { + const hintVisible = row.hint ? ` · ${row.hint}` : ""; + // Line shape: " " + "── " + title + hintVisible + " " + dashes → cols + const dashLen = Math.max(2, cols - 2 - 3 - row.title.length - hintVisible.length - 1); + const suffix = row.hint ? ` \x1B[2m· ${row.hint}\x1B[0m` : ""; + lines.push( + ` \x1B[2m── \x1B[0m\x1B[1m${row.title}\x1B[0m${suffix} \x1B[2m${"─".repeat(dashLen)}\x1B[0m`, + ); + continue; + } + + const opt = row.option; + const isActive = row.itemIndex === cursor; const pointer = isActive ? "\x1B[36m❯\x1B[0m" : " "; - const labelPart = isActive ? `\x1B[1;36m${opt.label}\x1B[0m` : opt.label; - const pad = opt.description ? " ".repeat(Math.max(2, 22 - opt.label.length)) : ""; - const desc = opt.description ? `\x1B[2m${opt.description}\x1B[0m` : ""; - lines.push(` ${pointer} ${labelPart}${pad}${desc}`); + const marker = opt.isAll + ? "\x1B[33m★\x1B[0m" + : opt.detected + ? "\x1B[32m●\x1B[0m" + : "\x1B[2m○\x1B[0m"; + const label = isActive + ? `\x1B[1;36m${opt.label}\x1B[0m` + : opt.detected + ? opt.label + : `\x1B[2m${opt.label}\x1B[0m`; + lines.push(` ${pointer} ${marker} ${label}`); } lines.push(""); @@ -178,6 +288,8 @@ async function promptCliTargetSelection( lastLineCount = lines.length; } + const itemCount = options.length; + return new Promise((resolve) => { render(); readline.emitKeypressEvents(process.stdin); @@ -200,10 +312,10 @@ async function promptCliTargetSelection( process.exit(130); // SIGINT-equivalent } if (key.name === "up") { - cursor = cursor > 0 ? cursor - 1 : options.length - 1; + cursor = cursor > 0 ? cursor - 1 : itemCount - 1; render(); } else if (key.name === "down") { - cursor = cursor < options.length - 1 ? cursor + 1 : 0; + cursor = cursor < itemCount - 1 ? cursor + 1 : 0; render(); } else if (key.name === "return" || key.name === "space") { cleanup();