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
5 changes: 5 additions & 0 deletions .changeset/no-ansi-escape-codes-in-agent-mode-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Print plain text without terminal color codes in agent mode, so AI agents no longer see escape sequences in command output when they run without `--json`. Pass `--mode human` or set `CLERK_MODE=human` to keep colors. Status messages now also honor `NO_COLOR`.
8 changes: 8 additions & 0 deletions packages/cli-core/src/commands/doctor/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ describe("doctor", () => {
expect(humanNames).toEqual(agentNames.slice(1));
});

test("an agent without --json gets the report with no escape codes", async () => {
setMode("agent");
outcomes.envVars = "fail";
await runDoctor();
expect(captured.err).toContain(`${CHECK_NAME.envVars}: fail`);
expect(captured.err).not.toContain("\x1b");
});

test("a run where every check answered and passed succeeds", async () => {
expect(await runDoctor()).toBeUndefined();
});
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/init/scan.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { join } from "node:path";
import { yellow, dim, cyan } from "../../lib/color.js";
import { log } from "../../lib/log.js";
import { log, plain } from "../../lib/log.js";

type AuthLibraryScan = {
packages: string[];
Expand Down Expand Up @@ -66,8 +66,8 @@ export function detectAuthLibraries(deps: Record<string, string>): void {
const found = scan.packages.some((pkg) => pkg in deps);
if (!found) continue;

log.data(yellow(`\n⚠ Detected ${scan.name} in your project.`));
log.data(dim(` Migration guide: ${scan.docsUrl}`));
log.data(plain(yellow(`\n⚠ Detected ${scan.name} in your project.`)));
log.data(plain(dim(` Migration guide: ${scan.docsUrl}`)));
}
}

Expand Down Expand Up @@ -171,10 +171,10 @@ export async function scanForIssues(cwd: string, frameworkDep: string): Promise<
export function printFindings(findings: ScanFinding[]): void {
if (findings.length === 0) return;

log.data(dim("\n Recommendations:"));
log.data(plain(dim("\n Recommendations:")));
for (const f of findings) {
const location = `${cyan(f.file)}:${f.line}`;
log.data(` ${yellow("⚠")} ${location} ${dim("—")} ${f.message}`);
if (f.docsUrl) log.data(` ${dim(f.docsUrl)}`);
log.data(plain(` ${yellow("⚠")} ${location} ${dim("—")} ${f.message}`));
if (f.docsUrl) log.data(plain(` ${dim(f.docsUrl)}`));
}
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/unlink/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { resolveProfile, removeProfile } from "../../lib/config.ts";
import { getGitRepoRoot } from "../../lib/git.ts";
import { dim, cyan } from "../../lib/color.ts";
import { CliError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { log, plain } from "../../lib/log.ts";
import { intro, outro } from "../../lib/spinner.ts";
import { NEXT_STEPS } from "../../lib/next-steps.ts";

Expand Down Expand Up @@ -44,7 +44,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {
}

await removeProfile(existing.path);
log.data(`\nUnlinked ${cyan(label)} from ${dim(displayPath)}`);
log.data(plain(`\nUnlinked ${cyan(label)} from ${dim(displayPath)}`));
await outro(NEXT_STEPS.UNLINK);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/lib/banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
*/

import { isHuman } from "../mode.ts";
import { bold, dim } from "./color.ts";
import { bold, colorDisabled, dim } from "./color.ts";
import { getCurrentEnvName } from "./environment.ts";
import { colorDisabled, isInteractive, rgbTo256, supportsTruecolor } from "./gradient.ts";
import { isInteractive, rgbTo256, supportsTruecolor } from "./gradient.ts";
import { log } from "./log.ts";
import { CURRENT_VERSION } from "./version.ts";

Expand Down
7 changes: 7 additions & 0 deletions packages/cli-core/src/lib/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ export const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
export const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
export const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
export const blue = (s: string) => `\x1b[34m${s}\x1b[0m`;

const forceColor = () => {
const v = process.env.FORCE_COLOR;
return v != null && v !== "0" && v !== "false" && v !== "";
};

export const colorDisabled = () => "NO_COLOR" in process.env && !forceColor();
8 changes: 1 addition & 7 deletions packages/cli-core/src/lib/gradient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { colorDisabled } from "./color.ts";
import { log } from "./log.ts";

export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
Expand Down Expand Up @@ -80,13 +81,6 @@ export function shineText(text: string, options: ShineOptions = {}): string {

export const isInteractive = () => !!process.stderr.isTTY && !process.env.CI;

const forceColor = () => {
const v = process.env.FORCE_COLOR;
return v != null && v !== "0" && v !== "false" && v !== "";
};

export const colorDisabled = () => "NO_COLOR" in process.env && !forceColor();

export const supportsTruecolor = () => /truecolor|24bit/i.test(process.env.COLORTERM ?? "");

const sleep = async (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
Expand Down
86 changes: 86 additions & 0 deletions packages/cli-core/src/lib/log.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
import { log, setLogLevel, getLogLevel, pushPrefix, popPrefix, type LogLevel } from "./log.ts";
import { dim } from "./color.ts";
import { _resetMode, setMode } from "../mode.ts";
import { useCaptureLog } from "../test/lib/stubs.ts";

let savedLevel: LogLevel;
// A developer's own NO_COLOR or FORCE_COLOR would decide what these tests see.
const COLOR_VARS = ["NO_COLOR", "FORCE_COLOR"] as const;
let savedColor: Partial<Record<(typeof COLOR_VARS)[number], string>>;

beforeEach(() => {
savedLevel = getLogLevel();
savedColor = {};
for (const key of COLOR_VARS) {
if (process.env[key] !== undefined) savedColor[key] = process.env[key];
delete process.env[key];
}
});

afterEach(() => {
setLogLevel(savedLevel);
_resetMode();
for (const key of COLOR_VARS) {
const saved = savedColor[key];
if (saved === undefined) delete process.env[key];
else process.env[key] = saved;
}
});

describe("log levels", () => {
Expand Down Expand Up @@ -88,6 +104,7 @@ describe("withTag", () => {
});

test("preserves outer color after dim tag", () => {
setMode("human");
const tagged = log.withTag("api");
tagged.error("broken");
expect(captured.stderr).toHaveLength(1);
Expand All @@ -102,6 +119,11 @@ describe("withTag", () => {
describe("inline highlighting", () => {
const captured = useCaptureLog();

// Styling only happens for humans; pinned so the runner's TTY doesn't decide.
beforeEach(() => {
setMode("human");
});

test("backtick spans are highlighted in cyan", () => {
log.info("Run `clerk link` to continue");
expect(captured.stderr.length).toBe(1);
Expand Down Expand Up @@ -146,3 +168,67 @@ describe("raw", () => {
expect(captured.stderr.every((line) => line === payload)).toBe(true);
});
});

describe("agent mode", () => {
const captured = useCaptureLog();

function styledCalls() {
log.info(dim("x"));
log.info("use `clerk`");
log.warn("w");
log.withTag("api").info("tagged");
pushPrefix();
log.info("in gutter");
log.blank();
popPrefix();
}

test("writes no escape codes", () => {
setMode("agent");
styledCalls();
expect(captured.stderr).toEqual(["x", "use `clerk`", "w", "[api] tagged", "│ in gutter", "│"]);
});

test("humans still get styled output", () => {
setMode("human");
styledCalls();
expect(captured.stderr.every((line) => line.includes("\x1b["))).toBe(true);
});

test("NO_COLOR gives humans plain output", () => {
setMode("human");
const saved = process.env.NO_COLOR;
process.env.NO_COLOR = "1";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
styledCalls();
} finally {
if (saved === undefined) delete process.env.NO_COLOR;
else process.env.NO_COLOR = saved;
}
expect(captured.stderr).toEqual(["x", "use `clerk`", "w", "[api] tagged", "│ in gutter", "│"]);
});

test("data passes through unaltered", () => {
setMode("agent");
const payload = JSON.stringify(
[{ first_name: "Eve\x10\x90\x9b\x9d" }, { first_name: "Bob" }],
null,
2,
);
log.data(payload);
expect(captured.stdout).toEqual([payload]);
});

test("strips SGR with colon-separated parameters too", () => {
setMode("agent");
log.info("\x1b[38:2::255:0:0mred\x1b[0m and \x1b[4:3mcurly\x1b[0m");
expect(captured.stderr).toEqual(["red and curly"]);
});

test("keeps user text that contains C1 bytes or a lone ESC", () => {
setMode("agent");
log.warn("Skipped (Eve\x90): taken\nSkipped (Bob): taken");
log.info("\x1b and");
expect(captured.stderr).toEqual(["Skipped (Eve\x90): taken\nSkipped (Bob): taken", "\x1b and"]);
});
});
28 changes: 23 additions & 5 deletions packages/cli-core/src/lib/log.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { dim, green, red, yellow } from "./color.ts";
import { isHuman } from "../mode.ts";
import { colorDisabled, dim, green, red, yellow } from "./color.ts";

// ── Log level ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -138,13 +139,30 @@ export function setActiveCapture(captured: CapturedLogs | null): void {
activeCapture = captured;
}

/**
* Our styling (color.ts, highlight(), formatTag(), the gutter) is all SGR.
* `:` too: a truecolor or underline-style SGR may separate its parameters so.
*/
const SGR = new RegExp(String.raw`\x1b\[[0-9:;]*m`, "g");

/**
* Drops our styling for agents and NO_COLOR. Only SGR is removed: a broader
* strip would treat C1 bytes in user data (e.g. a name) as escape starts and
* swallow the rest of the message.
*/
export function plain(msg: string): string {
return isHuman() && !colorDisabled() ? msg : msg.replace(SGR, "");
}

function writeln(stream: NodeJS.WriteStream, channel: "stdout" | "stderr", msg: string) {
// stdout carries data (JSON, raw API bodies) that must reach the reader unaltered.
const text = channel === "stderr" ? plain(msg) : msg;
if (activeCapture) {
activeCapture[channel].push(msg);
activeCapture[channel].push(text);
return;
}
if (!shouldWrite(channel, msg)) return;
stream.write(msg + "\n");
if (!shouldWrite(channel, text)) return;
stream.write(text + "\n");
}

// ── Tagged child logger ──────────────────────────────────────────────────
Expand Down Expand Up @@ -198,7 +216,7 @@ function createLogger(tag?: string): Logger {
},
/** Blank line to stderr. Preserves pipe prefix inside intro/outro flow. */
blank() {
const prefix = applyPrefix("");
const prefix = plain(applyPrefix(""));
if (activeCapture) {
activeCapture.stderr.push(prefix);
} else {
Expand Down
Loading