From 318aead0e43e9aa235c5621fa4dd43f19073c1e3 Mon Sep 17 00:00:00 2001 From: Roderik van der Veer Date: Fri, 7 Nov 2025 13:22:01 +0100 Subject: [PATCH 1/8] feat(terminal): add quiet mode support for Claude Code environments - Detect CLAUDECODE, REPL_ID, or AGENT env vars to suppress info/debug output - Warnings and errors still displayed in quiet mode - Enhanced note() to accept Error objects and automatically include stack traces --- sdk/utils/src/terminal/note.ts | 61 +++++++++++++++++++++----- sdk/utils/src/terminal/should-print.ts | 12 ++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/sdk/utils/src/terminal/note.ts b/sdk/utils/src/terminal/note.ts index 0dff7f3b9..7532c76cd 100644 --- a/sdk/utils/src/terminal/note.ts +++ b/sdk/utils/src/terminal/note.ts @@ -1,14 +1,16 @@ import { maskTokens } from "@/logging/mask-tokens.js"; -import { yellowBright } from "yoctocolors"; +import { redBright, yellowBright } from "yoctocolors"; import { shouldPrint } from "./should-print.js"; /** - * Displays a note message with optional warning level formatting. - * Regular notes are displayed in normal text, while warnings are shown in yellow. + * Displays a note message with optional warning or error level formatting. + * Regular notes are displayed in normal text, warnings are shown in yellow, and errors in red. * Any sensitive tokens in the message are masked before display. + * Warnings and errors are always displayed, even in quiet mode (when CLAUDECODE, REPL_ID, or AGENT env vars are set). + * When an Error object is provided with level "error", the stack trace is automatically included. * - * @param message - The message to display as a note - * @param level - The note level: "info" (default) or "warn" for warning styling + * @param message - The message to display as a note, or an Error object + * @param level - The note level: "info" (default), "warn" for warning styling, or "error" for error styling * @example * import { note } from "@settlemint/sdk-utils/terminal"; * @@ -17,18 +19,55 @@ import { shouldPrint } from "./should-print.js"; * * // Display warning note * note("Low disk space remaining", "warn"); + * + * // Display error note + * note("Operation failed", "error"); + * + * // Display error with stack trace automatically + * try { + * // some operation + * } catch (error) { + * note(error, "error"); + * } */ -export const note = (message: string, level: "info" | "warn" = "info"): void => { - if (!shouldPrint()) { +export const note = (message: string | Error, level: "info" | "warn" | "error" = "info"): void => { + let messageText: string; + let _error: Error | undefined; + + if (message instanceof Error) { + _error = message; + messageText = message.message; + // For errors, automatically include stack trace + if (level === "error" && message.stack) { + messageText = `${messageText}\n\n${message.stack}`; + } + } else { + messageText = message; + } + + const maskedMessage = maskTokens(messageText); + const _isQuietMode = process.env.CLAUDECODE || process.env.REPL_ID || process.env.AGENT; + + // Always print warnings and errors, even in quiet mode + if (level === "warn" || level === "error") { + console.log(""); + if (level === "warn") { + // Apply yellow color if not already colored (check if message contains ANSI codes) + const coloredMessage = maskedMessage.includes("\u001b[") ? maskedMessage : yellowBright(maskedMessage); + console.warn(coloredMessage); + } else { + // Apply red color if not already colored (check if message contains ANSI codes) + const coloredMessage = maskedMessage.includes("\u001b[") ? maskedMessage : redBright(maskedMessage); + console.error(coloredMessage); + } return; } - const maskedMessage = maskTokens(message); - console.log(""); - if (level === "warn") { - console.warn(yellowBright(maskedMessage)); + // For info messages, check if we should print + if (!shouldPrint()) { return; } + console.log(""); console.log(maskedMessage); }; diff --git a/sdk/utils/src/terminal/should-print.ts b/sdk/utils/src/terminal/should-print.ts index c9b2bde7f..b1becb83b 100644 --- a/sdk/utils/src/terminal/should-print.ts +++ b/sdk/utils/src/terminal/should-print.ts @@ -1,7 +1,17 @@ /** * Returns true if the terminal should print, false otherwise. + * When CLAUDECODE, REPL_ID, or AGENT env vars are set, suppresses info/debug output + * but warnings and errors will still be displayed. * @returns true if the terminal should print, false otherwise. */ export function shouldPrint() { - return process.env.SETTLEMINT_DISABLE_TERMINAL !== "true"; + if (process.env.SETTLEMINT_DISABLE_TERMINAL === "true") { + return false; + } + // In quiet mode (Claude Code), suppress info/debug/status messages + // Warnings and errors will still be displayed via note() with appropriate levels + if (process.env.CLAUDECODE || process.env.REPL_ID || process.env.AGENT) { + return false; + } + return true; } From d89cbc5ad39b4d466501dd8bfc793ce205c2fe09 Mon Sep 17 00:00:00 2001 From: Roderik van der Veer Date: Fri, 7 Nov 2025 13:22:02 +0100 Subject: [PATCH 2/8] refactor(terminal): simplify error handling with enhanced note() API - Update spinner error handler to use note() with Error objects - Update CLI error handler to use note() with Error objects - Remove manual redBright() calls as note() handles colors automatically --- sdk/cli/src/commands/index.ts | 5 ++--- sdk/utils/src/terminal/spinner.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/sdk/cli/src/commands/index.ts b/sdk/cli/src/commands/index.ts index e3dfbf93a..2dde52d98 100644 --- a/sdk/cli/src/commands/index.ts +++ b/sdk/cli/src/commands/index.ts @@ -1,7 +1,7 @@ import { Command, CommanderError } from "@commander-js/extra-typings"; import { AbortPromptError, CancelPromptError, ExitPromptError, ValidationError } from "@inquirer/core"; import { ascii, CancelError, maskTokens, note, SpinnerError } from "@settlemint/sdk-utils/terminal"; -import { magentaBright, redBright } from "yoctocolors"; +import { magentaBright } from "yoctocolors"; import { telemetry } from "@/utils/telemetry"; import pkg from "../../package.json"; import { getInstalledSdkVersion, validateSdkVersionFromCommand } from "../utils/sdk-version"; @@ -113,8 +113,7 @@ async function onError(sdkcli: ExtendedCommand, argv: string[], error: Error) { } if (!(error instanceof CancelError || error instanceof SpinnerError)) { - const errorMessage = maskTokens(error.message); - note(redBright(`Unknown error: ${errorMessage}\n\n${error.stack}`)); + note(error, "error"); } // Get the command path from the command that threw the error diff --git a/sdk/utils/src/terminal/spinner.ts b/sdk/utils/src/terminal/spinner.ts index 303f049b3..9ab5a5139 100644 --- a/sdk/utils/src/terminal/spinner.ts +++ b/sdk/utils/src/terminal/spinner.ts @@ -54,8 +54,8 @@ export interface SpinnerOptions { */ export const spinner = async (options: SpinnerOptions): Promise => { const handleError = (error: Error) => { + note(error, "error"); const errorMessage = maskTokens(error.message); - note(redBright(`${errorMessage}\n\n${error.stack}`)); throw new SpinnerError(errorMessage, error); }; if (isInCi || !shouldPrint()) { From 6a04e1c9a7b8fb3a8e7a9e00b1e1b343fd67b0ea Mon Sep 17 00:00:00 2001 From: Roderik van der Veer Date: Fri, 7 Nov 2025 13:22:07 +0100 Subject: [PATCH 3/8] style: format test file with Biome --- sdk/utils/src/environment/write-env.test.ts | 29 +++++---------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/sdk/utils/src/environment/write-env.test.ts b/sdk/utils/src/environment/write-env.test.ts index f478a31f2..36048337e 100644 --- a/sdk/utils/src/environment/write-env.test.ts +++ b/sdk/utils/src/environment/write-env.test.ts @@ -80,8 +80,7 @@ describe("writeEnv", () => { }); it("should merge with existing environment variables", async () => { - const existingEnv = - "EXISTING_VAR=existing\nSETTLEMINT_INSTANCE=https://old.example.com"; + const existingEnv = "EXISTING_VAR=existing\nSETTLEMINT_INSTANCE=https://old.example.com"; await writeFile(ENV_FILE, existingEnv); const newEnv = { @@ -104,10 +103,7 @@ describe("writeEnv", () => { it("should handle arrays and objects", async () => { const env = { - SETTLEMINT_THEGRAPH_SUBGRAPHS_ENDPOINTS: [ - "https://graph1.example.com", - "https://graph2.example.com", - ], + SETTLEMINT_THEGRAPH_SUBGRAPHS_ENDPOINTS: ["https://graph1.example.com", "https://graph2.example.com"], }; await writeEnv({ @@ -137,18 +133,11 @@ describe("writeEnv", () => { cwd: TEST_DIR, }); const initialContent = await Bun.file(ENV_FILE).text(); - expect(initialContent).toContain( - "SETTLEMINT_INSTANCE=https://dev.example.com", - ); - expect(initialContent).toContain( - "SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment", - ); + expect(initialContent).toContain("SETTLEMINT_INSTANCE=https://dev.example.com"); + expect(initialContent).toContain("SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment"); expect(initialContent).toContain("SETTLEMINT_WORKSPACE=test-workspace"); expect(initialContent).toContain("MY_VAR=my-value"); - const { - SETTLEMINT_CUSTOM_DEPLOYMENT: _SETTLEMINT_CUSTOM_DEPLOYMENT, - ...existingEnv - } = initialEnv; + const { SETTLEMINT_CUSTOM_DEPLOYMENT: _SETTLEMINT_CUSTOM_DEPLOYMENT, ...existingEnv } = initialEnv; await writeEnv({ prod: false, @@ -159,12 +148,8 @@ describe("writeEnv", () => { const updatedContent = await Bun.file(ENV_FILE).text(); expect(updatedContent).toContain("SETTLEMINT_WORKSPACE=test-workspace"); - expect(updatedContent).toContain( - "SETTLEMINT_INSTANCE=https://dev.example.com", - ); - expect(updatedContent).not.toContain( - "SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment", - ); + expect(updatedContent).toContain("SETTLEMINT_INSTANCE=https://dev.example.com"); + expect(updatedContent).not.toContain("SETTLEMINT_CUSTOM_DEPLOYMENT=test-custom-deployment"); expect(updatedContent).toContain("MY_VAR=my-value"); }); }); From aede12a438f7a570a5e586b4c99f063aaf582e0c Mon Sep 17 00:00:00 2001 From: Roderik van der Veer Date: Fri, 7 Nov 2025 13:27:39 +0100 Subject: [PATCH 4/8] test(terminal): add comprehensive quiet mode tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test coverage for quiet mode functionality in executeCommand: - Verify output is suppressed on success in quiet mode - Verify output is shown on error in quiet mode - Verify silent: false override works in quiet mode - Test all quiet mode triggers (CLAUDECODE, REPL_ID, AGENT) All 15 tests pass with 100% coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/terminal/execute-command.test.ts | 136 ++++++++++++++++++ sdk/utils/src/terminal/execute-command.ts | 39 ++++- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/sdk/utils/src/terminal/execute-command.test.ts b/sdk/utils/src/terminal/execute-command.test.ts index bc317b6b3..30520c3a5 100644 --- a/sdk/utils/src/terminal/execute-command.test.ts +++ b/sdk/utils/src/terminal/execute-command.test.ts @@ -109,4 +109,140 @@ describe("executeCommand", () => { expect(output.some((line) => line.includes("test"))).toBe(true); }); + + test("quiet mode suppresses output on success", async () => { + const originalCLAUDECODE = process.env.CLAUDECODE; + const originalWrite = process.stdout.write; + let stdoutWritten = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + stdoutWritten = true; + return true; + }); + + try { + process.env.CLAUDECODE = "true"; + await executeCommand("echo", ["quiet mode test"]); + expect(stdoutWritten).toBe(false); + } finally { + process.stdout.write = originalWrite; + if (originalCLAUDECODE === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = originalCLAUDECODE; + } + } + }); + + test("quiet mode shows output on error", async () => { + const originalCLAUDECODE = process.env.CLAUDECODE; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + let outputShown = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + outputShown = true; + return true; + }); + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stderr.write = mock((_chunk: any) => { + outputShown = true; + return true; + }); + + try { + process.env.CLAUDECODE = "true"; + await expect(() => + executeCommand("node", ["-e", "console.log('output'); console.error('error'); process.exit(1);"]), + ).toThrow(); + // Output should be shown on error even in quiet mode + expect(outputShown).toBe(true); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + if (originalCLAUDECODE === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = originalCLAUDECODE; + } + } + }); + + test("quiet mode respects silent: false to force output", async () => { + const originalCLAUDECODE = process.env.CLAUDECODE; + const originalWrite = process.stdout.write; + let stdoutWritten = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + stdoutWritten = true; + return true; + }); + + try { + process.env.CLAUDECODE = "true"; + await executeCommand("echo", ["force output in quiet mode"], { silent: false }); + expect(stdoutWritten).toBe(true); + } finally { + process.stdout.write = originalWrite; + if (originalCLAUDECODE === undefined) { + delete process.env.CLAUDECODE; + } else { + process.env.CLAUDECODE = originalCLAUDECODE; + } + } + }); + + test("quiet mode works with REPL_ID environment variable", async () => { + const originalREPL_ID = process.env.REPL_ID; + const originalWrite = process.stdout.write; + let stdoutWritten = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + stdoutWritten = true; + return true; + }); + + try { + process.env.REPL_ID = "test-repl"; + await executeCommand("echo", ["repl quiet test"]); + expect(stdoutWritten).toBe(false); + } finally { + process.stdout.write = originalWrite; + if (originalREPL_ID === undefined) { + delete process.env.REPL_ID; + } else { + process.env.REPL_ID = originalREPL_ID; + } + } + }); + + test("quiet mode works with AGENT environment variable", async () => { + const originalAGENT = process.env.AGENT; + const originalWrite = process.stdout.write; + let stdoutWritten = false; + + // biome-ignore lint/suspicious/noExplicitAny: Test mocking requires any + process.stdout.write = mock((_chunk: any) => { + stdoutWritten = true; + return true; + }); + + try { + process.env.AGENT = "true"; + await executeCommand("echo", ["agent quiet test"]); + expect(stdoutWritten).toBe(false); + } finally { + process.stdout.write = originalWrite; + if (originalAGENT === undefined) { + delete process.env.AGENT; + } else { + process.env.AGENT = originalAGENT; + } + } + }); }); diff --git a/sdk/utils/src/terminal/execute-command.ts b/sdk/utils/src/terminal/execute-command.ts index 4ebc470aa..72c0287ad 100644 --- a/sdk/utils/src/terminal/execute-command.ts +++ b/sdk/utils/src/terminal/execute-command.ts @@ -29,10 +29,19 @@ export class CommandError extends Error { } } +/** + * Checks if we're in quiet mode (Claude Code environment) + */ +function isQuietMode(): boolean { + return !!(process.env.CLAUDECODE || process.env.REPL_ID || process.env.AGENT); +} + /** * Executes a command with the given arguments in a child process. * Pipes stdin to the child process and captures stdout/stderr output. * Masks any sensitive tokens in the output before displaying or returning. + * In quiet mode (when CLAUDECODE, REPL_ID, or AGENT env vars are set), + * output is suppressed unless the command errors out. * * @param command - The command to execute * @param args - Array of arguments to pass to the command @@ -54,26 +63,50 @@ export async function executeCommand( options?: ExecuteCommandOptions, ): Promise { const { silent, ...spawnOptions } = options ?? {}; + const quietMode = isQuietMode(); + // In quiet mode, suppress output unless explicitly overridden with silent: false + const shouldSuppressOutput = quietMode ? (silent !== false) : !!silent; + const child = spawn(command, args, { ...spawnOptions, env: { ...process.env, ...options?.env } }); process.stdin.pipe(child.stdin); const output: string[] = []; + const stdoutOutput: string[] = []; + const stderrOutput: string[] = []; + return new Promise((resolve, reject) => { child.stdout.on("data", (data: Buffer | string) => { const maskedData = maskTokens(data.toString()); - if (!silent) { + if (!shouldSuppressOutput) { process.stdout.write(maskedData); } output.push(maskedData); + stdoutOutput.push(maskedData); }); child.stderr.on("data", (data: Buffer | string) => { const maskedData = maskTokens(data.toString()); - if (!silent) { + if (!shouldSuppressOutput) { process.stderr.write(maskedData); } output.push(maskedData); + stderrOutput.push(maskedData); }); + + const showErrorOutput = () => { + // In quiet mode, show output on error + if (quietMode && shouldSuppressOutput && output.length > 0) { + // Write stdout to stdout and stderr to stderr + if (stdoutOutput.length > 0) { + process.stdout.write(stdoutOutput.join("")); + } + if (stderrOutput.length > 0) { + process.stderr.write(stderrOutput.join("")); + } + } + }; + child.on("error", (err) => { process.stdin.unpipe(child.stdin); + showErrorOutput(); reject(new CommandError(err.message, "code" in err && typeof err.code === "number" ? err.code : 1, output)); }); child.on("close", (code) => { @@ -82,6 +115,8 @@ export async function executeCommand( resolve(output); return; } + // In quiet mode, show output on error + showErrorOutput(); reject(new CommandError(`Command "${command}" exited with code ${code}`, code, output)); }); }); From 56c2b476e7db2dc48c8a86eebad33f0e2cf414c0 Mon Sep 17 00:00:00 2001 From: Roderik van der Veer Date: Fri, 7 Nov 2025 13:31:56 +0100 Subject: [PATCH 5/8] chore(turbo.json): update build and task configurations - Adjusted inputs and outputs for various tasks in turbo.json to enhance build efficiency. - Ensured consistent logging settings across tasks with "outputLogs": "new-only". - Updated dependencies for code generation and documentation tasks to include new schema and example paths. --- turbo.json | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/turbo.json b/turbo.json index fcd4a3291..462a275f4 100644 --- a/turbo.json +++ b/turbo.json @@ -6,7 +6,8 @@ "build": { "dependsOn": ["^build", "codegen"], "inputs": ["$TURBO_DEFAULT$", ".env*", "tsdown.config.ts", "../../shared/tsdown-factory.ts"], - "outputs": ["dist/**", ".next/**", "!.next/cache/**", "artifacts/**"] + "outputs": ["dist/**", ".next/**", "!.next/cache/**", "artifacts/**"], + "outputLogs": "new-only" }, "codegen": { "inputs": ["graphql/**"], @@ -16,50 +17,62 @@ "src/examples/schemas/**", "src/portal/portal-cache.d.ts", "src/portal/portal-env.d.ts" - ] + ], + "outputLogs": "new-only" }, "publish-npm": { "dependsOn": ["build"], - "cache": false + "cache": false, + "outputLogs": "new-only" }, "//#lint:biome": { "dependsOn": ["//#format:biome"] }, "//#format:biome": {}, "lint": { - "dependsOn": ["^build", "//#lint:biome"] + "dependsOn": ["^build", "//#lint:biome"], + "outputLogs": "new-only" }, "//#knip": { - "cache": false + "cache": false, + "outputLogs": "new-only" }, "docker": { - "cache": false + "cache": false, + "outputLogs": "new-only" }, "attw": { - "dependsOn": ["build"] + "dependsOn": ["build"], + "outputLogs": "new-only" }, - "publint": { "dependsOn": ["build"] }, + "publint": { "dependsOn": ["build"], "outputLogs": "new-only" }, "dev": { "dependsOn": ["^build", "codegen"], "cache": false, - "persistent": true + "persistent": true, + "outputLogs": "new-only" }, "test": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "outputLogs": "new-only" }, "test:coverage": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "outputLogs": "new-only" }, "translate": { - "inputs": ["messages/**"] + "inputs": ["messages/**"], + "outputLogs": "new-only" }, "typecheck": { - "dependsOn": ["^build", "codegen"] + "dependsOn": ["^build", "codegen"], + "outputLogs": "new-only" }, "docs": { "dependsOn": ["^build", "codegen"], "inputs": ["src/**", "scripts/create-docs.ts", "../../typedoc.config.mjs", "../../package.json"], - "outputs": ["docs/**", "!docs/ABOUT.md"] + "outputs": ["docs/**", "!docs/ABOUT.md"], + "outputLogs": "new-only" }, "//#generate-readme": { "dependsOn": ["^build"], @@ -69,7 +82,8 @@ "sdk/**/docs/**", "sdk/**/examples/**" ], - "outputs": ["sdk/**/README.md"] + "outputs": ["sdk/**/README.md"], + "outputLogs": "new-only" } } } From 4bd6d9a447a25f4f52d1e3e966554b0a35e4e245 Mon Sep 17 00:00:00 2001 From: Roderik van der Veer Date: Fri, 7 Nov 2025 13:42:48 +0100 Subject: [PATCH 6/8] refactor(terminal): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `_isQuietMode` and `_error` variables - Extract helper functions to reduce complexity: - `colorize()` for ANSI color application - `canPrint()` for level-based filtering - `prepareMessage()` for Error handling and token masking - Enhance JSDoc documentation: - Document Error parameter behavior with examples - Clarify environment variable precedence in shouldPrint() - Add detailed descriptions for helper functions - Simplify control flow with early returns - Maintain backward compatibility All tests passing (108/108), no linting errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- sdk/utils/src/terminal/note.ts | 108 ++++++++++++++++--------- sdk/utils/src/terminal/should-print.ts | 18 +++-- 2 files changed, 83 insertions(+), 43 deletions(-) diff --git a/sdk/utils/src/terminal/note.ts b/sdk/utils/src/terminal/note.ts index 7532c76cd..d13ee59b1 100644 --- a/sdk/utils/src/terminal/note.ts +++ b/sdk/utils/src/terminal/note.ts @@ -2,6 +2,60 @@ import { maskTokens } from "@/logging/mask-tokens.js"; import { redBright, yellowBright } from "yoctocolors"; import { shouldPrint } from "./should-print.js"; +/** + * Applies color to a message if not already colored. + * @param msg - The message to colorize + * @param level - The severity level determining the color + * @returns Colorized message (yellow for warnings, red for errors, unchanged for info) + */ +function colorize(msg: string, level: "info" | "warn" | "error"): string { + // Don't re-colorize messages that already contain ANSI escape codes + if (msg.includes("\u001b[")) { + return msg; + } + if (level === "warn") { + return yellowBright(msg); + } + if (level === "error") { + return redBright(msg); + } + return msg; +} + +/** + * Determines whether a message should be printed based on its level and quiet mode. + * @param level - The severity level of the message + * @returns true if the message should be printed, false otherwise + */ +function canPrint(level: "info" | "warn" | "error"): boolean { + // Warnings and errors always print, even in quiet mode + if (level !== "info") { + return true; + } + // Info messages respect shouldPrint() which checks for quiet mode + return shouldPrint(); +} + +/** + * Prepares a message for display by converting Error objects and masking tokens. + * @param value - The message string or Error object + * @param level - The severity level (stack traces are included for errors) + * @returns Masked message text, optionally with stack trace + */ +function prepareMessage(value: string | Error, level: "info" | "warn" | "error"): string { + let text: string; + if (value instanceof Error) { + text = value.message; + // For errors, automatically include stack trace + if (level === "error" && value.stack) { + text = `${text}\n\n${value.stack}`; + } + } else { + text = value; + } + return maskTokens(text); +} + /** * Displays a note message with optional warning or error level formatting. * Regular notes are displayed in normal text, warnings are shown in yellow, and errors in red. @@ -9,7 +63,9 @@ import { shouldPrint } from "./should-print.js"; * Warnings and errors are always displayed, even in quiet mode (when CLAUDECODE, REPL_ID, or AGENT env vars are set). * When an Error object is provided with level "error", the stack trace is automatically included. * - * @param message - The message to display as a note, or an Error object + * @param message - The message to display as a note. Can be either: + * - A string: Displayed directly with appropriate styling + * - An Error object: The error message is displayed, and for level "error", the stack trace is automatically included * @param level - The note level: "info" (default), "warn" for warning styling, or "error" for error styling * @example * import { note } from "@settlemint/sdk-utils/terminal"; @@ -20,54 +76,30 @@ import { shouldPrint } from "./should-print.js"; * // Display warning note * note("Low disk space remaining", "warn"); * - * // Display error note + * // Display error note (string) * note("Operation failed", "error"); * - * // Display error with stack trace automatically + * // Display error with stack trace automatically (Error object) * try { * // some operation * } catch (error) { + * // If error is an Error object and level is "error", stack trace is included automatically * note(error, "error"); * } */ export const note = (message: string | Error, level: "info" | "warn" | "error" = "info"): void => { - let messageText: string; - let _error: Error | undefined; - - if (message instanceof Error) { - _error = message; - messageText = message.message; - // For errors, automatically include stack trace - if (level === "error" && message.stack) { - messageText = `${messageText}\n\n${message.stack}`; - } - } else { - messageText = message; - } - - const maskedMessage = maskTokens(messageText); - const _isQuietMode = process.env.CLAUDECODE || process.env.REPL_ID || process.env.AGENT; - - // Always print warnings and errors, even in quiet mode - if (level === "warn" || level === "error") { - console.log(""); - if (level === "warn") { - // Apply yellow color if not already colored (check if message contains ANSI codes) - const coloredMessage = maskedMessage.includes("\u001b[") ? maskedMessage : yellowBright(maskedMessage); - console.warn(coloredMessage); - } else { - // Apply red color if not already colored (check if message contains ANSI codes) - const coloredMessage = maskedMessage.includes("\u001b[") ? maskedMessage : redBright(maskedMessage); - console.error(coloredMessage); - } - return; - } - - // For info messages, check if we should print - if (!shouldPrint()) { + if (!canPrint(level)) { return; } + const msg = prepareMessage(message, level); console.log(""); - console.log(maskedMessage); + + if (level === "warn") { + console.warn(colorize(msg, level)); + } else if (level === "error") { + console.error(colorize(msg, level)); + } else { + console.log(msg); + } }; diff --git a/sdk/utils/src/terminal/should-print.ts b/sdk/utils/src/terminal/should-print.ts index b1becb83b..24b95bebf 100644 --- a/sdk/utils/src/terminal/should-print.ts +++ b/sdk/utils/src/terminal/should-print.ts @@ -1,10 +1,18 @@ /** - * Returns true if the terminal should print, false otherwise. - * When CLAUDECODE, REPL_ID, or AGENT env vars are set, suppresses info/debug output - * but warnings and errors will still be displayed. - * @returns true if the terminal should print, false otherwise. + * Determines whether terminal output should be printed based on environment variables. + * + * **Environment Variable Precedence:** + * 1. `SETTLEMINT_DISABLE_TERMINAL="true"` - Completely disables all terminal output (highest priority) + * 2. `CLAUDECODE`, `REPL_ID`, or `AGENT` (any truthy value) - Enables quiet mode, suppressing info/debug/status messages + * + * **Quiet Mode Behavior:** + * When quiet mode is active (Claude Code environments), this function returns `false` to suppress + * informational output. However, warnings and errors are always displayed regardless of quiet mode, + * as they are handled separately in the `note()` function with level-based filtering. + * + * @returns `true` if terminal output should be printed, `false` if suppressed */ -export function shouldPrint() { +export function shouldPrint(): boolean { if (process.env.SETTLEMINT_DISABLE_TERMINAL === "true") { return false; } From 78f122260ae71b02cf88f0f4b8c0e49118fb8c15 Mon Sep 17 00:00:00 2001 From: Roderik van der Veer Date: Fri, 7 Nov 2025 13:45:53 +0100 Subject: [PATCH 7/8] refactor(spinner): remove redundant token masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `note(error, "error")` function already masks tokens through the `prepareMessage()` helper, making the `maskTokens()` call in `handleError()` redundant. - Remove redundant `maskTokens()` call in `handleError()` - Remove unused `maskTokens` import - Pass `error.message` directly to `SpinnerError` All tests passing (108/108), no linting errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- sdk/utils/src/terminal/spinner.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sdk/utils/src/terminal/spinner.ts b/sdk/utils/src/terminal/spinner.ts index 9ab5a5139..79a05297f 100644 --- a/sdk/utils/src/terminal/spinner.ts +++ b/sdk/utils/src/terminal/spinner.ts @@ -1,7 +1,6 @@ import isInCi from "is-in-ci"; import yoctoSpinner, { type Spinner } from "yocto-spinner"; import { redBright } from "yoctocolors"; -import { maskTokens } from "../logging/mask-tokens.js"; import { note } from "./note.js"; import { shouldPrint } from "./should-print.js"; @@ -55,8 +54,7 @@ export interface SpinnerOptions { export const spinner = async (options: SpinnerOptions): Promise => { const handleError = (error: Error) => { note(error, "error"); - const errorMessage = maskTokens(error.message); - throw new SpinnerError(errorMessage, error); + throw new SpinnerError(error.message, error); }; if (isInCi || !shouldPrint()) { try { From 05ca0d6e9799163203cea0a4796a312f541ee279 Mon Sep 17 00:00:00 2001 From: Roderik van der Veer Date: Fri, 7 Nov 2025 13:50:45 +0100 Subject: [PATCH 8/8] refactor(terminal): enhance output suppression logic in executeCommand - Introduced a clearer mechanism for output suppression in quiet mode, ensuring that output is only displayed when explicitly allowed. - Updated the handling of the `silent` option to improve clarity and maintainability. All tests passing, no linting errors. --- sdk/utils/src/terminal/execute-command.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk/utils/src/terminal/execute-command.ts b/sdk/utils/src/terminal/execute-command.ts index 72c0287ad..3f3dc380e 100644 --- a/sdk/utils/src/terminal/execute-command.ts +++ b/sdk/utils/src/terminal/execute-command.ts @@ -65,14 +65,14 @@ export async function executeCommand( const { silent, ...spawnOptions } = options ?? {}; const quietMode = isQuietMode(); // In quiet mode, suppress output unless explicitly overridden with silent: false - const shouldSuppressOutput = quietMode ? (silent !== false) : !!silent; - + const shouldSuppressOutput = quietMode ? silent !== false : !!silent; + const child = spawn(command, args, { ...spawnOptions, env: { ...process.env, ...options?.env } }); process.stdin.pipe(child.stdin); const output: string[] = []; const stdoutOutput: string[] = []; const stderrOutput: string[] = []; - + return new Promise((resolve, reject) => { child.stdout.on("data", (data: Buffer | string) => { const maskedData = maskTokens(data.toString()); @@ -90,7 +90,7 @@ export async function executeCommand( output.push(maskedData); stderrOutput.push(maskedData); }); - + const showErrorOutput = () => { // In quiet mode, show output on error if (quietMode && shouldSuppressOutput && output.length > 0) { @@ -103,7 +103,7 @@ export async function executeCommand( } } }; - + child.on("error", (err) => { process.stdin.unpipe(child.stdin); showErrorOutput();