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..232c3d003 --- /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. Status messages now also honor `NO_COLOR`. 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/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 527af420b..959277b3a 100644 --- a/packages/cli-core/src/lib/log.test.ts +++ b/packages/cli-core/src/lib/log.test.ts @@ -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>; 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", () => { @@ -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); @@ -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); @@ -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"; + 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"]); + }); +}); diff --git a/packages/cli-core/src/lib/log.ts b/packages/cli-core/src/lib/log.ts index 18e6a5c25..8da938c48 100644 --- a/packages/cli-core/src/lib/log.ts +++ b/packages/cli-core/src/lib/log.ts @@ -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 ──────────────────────────────────────────────────────────── @@ -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 ────────────────────────────────────────────────── @@ -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 {