From a29de6b02b4f613bea69164bcdfd75ae6b593c97 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 21:24:49 -0400 Subject: [PATCH 1/4] fix(cli): strip escape codes from agent-mode output color.ts and log.ts style every line for a terminal, so agents running a command without --json got raw escape codes in stderr. writeln() and blank() now strip them outside human mode. Co-Authored-By: Claude Opus 5.5 --- ...-ansi-escape-codes-in-agent-mode-output.md | 5 +++ .../src/commands/doctor/index.test.ts | 8 +++++ packages/cli-core/src/lib/log.test.ts | 36 +++++++++++++++++++ packages/cli-core/src/lib/log.ts | 15 +++++--- 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 .changeset/no-ansi-escape-codes-in-agent-mode-output.md diff --git a/.changeset/no-ansi-escape-codes-in-agent-mode-output.md b/.changeset/no-ansi-escape-codes-in-agent-mode-output.md new file mode 100644 index 000000000..6eed62b50 --- /dev/null +++ b/.changeset/no-ansi-escape-codes-in-agent-mode-output.md @@ -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. diff --git a/packages/cli-core/src/commands/doctor/index.test.ts b/packages/cli-core/src/commands/doctor/index.test.ts index a2d6d7de8..997ebfb50 100644 --- a/packages/cli-core/src/commands/doctor/index.test.ts +++ b/packages/cli-core/src/commands/doctor/index.test.ts @@ -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(); }); diff --git a/packages/cli-core/src/lib/log.test.ts b/packages/cli-core/src/lib/log.test.ts index 527af420b..aa76ef58b 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -1,5 +1,7 @@ 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; @@ -10,6 +12,7 @@ beforeEach(() => { afterEach(() => { setLogLevel(savedLevel); + _resetMode(); }); describe("log levels", () => { @@ -88,6 +91,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); @@ -102,6 +106,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); @@ -146,3 +155,30 @@ 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); + }); +}); diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index 18e6a5c25..58270a1db 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -1,3 +1,4 @@ +import { isHuman } from "../mode.ts"; import { dim, green, red, yellow } from "./color.ts"; // ── Log level ──────────────────────────────────────────────────────────── @@ -138,13 +139,19 @@ export function setActiveCapture(captured: CapturedLogs | null): void { activeCapture = captured; } +/** Agents read output as text; color.ts, highlight() and the gutter style it for a terminal. */ +function plain(msg: string): string { + return isHuman() ? msg : Bun.stripANSI(msg); +} + function writeln(stream: NodeJS.WriteStream, channel: "stdout" | "stderr", msg: string) { + const text = plain(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 ────────────────────────────────────────────────── @@ -198,7 +205,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 { From 710faefd99912aa1f14b41efad14912c90b658fc Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 16:56:07 -0400 Subject: [PATCH 2/4] fix(cli): strip only our SGR styling, leave stdout untouched Bun.stripANSI read C1 bytes in user data (U+0090, U+009B, U+009D) as escape starts and dropped the rest of the output, breaking JSON on stdout and truncating stderr messages. Strip only SGR codes, which is all color.ts, highlight(), formatTag() and the gutter emit, and only on stderr. The few stdout callers that add color now pass through plain(). plain() also honors NO_COLOR, so humans who set it get uncolored status lines. colorDisabled() moves to color.ts so log.ts can use it without an import cycle through gradient.ts. Co-Authored-By: Claude Opus 5.5 --- ...-ansi-escape-codes-in-agent-mode-output.md | 2 +- packages/cli-core/src/commands/init/scan.ts | 12 +++---- .../cli-core/src/commands/unlink/index.ts | 4 +-- packages/cli-core/src/lib/banner.ts | 4 +-- packages/cli-core/src/lib/color.ts | 7 +++++ packages/cli-core/src/lib/gradient.ts | 8 +---- packages/cli-core/src/lib/log.test.ts | 31 +++++++++++++++++++ packages/cli-core/src/lib/log.ts | 18 ++++++++--- 8 files changed, 63 insertions(+), 23 deletions(-) diff --git a/.changeset/no-ansi-escape-codes-in-agent-mode-output.md b/.changeset/no-ansi-escape-codes-in-agent-mode-output.md index 6eed62b50..232c3d003 100644 --- a/.changeset/no-ansi-escape-codes-in-agent-mode-output.md +++ b/.changeset/no-ansi-escape-codes-in-agent-mode-output.md @@ -2,4 +2,4 @@ "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. +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`. diff --git a/packages/cli-core/src/commands/init/scan.ts b/packages/cli-core/src/commands/init/scan.ts index 7dbc599a3..d70796ed5 100644 --- a/packages/cli-core/src/commands/init/scan.ts +++ b/packages/cli-core/src/commands/init/scan.ts @@ -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[]; @@ -66,8 +66,8 @@ export function detectAuthLibraries(deps: Record): 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}`))); } } @@ -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)}`)); } } diff --git a/packages/cli-core/src/commands/unlink/index.ts b/packages/cli-core/src/commands/unlink/index.ts index 3bd0c4c97..4e572ec9f 100644 --- a/packages/cli-core/src/commands/unlink/index.ts +++ b/packages/cli-core/src/commands/unlink/index.ts @@ -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"; @@ -44,7 +44,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise { } 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); } diff --git a/packages/cli-core/src/lib/banner.ts b/packages/cli-core/src/lib/banner.ts index 4cd0003af..1cc0e01fe 100644 --- a/packages/cli-core/src/lib/banner.ts +++ b/packages/cli-core/src/lib/banner.ts @@ -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"; diff --git a/packages/cli-core/src/lib/color.ts b/packages/cli-core/src/lib/color.ts index 0c031ce37..6231cf672 100644 --- a/packages/cli-core/src/lib/color.ts +++ b/packages/cli-core/src/lib/color.ts @@ -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(); diff --git a/packages/cli-core/src/lib/gradient.ts b/packages/cli-core/src/lib/gradient.ts index 743a7dbcd..3d25cad82 100644 --- a/packages/cli-core/src/lib/gradient.ts +++ b/packages/cli-core/src/lib/gradient.ts @@ -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] { @@ -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((resolve) => setTimeout(resolve, ms)); diff --git a/packages/cli-core/src/lib/log.test.ts b/packages/cli-core/src/lib/log.test.ts index aa76ef58b..ea07f7969 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -181,4 +181,35 @@ describe("agent mode", () => { 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"; + 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("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"]); + }); }); diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index 58270a1db..ae622abe6 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -1,5 +1,5 @@ import { isHuman } from "../mode.ts"; -import { dim, green, red, yellow } from "./color.ts"; +import { colorDisabled, dim, green, red, yellow } from "./color.ts"; // ── Log level ──────────────────────────────────────────────────────────── @@ -139,13 +139,21 @@ export function setActiveCapture(captured: CapturedLogs | null): void { activeCapture = captured; } -/** Agents read output as text; color.ts, highlight() and the gutter style it for a terminal. */ -function plain(msg: string): string { - return isHuman() ? msg : Bun.stripANSI(msg); +/** Our styling (color.ts, highlight(), formatTag(), the gutter) is all SGR. */ +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) { - const text = plain(msg); + // 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(text); return; From f74ca738d78e26f40480a6445edb0da3ecea5b06 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 17:55:07 -0400 Subject: [PATCH 3/4] test(cli): clear NO_COLOR and FORCE_COLOR around the log tests The log tests inherited both, so a developer's own NO_COLOR failed three of them, and FORCE_COLOR overrides NO_COLOR in colorDisabled. The shared hooks now clear them and put them back. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/lib/log.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli-core/src/lib/log.test.ts b/packages/cli-core/src/lib/log.test.ts index ea07f7969..5e4043098 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -5,14 +5,27 @@ 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>; 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", () => { From b0d31ac8bf2e059180a4931400b2a9067155153c Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 19:01:15 -0400 Subject: [PATCH 4/4] fix(cli): strip SGR with colon-separated parameters in plain() A truecolor or underline-style SGR can separate its parameters with `:` (ESC[38:2::255:0:0m), which the pattern missed, so it reached agent and NO_COLOR output. The pattern still matches SGR only, so user text with C1 bytes or a lone ESC is kept. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/lib/log.test.ts | 6 ++++++ packages/cli-core/src/lib/log.ts | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/log.test.ts b/packages/cli-core/src/lib/log.test.ts index 5e4043098..959277b3a 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -219,6 +219,12 @@ describe("agent mode", () => { 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"); diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index ae622abe6..8da938c48 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -139,8 +139,11 @@ export function setActiveCapture(captured: CapturedLogs | null): void { activeCapture = captured; } -/** Our styling (color.ts, highlight(), formatTag(), the gutter) is all SGR. */ -const SGR = new RegExp(String.raw`\x1b\[[0-9;]*m`, "g"); +/** + * 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