From 0eb45b4c0b00d89a9232e8c9e0dd936fd878b016 Mon Sep 17 00:00:00 2001 From: dharmateja03 <59060125+dharmateja03@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:09:36 -0400 Subject: [PATCH] no-color cli output --- CHANGELOG.md | 1 + README.md | 2 + __tests__/hooks/install-prompt.test.ts | 54 ++++++++++++++++ bin/failproofai.mjs | 9 ++- docs/cli/environment-variables.mdx | 1 + src/cli-color.ts | 87 ++++++++++++++++++++++++++ src/hooks/install-prompt.ts | 72 ++++++++------------- 7 files changed, 180 insertions(+), 46 deletions(-) create mode 100644 src/cli-color.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aec3c46..f92dae1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features - 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) +- 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 (#251) ### 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 1230c469..8c87df20 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,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 11b52de4..c1f59d8b 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"]); expect(uninstallResult).toEqual(["claude", "codex", "copilot"]); }); + + 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). 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 2ab7d8c7..f0f91c37 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"; @@ -129,6 +133,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 @@ -196,6 +201,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) @@ -203,6 +209,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 baf6e9bf..e3d733bc 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). " + - "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). " + + "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). " + - "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). " + + "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();