Skip to content

Commit bc8f2e0

Browse files
roderikclaude
andauthored
feat(terminal): add quiet mode support for Claude Code environments (#1409)
## What Adds quiet mode support to the CLI that automatically suppresses info/debug/status messages when running in Claude Code environments (detected via `CLAUDECODE`, `REPL_ID`, or `AGENT` environment variables). Warnings and errors are still displayed to ensure critical information remains visible. Also enhances the `note()` function to accept Error objects directly and automatically include stack traces for error-level messages, simplifying error handling throughout the codebase. ## Why When the CLI is used in automated environments like Claude Code, the verbose output (ASCII art, status messages, spinner progress) creates noise and makes it harder to identify actual issues. This change provides a cleaner output while preserving visibility of warnings and errors. The enhanced `note()` API also reduces boilerplate code by automatically handling error formatting (colors, stack traces) that was previously done manually. ## How - **Quiet mode detection**: Modified `shouldPrint()` to check for `CLAUDECODE`, `REPL_ID`, or `AGENT` environment variables - **Enhanced note() function**: - Now accepts `string | Error` as the first parameter - Automatically includes stack traces when Error objects are passed with level `"error"` - Automatically applies red/yellow colors for error/warning levels - Always displays warnings and errors, even in quiet mode - **Simplified error handling**: Updated `spinner.ts` and `index.ts` to use the enhanced `note()` API, removing manual color formatting and stack trace concatenation ## Files Changed - `sdk/utils/src/terminal/should-print.ts` - Added quiet mode detection - `sdk/utils/src/terminal/note.ts` - Enhanced to accept Error objects and auto-include stack traces - `sdk/utils/src/terminal/spinner.ts` - Simplified error handling - `sdk/cli/src/commands/index.ts` - Simplified error handling - `sdk/utils/src/environment/write-env.test.ts` - Formatting updates ## Breaking Changes None. The changes are backward compatible - existing code using `note()` with string messages continues to work, and the new Error object support is additive. ## Related Linear Issues None ## Summary by Sourcery Add quiet mode support for automated Claude Code environments and enhance error reporting by extending the note() API and simplifying error handling. New Features: - Suppress info/debug/status messages when CLAUDECODE, REPL_ID, or AGENT env vars are set, while still displaying warnings and errors - Allow note() to accept Error objects directly and automatically include colored stack traces for error-level messages Enhancements: - Unify error handling in spinner.ts and CLI entrypoint by delegating to the new note() API - Update shouldPrint() to detect quiet mode and respect SETTLEMINT_DISABLE_TERMINAL alongside Claude Code variables Tests: - Clean up array and destructuring formatting in write-env tests for consistency <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduce quiet mode across terminal utils (suppresses info noise, shows warnings/errors) and upgrade note() to handle Error objects with colored stacks; add tests and tune Turbo output logging. > > - **Terminal utils** > - **Quiet mode**: `should-print.ts` detects `CLAUDECODE`/`REPL_ID`/`AGENT` to suppress non-critical output. > - **Command execution**: `execute-command.ts` suppresses output in quiet mode, echoes buffered output on error, and honors `silent: false` override. > - **Notes API**: `note.ts` now accepts `string | Error`, auto-masks, auto-includes stack for errors, and applies red/yellow coloring; warnings/errors always print. > - **Spinner**: `spinner.ts` routes errors through `note(error, "error")` and simplifies handling. > - **CLI** > - `sdk/cli/src/commands/index.ts`: Centralize unexpected error reporting via `note(error, "error")`. > - **Tests** > - `execute-command.test.ts`: Add coverage for quiet mode behavior, error echoing, and `silent: false` override. > - `write-env.test.ts`: Minor formatting cleanups. > - **Build config** > - `turbo.json`: Set `outputLogs: "new-only"` on tasks to reduce noise. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 78f1222. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b49d2d0 commit bc8f2e0

File tree

8 files changed

+318
-62
lines changed

8 files changed

+318
-62
lines changed

sdk/cli/src/commands/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Command, CommanderError } from "@commander-js/extra-typings";
22
import { AbortPromptError, CancelPromptError, ExitPromptError, ValidationError } from "@inquirer/core";
33
import { ascii, CancelError, maskTokens, note, SpinnerError } from "@settlemint/sdk-utils/terminal";
4-
import { magentaBright, redBright } from "yoctocolors";
4+
import { magentaBright } from "yoctocolors";
55
import { telemetry } from "@/utils/telemetry";
66
import pkg from "../../package.json";
77
import { getInstalledSdkVersion, validateSdkVersionFromCommand } from "../utils/sdk-version";
@@ -113,8 +113,7 @@ async function onError(sdkcli: ExtendedCommand, argv: string[], error: Error) {
113113
}
114114

115115
if (!(error instanceof CancelError || error instanceof SpinnerError)) {
116-
const errorMessage = maskTokens(error.message);
117-
note(redBright(`Unknown error: ${errorMessage}\n\n${error.stack}`));
116+
note(error, "error");
118117
}
119118

120119
// Get the command path from the command that threw the error

sdk/utils/src/environment/write-env.test.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ describe("writeEnv", () => {
8080
});
8181

8282
it("should merge with existing environment variables", async () => {
83-
const existingEnv =
84-
"EXISTING_VAR=existing\nSETTLEMINT_INSTANCE=https://old.example.com";
83+
const existingEnv = "EXISTING_VAR=existing\nSETTLEMINT_INSTANCE=https://old.example.com";
8584
await writeFile(ENV_FILE, existingEnv);
8685

8786
const newEnv = {
@@ -104,10 +103,7 @@ describe("writeEnv", () => {
104103

105104
it("should handle arrays and objects", async () => {
106105
const env = {
107-
SETTLEMINT_THEGRAPH_SUBGRAPHS_ENDPOINTS: [
108-
"https://graph1.example.com",
109-
"https://graph2.example.com",
110-
],
106+
SETTLEMINT_THEGRAPH_SUBGRAPHS_ENDPOINTS: ["https://graph1.example.com", "https://graph2.example.com"],
111107
};
112108

113109
await writeEnv({
@@ -137,18 +133,11 @@ describe("writeEnv", () => {
137133
cwd: TEST_DIR,
138134
});
139135
const initialContent = await Bun.file(ENV_FILE).text();
140-
expect(initialContent).toContain(
141-
"SETTLEMINT_INSTANCE=https://dev.example.com",
142-
);
143-
expect(initialContent).toContain(
144-
"SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment",
145-
);
136+
expect(initialContent).toContain("SETTLEMINT_INSTANCE=https://dev.example.com");
137+
expect(initialContent).toContain("SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment");
146138
expect(initialContent).toContain("SETTLEMINT_WORKSPACE=test-workspace");
147139
expect(initialContent).toContain("MY_VAR=my-value");
148-
const {
149-
SETTLEMINT_CUSTOM_DEPLOYMENT: _SETTLEMINT_CUSTOM_DEPLOYMENT,
150-
...existingEnv
151-
} = initialEnv;
140+
const { SETTLEMINT_CUSTOM_DEPLOYMENT: _SETTLEMINT_CUSTOM_DEPLOYMENT, ...existingEnv } = initialEnv;
152141

153142
await writeEnv({
154143
prod: false,
@@ -159,12 +148,8 @@ describe("writeEnv", () => {
159148

160149
const updatedContent = await Bun.file(ENV_FILE).text();
161150
expect(updatedContent).toContain("SETTLEMINT_WORKSPACE=test-workspace");
162-
expect(updatedContent).toContain(
163-
"SETTLEMINT_INSTANCE=https://dev.example.com",
164-
);
165-
expect(updatedContent).not.toContain(
166-
"SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment",
167-
);
151+
expect(updatedContent).toContain("SETTLEMINT_INSTANCE=https://dev.example.com");
152+
expect(updatedContent).not.toContain("SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment");
168153
expect(updatedContent).toContain("MY_VAR=my-value");
169154
});
170155
});

sdk/utils/src/terminal/execute-command.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,140 @@ describe("executeCommand", () => {
109109

110110
expect(output.some((line) => line.includes("test"))).toBe(true);
111111
});
112+
113+
test("quiet mode suppresses output on success", async () => {
114+
const originalCLAUDECODE = process.env.CLAUDECODE;
115+
const originalWrite = process.stdout.write;
116+
let stdoutWritten = false;
117+
118+
// biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any
119+
process.stdout.write = mock((_chunk: any) => {
120+
stdoutWritten = true;
121+
return true;
122+
});
123+
124+
try {
125+
process.env.CLAUDECODE = "true";
126+
await executeCommand("echo", ["quiet mode test"]);
127+
expect(stdoutWritten).toBe(false);
128+
} finally {
129+
process.stdout.write = originalWrite;
130+
if (originalCLAUDECODE === undefined) {
131+
delete process.env.CLAUDECODE;
132+
} else {
133+
process.env.CLAUDECODE = originalCLAUDECODE;
134+
}
135+
}
136+
});
137+
138+
test("quiet mode shows output on error", async () => {
139+
const originalCLAUDECODE = process.env.CLAUDECODE;
140+
const originalStdoutWrite = process.stdout.write;
141+
const originalStderrWrite = process.stderr.write;
142+
let outputShown = false;
143+
144+
// biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any
145+
process.stdout.write = mock((_chunk: any) => {
146+
outputShown = true;
147+
return true;
148+
});
149+
150+
// biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any
151+
process.stderr.write = mock((_chunk: any) => {
152+
outputShown = true;
153+
return true;
154+
});
155+
156+
try {
157+
process.env.CLAUDECODE = "true";
158+
await expect(() =>
159+
executeCommand("node", ["-e", "console.log('output'); console.error('error'); process.exit(1);"]),
160+
).toThrow();
161+
// Output should be shown on error even in quiet mode
162+
expect(outputShown).toBe(true);
163+
} finally {
164+
process.stdout.write = originalStdoutWrite;
165+
process.stderr.write = originalStderrWrite;
166+
if (originalCLAUDECODE === undefined) {
167+
delete process.env.CLAUDECODE;
168+
} else {
169+
process.env.CLAUDECODE = originalCLAUDECODE;
170+
}
171+
}
172+
});
173+
174+
test("quiet mode respects silent: false to force output", async () => {
175+
const originalCLAUDECODE = process.env.CLAUDECODE;
176+
const originalWrite = process.stdout.write;
177+
let stdoutWritten = false;
178+
179+
// biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any
180+
process.stdout.write = mock((_chunk: any) => {
181+
stdoutWritten = true;
182+
return true;
183+
});
184+
185+
try {
186+
process.env.CLAUDECODE = "true";
187+
await executeCommand("echo", ["force output in quiet mode"], { silent: false });
188+
expect(stdoutWritten).toBe(true);
189+
} finally {
190+
process.stdout.write = originalWrite;
191+
if (originalCLAUDECODE === undefined) {
192+
delete process.env.CLAUDECODE;
193+
} else {
194+
process.env.CLAUDECODE = originalCLAUDECODE;
195+
}
196+
}
197+
});
198+
199+
test("quiet mode works with REPL_ID environment variable", async () => {
200+
const originalREPL_ID = process.env.REPL_ID;
201+
const originalWrite = process.stdout.write;
202+
let stdoutWritten = false;
203+
204+
// biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any
205+
process.stdout.write = mock((_chunk: any) => {
206+
stdoutWritten = true;
207+
return true;
208+
});
209+
210+
try {
211+
process.env.REPL_ID = "test-repl";
212+
await executeCommand("echo", ["repl quiet test"]);
213+
expect(stdoutWritten).toBe(false);
214+
} finally {
215+
process.stdout.write = originalWrite;
216+
if (originalREPL_ID === undefined) {
217+
delete process.env.REPL_ID;
218+
} else {
219+
process.env.REPL_ID = originalREPL_ID;
220+
}
221+
}
222+
});
223+
224+
test("quiet mode works with AGENT environment variable", async () => {
225+
const originalAGENT = process.env.AGENT;
226+
const originalWrite = process.stdout.write;
227+
let stdoutWritten = false;
228+
229+
// biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any
230+
process.stdout.write = mock((_chunk: any) => {
231+
stdoutWritten = true;
232+
return true;
233+
});
234+
235+
try {
236+
process.env.AGENT = "true";
237+
await executeCommand("echo", ["agent quiet test"]);
238+
expect(stdoutWritten).toBe(false);
239+
} finally {
240+
process.stdout.write = originalWrite;
241+
if (originalAGENT === undefined) {
242+
delete process.env.AGENT;
243+
} else {
244+
process.env.AGENT = originalAGENT;
245+
}
246+
}
247+
});
112248
});

sdk/utils/src/terminal/execute-command.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,19 @@ export class CommandError extends Error {
2929
}
3030
}
3131

32+
/**
33+
* Checks if we're in quiet mode (Claude Code environment)
34+
*/
35+
function isQuietMode(): boolean {
36+
return !!(process.env.CLAUDECODE || process.env.REPL_ID || process.env.AGENT);
37+
}
38+
3239
/**
3340
* Executes a command with the given arguments in a child process.
3441
* Pipes stdin to the child process and captures stdout/stderr output.
3542
* Masks any sensitive tokens in the output before displaying or returning.
43+
* In quiet mode (when CLAUDECODE, REPL_ID, or AGENT env vars are set),
44+
* output is suppressed unless the command errors out.
3645
*
3746
* @param command - The command to execute
3847
* @param args - Array of arguments to pass to the command
@@ -54,26 +63,50 @@ export async function executeCommand(
5463
options?: ExecuteCommandOptions,
5564
): Promise<string[]> {
5665
const { silent, ...spawnOptions } = options ?? {};
66+
const quietMode = isQuietMode();
67+
// In quiet mode, suppress output unless explicitly overridden with silent: false
68+
const shouldSuppressOutput = quietMode ? silent !== false : !!silent;
69+
5770
const child = spawn(command, args, { ...spawnOptions, env: { ...process.env, ...options?.env } });
5871
process.stdin.pipe(child.stdin);
5972
const output: string[] = [];
73+
const stdoutOutput: string[] = [];
74+
const stderrOutput: string[] = [];
75+
6076
return new Promise((resolve, reject) => {
6177
child.stdout.on("data", (data: Buffer | string) => {
6278
const maskedData = maskTokens(data.toString());
63-
if (!silent) {
79+
if (!shouldSuppressOutput) {
6480
process.stdout.write(maskedData);
6581
}
6682
output.push(maskedData);
83+
stdoutOutput.push(maskedData);
6784
});
6885
child.stderr.on("data", (data: Buffer | string) => {
6986
const maskedData = maskTokens(data.toString());
70-
if (!silent) {
87+
if (!shouldSuppressOutput) {
7188
process.stderr.write(maskedData);
7289
}
7390
output.push(maskedData);
91+
stderrOutput.push(maskedData);
7492
});
93+
94+
const showErrorOutput = () => {
95+
// In quiet mode, show output on error
96+
if (quietMode && shouldSuppressOutput && output.length > 0) {
97+
// Write stdout to stdout and stderr to stderr
98+
if (stdoutOutput.length > 0) {
99+
process.stdout.write(stdoutOutput.join(""));
100+
}
101+
if (stderrOutput.length > 0) {
102+
process.stderr.write(stderrOutput.join(""));
103+
}
104+
}
105+
};
106+
75107
child.on("error", (err) => {
76108
process.stdin.unpipe(child.stdin);
109+
showErrorOutput();
77110
reject(new CommandError(err.message, "code" in err && typeof err.code === "number" ? err.code : 1, output));
78111
});
79112
child.on("close", (code) => {
@@ -82,6 +115,8 @@ export async function executeCommand(
82115
resolve(output);
83116
return;
84117
}
118+
// In quiet mode, show output on error
119+
showErrorOutput();
85120
reject(new CommandError(`Command "${command}" exited with code ${code}`, code, output));
86121
});
87122
});

0 commit comments

Comments
 (0)