diff --git a/CHANGELOG.md b/CHANGELOG.md index 700d370b..63612063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add GitHub Copilot CLI integration (beta) across hooks, activity dashboard, session fallback, and `/projects` listing. Also ships this repo's own `.github/hooks/failproofai.json` so contributors developing failproofai with the GitHub Copilot CLI get hooks active automatically, mirroring the existing `.claude/settings.json` and `.codex/hooks.json` (#236) - Add Cursor Agent CLI integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. New `--cli cursor` flag installs into `~/.cursor/hooks.json` (user) or `/.cursor/hooks.json` (project) using Cursor's flat-array schema with camelCase event keys (`preToolUse`, `beforeSubmitPrompt`, …); the handler canonicalizes to PascalCase via `CURSOR_EVENT_MAP` so existing builtin policies fire unchanged. The policy evaluator emits Cursor's `{permission, user_message, agent_message, additional_context, followup_message}` stdout shape. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.cursor/` and `.cursor/hooks.json`. Frontend: `lib/cli-registry.ts` adds a `Cursor Agent` entry with an emerald badge; `lib/projects.ts` merges Cursor projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Also ships this repo's own `.cursor/hooks.json` so contributors using Cursor get hooks active automatically (#245). - Project page (`/project/[name]`): list Copilot and Cursor sessions alongside Claude + Codex, mirroring the existing merge logic on the projects index. Previously the project detail view only enumerated Claude + Codex transcripts (#245). +- Honor `NO_COLOR` and add `--no-color` for CLI output; interactive policy/CLI selectors now auto-fallback to non-interactive behavior in no-color mode, ANSI styling is suppressed for warnings/help usage, and cursor-hide control is centralized with exit-time cursor restore safety (#256) ### Fixes - `failproofai policies --uninstall` interactive CLI selector now says "Remove Hooks" / "Choose where to remove from:" instead of "Install Hooks" / "Choose where to install:" (#236) diff --git a/README.md b/README.md index 851c6186..4f9d35fd 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,8 @@ Policy configuration lives in `~/.failproofai/policies-config.json` (global) or **Three config scopes** are merged automatically (project → local → global). See [docs/configuration.mdx](docs/configuration.mdx) for full merge rules. +CLI output color can be disabled with `NO_COLOR=1` or `failproofai --no-color` (useful for CI logs and non-ANSI terminals). + --- ## Built-in policies diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index f4d97d42..55b9c707 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi, afterEach } from "vitest"; describe("hooks/install-prompt", () => { const originalIsTTY = process.stdin.isTTY; + const originalNoColor = process.env.NO_COLOR; + const originalArgv = [...process.argv]; afterEach(() => { Object.defineProperty(process.stdin, "isTTY", { @@ -10,6 +12,8 @@ describe("hooks/install-prompt", () => { writable: true, configurable: true, }); + process.env.NO_COLOR = originalNoColor; + process.argv = [...originalArgv]; vi.restoreAllMocks(); }); @@ -49,6 +53,29 @@ describe("hooks/install-prompt", () => { expect(selected).toEqual(["block-sudo", "block-rm-rf"]); }); + it("returns defaults in NO_COLOR mode even when stdin is a TTY", async () => { + Object.defineProperty(process.stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + process.env.NO_COLOR = "1"; + const setRawMode = vi.fn(); + const resume = vi.fn(); + const pause = vi.fn(); + Object.assign(process.stdin, { setRawMode, resume, pause }); + + const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); + const selected = await promptPolicySelection(); + + expect(selected).toContain("sanitize-jwt"); + expect(selected).toContain("protect-env-vars"); + expect(selected).toHaveLength(11); + expect(setRawMode).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(pause).not.toHaveBeenCalled(); + }); + describe("resolveTargetClis", () => { it("returns explicit cli list as-is regardless of action", async () => { const { resolveTargetClis } = await import("../../src/hooks/install-prompt"); @@ -131,5 +158,32 @@ describe("hooks/install-prompt", () => { expect(installResult).toEqual(["claude", "codex", "copilot", "cursor"]); expect(uninstallResult).toEqual(["claude", "codex", "copilot", "cursor"]); }); + + it("NO_COLOR warning output contains no ANSI sequences", async () => { + process.env.NO_COLOR = "1"; + vi.doMock("../../src/hooks/integrations", async () => { + const actual = await vi.importActual( + "../../src/hooks/integrations", + ); + return { + ...actual, + detectInstalledClis: () => [], + }; + }); + const logs: string[] = []; + const spy = vi.spyOn(console, "log").mockImplementation((m) => { + logs.push(String(m)); + }); + vi.resetModules(); + const { resolveTargetClis } = await import("../../src/hooks/install-prompt"); + await resolveTargetClis(undefined, "install"); + spy.mockRestore(); + vi.doUnmock("../../src/hooks/integrations"); + vi.resetModules(); + expect(logs[0]).toMatchInlineSnapshot( + "\"Warning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). Defaulting to Claude Code; hooks will activate when an agent is installed.\"", + ); + expect(logs[0]).not.toContain("\\u001b["); + }); }); }); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 913d016f..32a1f874 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -32,7 +32,11 @@ if (!process.env.FAILPROOFAI_DIST_PATH) { ); } -const args = process.argv.slice(2); +const rawArgs = process.argv.slice(2); +const args = rawArgs.filter((a) => a !== "--no-color"); +if (rawArgs.includes("--no-color")) { + process.env.NO_COLOR = "1"; +} // Normalize 'p' → 'policies' (shorthand alias) if (args[0] === "p") args[0] = "policies"; @@ -131,6 +135,7 @@ COMMANDS sync One-shot flush of pending events to the server --version, -v Print version and exit + --no-color Disable ANSI colors (same as NO_COLOR=1) --help, -h Show this help message CONVENTION POLICIES @@ -201,6 +206,7 @@ OPTIONS (install) --beta Include beta policies --custom, -c Path to a JS file of custom policies (skips interactive prompt; validates file first) + --no-color Disable ANSI colors / interactive selector OPTIONS (uninstall) [names...] Specific policy names to disable (omit to remove hooks) @@ -209,6 +215,7 @@ OPTIONS (uninstall) --scope user|project|local|all Config scope to remove from (default: user) --beta Remove only beta policies --custom, -c Clear the customPoliciesPath from config + --no-color Disable ANSI colors / interactive selector EXAMPLES failproofai policies diff --git a/docs/cli/environment-variables.mdx b/docs/cli/environment-variables.mdx index d440c3f3..8913c23b 100644 --- a/docs/cli/environment-variables.mdx +++ b/docs/cli/environment-variables.mdx @@ -18,6 +18,7 @@ description: "Configure failproofai behavior with environment variables" |----------|-------------| | `FAILPROOFAI_LOG_LEVEL=info\|warn\|error` | Server log level (default: `warn`) | | `FAILPROOFAI_HOOK_LOG_FILE` | Custom hook log file path, or `true` for default (`~/.failproofai/logs/hooks.log`) | +| `NO_COLOR=1` | Disable ANSI colors in CLI output (same as `--no-color`) | ## Telemetry diff --git a/src/cli-color.ts b/src/cli-color.ts new file mode 100644 index 00000000..35b92c91 --- /dev/null +++ b/src/cli-color.ts @@ -0,0 +1,87 @@ +/** + * Shared ANSI/color policy for CLI output. + * + * Priority: + * 1) `NO_COLOR` or `--no-color` disables color. + * 2) `FORCE_COLOR` enables color. + * 3) Otherwise require a TTY. + */ + +type StreamLike = { isTTY?: boolean }; + +function hasNoColorFlag(argv: readonly string[] = process.argv): boolean { + return argv.includes("--no-color"); +} + +function isTruthy(value: string | undefined): boolean { + if (!value) return false; + const v = value.trim().toLowerCase(); + return v !== "" && v !== "0" && v !== "false" && v !== "off"; +} + +export function isColorEnabled(stream: StreamLike = process.stdout): boolean { + if (process.env.NO_COLOR) return false; + if (hasNoColorFlag()) return false; + if (isTruthy(process.env.FORCE_COLOR)) return true; + return stream.isTTY === true; +} + +function wrap(code: string, text: string, enabled: boolean): string { + return enabled ? `\x1B[${code}m${text}\x1B[0m` : text; +} + +export interface CliStyler { + enabled: boolean; + dim(text: string): string; + bold(text: string): string; + yellow(text: string): string; + cyan(text: string): string; + green(text: string): string; + magenta(text: string): string; + reverse(text: string): string; +} + +export function createCliStyler(stream: StreamLike = process.stdout): CliStyler { + const enabled = isColorEnabled(stream); + return { + enabled, + dim: (text) => wrap("2", text, enabled), + bold: (text) => wrap("1", text, enabled), + yellow: (text) => wrap("33", text, enabled), + cyan: (text) => wrap("36", text, enabled), + green: (text) => wrap("32", text, enabled), + magenta: (text) => wrap("35", text, enabled), + reverse: (text) => wrap("7", text, enabled), + }; +} + +export function createCursorController(stream: NodeJS.WriteStream = process.stdout): { + hide: () => void; + show: () => void; + dispose: () => void; +} { + const enabled = isColorEnabled(stream) && stream.isTTY === true; + let hidden = false; + const show = (): void => { + if (!enabled || !hidden) return; + stream.write("\x1B[?25h"); + hidden = false; + }; + const onExit = (): void => { + show(); + }; + process.on("exit", onExit); + return { + hide: (): void => { + if (!enabled || hidden) return; + stream.write("\x1B[?25l"); + hidden = true; + }, + show, + dispose: (): void => { + show(); + process.removeListener("exit", onExit); + }, + }; +} + diff --git a/src/hooks/install-prompt.ts b/src/hooks/install-prompt.ts index a266785e..b923b9d1 100644 --- a/src/hooks/install-prompt.ts +++ b/src/hooks/install-prompt.ts @@ -13,6 +13,7 @@ import * as readline from "node:readline"; import { BUILTIN_POLICIES } from "./builtin-policies"; import { detectInstalledClis, getIntegration } from "./integrations"; import type { IntegrationType } from "./types"; +import { createCliStyler, createCursorController } from "../cli-color"; interface SelectItem { name: string; @@ -51,6 +52,7 @@ export async function resolveTargetClis( explicit?: IntegrationType[], action: CliPromptAction = "install", ): Promise { + const style = createCliStyler(process.stdout); if (explicit && explicit.length > 0) return [...new Set(explicit)]; const detected = detectInstalledClis(); @@ -60,14 +62,18 @@ export async function resolveTargetClis( // Uninstall flow: no agent CLIs detected — nothing to remove from. Default to // claude so removeHooks operates over Claude's scopes (no-op if no settings file). console.log( - "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). " + - "Defaulting to Claude Code; nothing will be removed if no settings file exists.\x1B[0m", + style.yellow( + "Warning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). " + + "Defaulting to Claude Code; nothing will be removed if no settings file exists.", + ), ); return ["claude"]; } console.log( - "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). " + - "Defaulting to Claude Code; hooks will activate when an agent is installed.\x1B[0m", + style.yellow( + "Warning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent). " + + "Defaulting to Claude Code; hooks will activate when an agent is installed.", + ), ); return ["claude"]; } @@ -80,7 +86,7 @@ export async function resolveTargetClis( } // Multiple detected. Prompt or default. - if (!process.stdin.isTTY) return detected; // non-interactive: install/remove for all detected + if (!process.stdin.isTTY || !style.enabled) return detected; // non-interactive/no-color: use all detected return promptCliTargetSelection(detected, action); } @@ -93,6 +99,7 @@ async function promptCliTargetSelection( detected: IntegrationType[], action: CliPromptAction = "install", ): Promise { + const style = createCliStyler(process.stdout); 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[] }> = [ @@ -106,20 +113,7 @@ async function promptCliTargetSelection( let cursor = 0; let lastLineCount = 0; - let cursorHidden = false; - - function hideCursor(): void { - if (!cursorHidden) { - process.stdout.write("\x1B[?25l"); - cursorHidden = true; - } - } - function showCursor(): void { - if (cursorHidden) { - process.stdout.write("\x1B[?25h"); - cursorHidden = false; - } - } + const cursorCtl = createCursorController(process.stdout); function truncateLine(line: string, width: number): string { let visual = 0; @@ -147,26 +141,26 @@ async function promptCliTargetSelection( function render(): void { const cols = process.stdout.columns || 120; - hideCursor(); + cursorCtl.hide(); const lines: string[] = []; lines.push(` Failproof AI — ${heading}`); lines.push(""); - lines.push(` \x1B[2mDetected ${labels}. Choose where to ${verb}:\x1B[0m`); + lines.push(` ${style.dim(`Detected ${labels}. Choose where to ${verb}:`)}`); lines.push(""); for (let i = 0; i < options.length; i++) { const opt = options[i]; const isActive = i === cursor; - const pointer = isActive ? "\x1B[36m❯\x1B[0m" : " "; - const labelPart = isActive ? `\x1B[1;36m${opt.label}\x1B[0m` : opt.label; + const pointer = isActive ? style.cyan("❯") : " "; + const labelPart = isActive ? style.cyan(style.bold(opt.label)) : opt.label; const pad = opt.description ? " ".repeat(Math.max(2, 22 - opt.label.length)) : ""; - const desc = opt.description ? `\x1B[2m${opt.description}\x1B[0m` : ""; + const desc = opt.description ? style.dim(opt.description) : ""; lines.push(` ${pointer} ${labelPart}${pad}${desc}`); } lines.push(""); - lines.push(" \x1B[2m" + "─".repeat(Math.max(2, cols - 2)) + "\x1B[0m"); + lines.push(" " + style.dim("─".repeat(Math.max(2, cols - 2)))); lines.push(" [↑↓] Move [Enter] Select [^C] Quit"); if (lastLineCount > 0) { @@ -186,7 +180,7 @@ async function promptCliTargetSelection( process.stdin.resume(); function cleanup(): void { - showCursor(); + cursorCtl.dispose(); process.stdin.removeListener("keypress", onKey); if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false); process.stdin.pause(); @@ -227,9 +221,11 @@ export async function promptPolicySelection( options: PromptOptions = {}, ): Promise { const { includeBeta = false } = options; + const style = createCliStyler(process.stdout); - // If stdin is not a TTY (piped/CI), return defaults - if (!process.stdin.isTTY) { + // If stdin is not a TTY (piped/CI) or colors are explicitly disabled, + // skip interactive rendering and return defaults. + if (!process.stdin.isTTY || !style.enabled) { const available = BUILTIN_POLICIES.filter((p) => includeBeta || !p.beta); if (preSelected) return preSelected.filter((name) => available.some((p) => p.name === name)); return available.filter((p) => p.defaultEnabled).map((p) => p.name); @@ -253,21 +249,7 @@ export async function promptPolicySelection( let cursor = 0; let search = ""; let lastLineCount = 0; - let cursorHidden = false; - - function hideCursor(): void { - if (!cursorHidden) { - process.stdout.write("\x1B[?25l"); - cursorHidden = true; - } - } - - function showCursor(): void { - if (cursorHidden) { - process.stdout.write("\x1B[?25h"); - cursorHidden = false; - } - } + const cursorCtl = createCursorController(process.stdout); // Truncate a line to `width` visual columns, skipping ANSI CSI sequences. function truncateLine(line: string, width: number): string { @@ -338,7 +320,7 @@ export async function promptPolicySelection( function render(): void { const cols = process.stdout.columns || 120; - hideCursor(); + cursorCtl.hide(); const filtered = getFiltered(); const shown = filtered.length; @@ -534,7 +516,7 @@ export async function promptPolicySelection( } function cleanup(): void { - showCursor(); + cursorCtl.dispose(); process.stdin.removeListener("keypress", keypressHandler); process.stdin.setRawMode(false); process.stdin.pause();