Skip to content
Merged
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 @@

## 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).

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### 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).

Expand Down
68 changes: 68 additions & 0 deletions __tests__/hooks/install-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
});
2 changes: 1 addition & 1 deletion docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `<CLI A> only`, `<CLI B> 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.

Expand Down
162 changes: 137 additions & 25 deletions src/hooks/install-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IntegrationType[]> {
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;
Expand Down Expand Up @@ -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("");
Expand All @@ -178,6 +288,8 @@ async function promptCliTargetSelection(
lastLineCount = lines.length;
}

const itemCount = options.length;

return new Promise<IntegrationType[]>((resolve) => {
render();
readline.emitKeypressEvents(process.stdin);
Expand All @@ -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();
Expand Down