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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd>/.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)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions __tests__/hooks/install-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ 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", {
value: originalIsTTY,
writable: true,
configurable: true,
});
process.env.NO_COLOR = originalNoColor;
process.argv = [...originalArgv];
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<typeof import("../../src/hooks/integrations")>(
"../../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[");
});
});
});
9 changes: 8 additions & 1 deletion bin/failproofai.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -201,6 +206,7 @@ OPTIONS (install)
--beta Include beta policies
--custom, -c <path> 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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/cli/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 87 additions & 0 deletions src/cli-color.ts
Original file line number Diff line number Diff line change
@@ -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);
},
};
}

Loading
Loading