From b31bee03a8d4f3b7bcb8eaa8109c43115be7cf52 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 25 Jul 2026 00:54:46 -0700 Subject: [PATCH] FN-8576: add global quiet flag to CLI Add a global quiet mode that suppresses informational CLI stdout without hiding requested results. - Parse --quiet/-q and FUSION_QUIET with command and output exemptions. - Route result output and interactive prompts around the reversible stdout gate. - Document the flag and cover quiet output, prompts, and argument parsing. Files changed: .changeset/fn-8576-cli-quiet-flag.md | 7 ++ docs/cli-reference.md | 8 ++ packages/cli/src/__tests__/bin.test.ts | 15 +++ packages/cli/src/__tests__/cli-quiet-mode.test.ts | 63 +++++++++++++ .../__tests__/cli-quiet-prompt-surfaces.test.ts | 33 +++++++ packages/cli/src/bin.ts | 39 ++++++-- packages/cli/src/commands/experiment-finalize.ts | 3 +- packages/cli/src/commands/git.ts | 17 ++-- packages/cli/src/commands/goals.ts | 4 +- packages/cli/src/commands/mission.ts | 8 +- packages/cli/src/commands/node.ts | 4 +- packages/cli/src/commands/onboard.ts | 4 +- packages/cli/src/commands/org-import.ts | 5 +- packages/cli/src/commands/plugin.ts | 14 ++- packages/cli/src/commands/port-prompt.ts | 4 +- packages/cli/src/commands/project.ts | 6 +- packages/cli/src/commands/research.ts | 3 +- packages/cli/src/commands/task.ts | 101 +++++++++++--------- packages/cli/src/commands/update.ts | 3 +- packages/cli/src/commands/workflow.ts | 13 +-- packages/cli/src/output.ts | 104 +++++++++++++++++++++ packages/cli/src/project-resolver.ts | 20 ++-- 22 files changed, 388 insertions(+), 90 deletions(-) Fusion-Task-Id: FN-8576 Fusion-Task-Lineage: 2493d6d5-bbd9-4fc2-b903-950457ef30b0 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8576-cli-quiet-flag.md | 7 ++ docs/cli-reference.md | 8 ++ packages/cli/src/__tests__/bin.test.ts | 15 +++ .../cli/src/__tests__/cli-quiet-mode.test.ts | 63 +++++++++++ .../cli-quiet-prompt-surfaces.test.ts | 33 ++++++ packages/cli/src/bin.ts | 39 +++++-- .../cli/src/commands/experiment-finalize.ts | 3 +- packages/cli/src/commands/git.ts | 15 ++- packages/cli/src/commands/goals.ts | 4 +- packages/cli/src/commands/mission.ts | 8 +- packages/cli/src/commands/node.ts | 4 +- packages/cli/src/commands/onboard.ts | 4 +- packages/cli/src/commands/org-import.ts | 5 +- packages/cli/src/commands/plugin.ts | 14 ++- packages/cli/src/commands/port-prompt.ts | 4 +- packages/cli/src/commands/project.ts | 6 +- packages/cli/src/commands/research.ts | 3 +- packages/cli/src/commands/task.ts | 107 ++++++++++-------- packages/cli/src/commands/update.ts | 3 +- packages/cli/src/commands/workflow.ts | 13 ++- packages/cli/src/output.ts | 104 +++++++++++++++++ packages/cli/src/project-resolver.ts | 20 ++-- 22 files changed, 390 insertions(+), 92 deletions(-) create mode 100644 .changeset/fn-8576-cli-quiet-flag.md create mode 100644 packages/cli/src/__tests__/cli-quiet-mode.test.ts create mode 100644 packages/cli/src/__tests__/cli-quiet-prompt-surfaces.test.ts create mode 100644 packages/cli/src/output.ts diff --git a/.changeset/fn-8576-cli-quiet-flag.md b/.changeset/fn-8576-cli-quiet-flag.md new file mode 100644 index 0000000000..e630b286a8 --- /dev/null +++ b/.changeset/fn-8576-cli-quiet-flag.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a quiet CLI mode that hides informational stdout chatter. +category: feature +dev: Adds the output.ts quiet seam and FUSION_QUIET environment control. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 33e897af63..46c9019dc7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -99,8 +99,16 @@ fn [options] | Option | Description | |---|---| | `--project `, `-P ` | Target a specific registered project. | +| `--quiet`, `-q` | Suppress informational stdout chatter for non-interactive commands. | +| `FUSION_QUIET=1` | Enable quiet mode from the environment (`1`, `true`, `yes`, or `on`). | | `--help`, `-h` | Show help output. | +### Quiet mode + +`fn --quiet ` (or `FUSION_QUIET=1 fn `) suppresses console chatter and raw stdout progress writers without changing exit codes. Stderr, every interactive prompt, and command output whose stdout is the machine-consumable result (such as IDs, paths, and exported payloads) always remain visible. An explicit `--quiet` flag wins over the environment. + +Quiet suppression is disabled for `--json`, help/version requests (including delegated subcommand help), and the live `serve`, `daemon`, `dashboard`, `desktop`, `chat`, and Ink dashboard-TUI surfaces. These commands still accept either quiet flag and strip it before command routing. + ### Project resolution order When `--project` is not supplied, Fusion resolves project context in this order: diff --git a/packages/cli/src/__tests__/bin.test.ts b/packages/cli/src/__tests__/bin.test.ts index 579f1b2cf5..e97c183419 100644 --- a/packages/cli/src/__tests__/bin.test.ts +++ b/packages/cli/src/__tests__/bin.test.ts @@ -334,6 +334,21 @@ async function runBin(args: string[]) { } describe("bin command routing and fallbacks", () => { + it("keeps an absent quiet flag undefined while stripping either quiet spelling", async () => { + const previous = process.env.FUSION_CLI_SKIP_MAIN; + process.env.FUSION_CLI_SKIP_MAIN = "1"; + try { + const { extractGlobalProjectFlag } = await import("../bin.ts?quiet-parser"); + expect(extractGlobalProjectFlag(["task", "list"]).quiet).toBeUndefined(); + expect(extractGlobalProjectFlag(["task", "--quiet", "list"]).cleanedArgs).toEqual(["task", "list"]); + expect(extractGlobalProjectFlag(["serve", "-q", "--port", "0"]).cleanedArgs).toEqual(["serve", "--port", "0"]); + expect(extractGlobalProjectFlag(["--quiet", "serve", "--project", "name"]).cleanedArgs).toEqual(["serve", "--project", "name"]); + } finally { + if (previous === undefined) delete process.env.FUSION_CLI_SKIP_MAIN; + else process.env.FUSION_CLI_SKIP_MAIN = previous; + } + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/cli/src/__tests__/cli-quiet-mode.test.ts b/packages/cli/src/__tests__/cli-quiet-mode.test.ts new file mode 100644 index 0000000000..723d7e62fc --- /dev/null +++ b/packages/cli/src/__tests__/cli-quiet-mode.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +let output: typeof import("../output.js") | undefined; +let stdoutSpy: ReturnType | undefined; + +async function loadOutput() { + vi.resetModules(); + stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation((() => true) as typeof process.stdout.write); + output = await import("../output.js"); + return output; +} + +afterEach(() => { + output?.resetQuietMode(); + output?.uninstallQuietGate(); + stdoutSpy?.mockRestore(); + output = undefined; + stdoutSpy = undefined; + vi.restoreAllMocks(); +}); + +describe("CLI quiet output seam", () => { + it("resolves presence-preserving flag and environment precedence", async () => { + const seam = await loadOutput(); + expect(seam.resolveQuietMode({ flag: true })).toBe(true); + expect(seam.resolveQuietMode({ flag: false, env: "true" })).toBe(false); + expect(seam.resolveQuietMode({ env: "YES" })).toBe(true); + expect(seam.resolveQuietMode({})).toBe(false); + }); + + it("suppresses gated chatter while preserving stderr, results, prompts, and write callbacks", async () => { + const seam = await loadOutput(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((() => true) as typeof process.stderr.write); + const callback = vi.fn(); + seam.setQuietMode(true); + seam.installQuietGate(); + console.log("chatter"); + console.info("chatter"); + expect(process.stdout.write("progress", callback)).toBe(true); + console.error("error"); + process.stderr.write("stderr"); + seam.result("result"); + seam.promptOutputStream().write("question"); + expect(callback).toHaveBeenCalledOnce(); + expect(stdoutSpy).toHaveBeenCalledTimes(2); + expect(errorSpy).toHaveBeenCalledWith("error"); + expect(stderrSpy).toHaveBeenCalledWith("stderr"); + }); + + it("is idempotent and returns stdout to normal after disable", async () => { + const seam = await loadOutput(); + seam.setQuietMode(true); + seam.installQuietGate(); + const firstWrite = process.stdout.write; + seam.installQuietGate(); + expect(process.stdout.write).toBe(firstWrite); + seam.setQuietMode(false); + console.log("visible"); + process.stdout.write("visible"); + expect(stdoutSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/__tests__/cli-quiet-prompt-surfaces.test.ts b/packages/cli/src/__tests__/cli-quiet-prompt-surfaces.test.ts new file mode 100644 index 0000000000..c6ec9cdd29 --- /dev/null +++ b/packages/cli/src/__tests__/cli-quiet-prompt-surfaces.test.ts @@ -0,0 +1,33 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const cliRoot = join(import.meta.dirname, ".."); +const exemptSources = new Set([join(cliRoot, "commands", "chat.ts")]); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.name === "__tests__") return []; + return entry.isDirectory() ? sourceFiles(path) : entry.name.endsWith(".ts") ? [path] : []; + }); +} + +describe("CLI quiet prompt and result source contracts", () => { + it("does not construct a non-exempt readline prompt with gated stdout", () => { + for (const path of sourceFiles(cliRoot)) { + if (exemptSources.has(path)) continue; + const source = readFileSync(path, "utf8"); + if (!source.includes("createInterface")) continue; + expect(source, path).not.toMatch(/output:\s*process\.stdout/); + } + }); + + it("keeps all audited result writers attached to the output seam", () => { + for (const file of ["task.ts", "org-import.ts", "workflow.ts", "research.ts", "experiment-finalize.ts", "update.ts"]) { + const source = readFileSync(join(cliRoot, "commands", file), "utf8"); + expect(source, file).toMatch(/import\s*\{[^}]*\bresult\b[^}]*\}\s*from\s*["']\.\.\/output\.js["']/); + expect(source, file).toMatch(/(?:result|outputResult)\(/); + } + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index a00b5930e8..dfdd7ff884 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -18,6 +18,7 @@ import { tmpdir } from "node:os"; import { performance } from "node:perf_hooks"; import { Readable } from "node:stream"; import { fileURLToPath } from "node:url"; +import { installQuietGate, resolveQuietMode, setQuietMode, uninstallQuietGate } from "./output.js"; // @ts-expect-error -- Bun-only global; undefined in Node const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles; @@ -511,6 +512,7 @@ Options: --labels, -L Comma-separated label filter for import --interactive, -i Interactive mode for issue selection --help, -h Show this help + --quiet, -q Suppress informational stdout output Columns: triage, todo, in-progress, in-review, done, archived Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv, xml @@ -520,18 +522,29 @@ export function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; projectName?: string; skipOnboarding: boolean; + quiet?: boolean; } { - const command = argv[0]; - if (command === "serve" || command === "daemon") { - return { cleanedArgs: [...argv], skipOnboarding: false }; - } - + // FNXC:CliQuietMode 2026-07-16-01:00: Serve/daemon keep their legacy + // pass-through argv contract even when a global quiet flag precedes them. + const command = argv.find((arg) => arg !== "--quiet" && arg !== "-q"); + const isServeOrDaemon = command === "serve" || command === "daemon"; const cleanedArgs: string[] = []; let projectName: string | undefined; let skipOnboarding = false; + // FNXC:CliQuietMode 2026-07-16-00:00: `undefined` preserves flag absence so + // FUSION_QUIET can participate in resolution; never collapse it to false. + let quiet: boolean | undefined; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; + if (arg === "--quiet" || arg === "-q") { + quiet = true; + continue; + } + if (isServeOrDaemon) { + cleanedArgs.push(arg); + continue; + } if (arg === "--project" || arg === "-P") { if (projectName) { throw new Error("Duplicate --project flag. Specify a project only once."); @@ -551,7 +564,7 @@ export function extractGlobalProjectFlag(argv: string[]): { cleanedArgs.push(arg); } - return { cleanedArgs, projectName, skipOnboarding }; + return { cleanedArgs, projectName, skipOnboarding, quiet }; } function getFlagValue(args: string[], flag: string): string | undefined { @@ -655,7 +668,19 @@ function readOwnCliVersion(): string | undefined { } async function main() { - const { cleanedArgs: args, projectName, skipOnboarding } = extractGlobalProjectFlag(process.argv.slice(2)); + const { cleanedArgs: args, projectName, skipOnboarding, quiet } = extractGlobalProjectFlag(process.argv.slice(2)); + const hasJsonFlag = args.includes("--json"); + const hasHelpOrVersionFlag = args.some((arg) => ["--help", "-h", "--version", "-v"].includes(arg)); + const selectedCommand = !args[0] || args[0].startsWith("-") ? "dashboard" : args[0]; + const isExemptCommand = ["serve", "daemon", "dashboard", "desktop", "chat"].includes(selectedCommand); + // FNXC:CliQuietMode 2026-07-16-00:00: Recompute effective state on every + // invocation. JSON and help/version are requested results; live commands, + // including the bare/dashboard Ink TUI path, must retain their UI output. + const effectiveQuiet = resolveQuietMode({ flag: quiet, env: process.env.FUSION_QUIET }) + && !hasJsonFlag && !hasHelpOrVersionFlag && !isExemptCommand; + setQuietMode(effectiveQuiet); + if (effectiveQuiet) installQuietGate(); + else uninstallQuietGate(); // Print version and exit before any application imports. This is what the // dashboard's CLI Binary panel probes via ` --version`; without an diff --git a/packages/cli/src/commands/experiment-finalize.ts b/packages/cli/src/commands/experiment-finalize.ts index 747d2b1547..0626f586b0 100644 --- a/packages/cli/src/commands/experiment-finalize.ts +++ b/packages/cli/src/commands/experiment-finalize.ts @@ -13,6 +13,7 @@ import { type FinalizePlanOverride, } from "@fusion/engine"; import { closeProjectStore, resolveProject, type ProjectContext } from "../project-context.js"; +import { result } from "../output.js"; interface ExperimentFinalizeOptions { sessionId: string; @@ -34,7 +35,7 @@ const EXIT_CODES = new Map([ ]); function printJson(payload: unknown): void { - console.log(JSON.stringify(payload, null, 2)); + result(JSON.stringify(payload, null, 2) + "\n"); } function printPlan(plan: Awaited>): void { diff --git a/packages/cli/src/commands/git.ts b/packages/cli/src/commands/git.ts index b5a7240ba6..052f1502a0 100644 --- a/packages/cli/src/commands/git.ts +++ b/packages/cli/src/commands/git.ts @@ -4,6 +4,7 @@ import { promisify } from "node:util"; const execAsync = promisify(exec); import { createInterface } from "node:readline/promises"; import { resolveProjectPathOnly } from "../project-context.js"; +import { promptOutputStream, result } from "../output.js"; /** * FNXC:CliBoardMutation 2026-07-09-00:00: @@ -322,11 +323,15 @@ export async function runGitPull(options: { skipConfirm?: boolean; projectName?: // Warn about uncommitted changes if (status.isDirty && !options.skipConfirm) { - console.log(); - console.log(" ⚠ Warning: You have uncommitted changes."); - console.log(` Branch: ${status.branch}`); + /* + * FNXC:CliQuietMode 2026-07-16-00:00: + * The dirty-worktree warning identifies the consequence the user is being + * asked to confirm, so it must bypass quiet-mode chatter suppression. + */ + result(`\n ⚠ Warning: You have uncommitted changes.\n Branch: ${status.branch}\n`); - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream() }); const answer = await rl.question(" Continue with pull? [y/N] "); rl.close(); @@ -394,7 +399,7 @@ export async function runGitPush(options: { skipConfirm?: boolean; projectName?: // Confirmation prompt if (!options.skipConfirm) { console.log(); - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); const answer = await rl.question(` Push branch ${status.branch} to remote? [Y/n] `); rl.close(); diff --git a/packages/cli/src/commands/goals.ts b/packages/cli/src/commands/goals.ts index 4a745a4fef..1ce210ab71 100644 --- a/packages/cli/src/commands/goals.ts +++ b/packages/cli/src/commands/goals.ts @@ -1,6 +1,7 @@ import { createInterface } from "node:readline/promises"; import type { GoalCitationSurface } from "@fusion/core"; import { getStore } from "../project-resolver.js"; +import { promptOutputStream } from "../output.js"; type GoalStatusFilter = "active" | "archived" | "all"; @@ -38,7 +39,8 @@ async function promptForTitleAndDescription( let description: string | undefined; if (!title) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream() }); title = await rl.question("Goal title: "); if (!title?.trim()) { diff --git a/packages/cli/src/commands/mission.ts b/packages/cli/src/commands/mission.ts index 51b462a8f8..26eaeee727 100644 --- a/packages/cli/src/commands/mission.ts +++ b/packages/cli/src/commands/mission.ts @@ -8,6 +8,7 @@ import { } from "@fusion/core"; import { createInterface } from "node:readline/promises"; import { resolveProjectStore } from "../project-resolver.js"; +import { promptOutputStream } from "../output.js"; // ── Status Labels for Display ─────────────────────────────────────────────── @@ -105,7 +106,8 @@ async function promptForTitleAndDescription( if (!title) { const rl = createInterface({ input: process.stdin, - output: process.stdout, + /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream(), }); title = await rl.question(titlePrompt); @@ -431,7 +433,7 @@ export async function runMissionDelete( if (!force) { const rl = createInterface({ input: process.stdin, - output: process.stdout, + output: promptOutputStream(), }); const answer = await rl.question( `Are you sure you want to delete ${id}: "${mission.title}"? [y/N] `, @@ -614,7 +616,7 @@ export async function runFeatureAdd( if (!title) { const rl = createInterface({ input: process.stdin, - output: process.stdout, + output: promptOutputStream(), }); title = await rl.question("Feature title: "); diff --git a/packages/cli/src/commands/node.ts b/packages/cli/src/commands/node.ts index ccfcfbe0e9..65a6c0bd57 100644 --- a/packages/cli/src/commands/node.ts +++ b/packages/cli/src/commands/node.ts @@ -1,5 +1,6 @@ import { CentralCore, type NodeConfig } from "@fusion/core"; import { createInterface } from "node:readline/promises"; +import { promptOutputStream } from "../output.js"; const GREEN = "\x1b[32m"; const RED = "\x1b[31m"; @@ -362,7 +363,8 @@ export async function runNodeDisconnect( } if (!options.force) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream() }); const answer = await rl.question(`Disconnect node '${node.name}'? [y/N] `); rl.close(); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index b422a5e6f6..c19c898a5c 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -4,6 +4,7 @@ import { createInterface } from "node:readline"; import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core"; import { createFusionAuthStorage, createFusionModelRegistry } from "@fusion/engine"; import { resolveProject } from "../project-context.js"; +import { promptOutputStream } from "../output.js"; import { runInit } from "./init.js"; import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; import { getModelRegistryModelsPath } from "./auth-paths.js"; @@ -37,7 +38,8 @@ interface PromptSession { } function createPromptSession(input: NodeJS.ReadableStream = process.stdin): PromptSession { - const rl = createInterface({ input, output: process.stdout }); + const rl = createInterface({ input, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream() }); let settled = false; const cleanup = () => { diff --git a/packages/cli/src/commands/org-import.ts b/packages/cli/src/commands/org-import.ts index f8c596ba6d..3a38f48575 100644 --- a/packages/cli/src/commands/org-import.ts +++ b/packages/cli/src/commands/org-import.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { AgentStore, AutomationStore, RoutineStore, createTaskStoreForBackend, materializeOrgBundle, type OrgBundle } from "@fusion/core"; import { resolveProjectPathOnly } from "../project-context.js"; +import { result } from "../output.js"; export async function runOrgImport(file: string, options: { project?: string; dryRun?: boolean; collisionMode?: "skip" | "suffix" } = {}): Promise { const rootDir = options.project ? await resolveProjectPathOnly(options.project) : process.cwd(); @@ -11,8 +12,8 @@ export async function runOrgImport(file: string, options: { project?: string; dr const agents = new AgentStore({ rootDir: `${rootDir}/.fusion`, asyncLayer: boot.taskStore.asyncLayer! }); try { await agents.init(); - const result = await materializeOrgBundle({ projectRoot: rootDir, agentStore: agents, routineStore: new RoutineStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), automationStore: new AutomationStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), settingsStore: boot.taskStore }, bundle, { dryRun: options.dryRun, collisionMode: options.collisionMode }); + const imported = await materializeOrgBundle({ projectRoot: rootDir, agentStore: agents, routineStore: new RoutineStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), automationStore: new AutomationStore(rootDir, { asyncLayer: boot.taskStore.asyncLayer! }), settingsStore: boot.taskStore }, bundle, { dryRun: options.dryRun, collisionMode: options.collisionMode }); console.log(options.dryRun ? "Organization import dry-run:" : "Organization imported:"); - console.log(JSON.stringify(result, null, 2)); + result(JSON.stringify(imported, null, 2) + "\n"); } finally { agents.close(); await boot.shutdown(); } } diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index ec99632d51..2d3ac4e0b7 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -15,6 +15,7 @@ import { readFile, stat } from "node:fs/promises"; import * as readline from "node:readline"; import { PluginStore, PluginLoader, validatePluginManifest, resolveGlobalDir, CentralCore } from "@fusion/core"; import { resolveProject } from "../project-context.js"; +import { promptOutputStream, result } from "../output.js"; export interface BuiltinPluginCatalogEntry { id: string; @@ -432,15 +433,18 @@ export async function runPluginUninstall( // Confirm unless force if (!options?.force) { - console.log(); - console.log(` Uninstall "${plugin.name}" globally?`); - console.log(" This removes it for all projects."); - console.log(); + /* + * FNXC:CliQuietMode 2026-07-16-00:00: + * This label describes the irreversible scope of the following prompt, + * so it remains visible while quiet mode suppresses ordinary status logs. + */ + result(`\n Uninstall "${plugin.name}" globally?\n This removes it for all projects.\n\n`); const response = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, - output: process.stdout, + /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream(), }); rl.question(" Continue? [y/N] ", (answer: string) => { rl.close(); diff --git a/packages/cli/src/commands/port-prompt.ts b/packages/cli/src/commands/port-prompt.ts index b134054740..903a2fa194 100644 --- a/packages/cli/src/commands/port-prompt.ts +++ b/packages/cli/src/commands/port-prompt.ts @@ -7,6 +7,7 @@ */ import { createInterface } from "node:readline"; +import { promptOutputStream } from "../output.js"; /** * Prompt the user for a port number interactively. @@ -26,7 +27,8 @@ export function promptForPort( return new Promise((resolve, reject) => { const rl = createInterface({ input, - output: process.stdout, + /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream(), }); // Handle Ctrl+C during prompt diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index b651f5874a..2e7fe7881a 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -30,6 +30,7 @@ import { import { resolve, isAbsolute, relative, basename, join } from "node:path"; import { existsSync, statSync } from "node:fs"; import { createInterface } from "node:readline/promises"; +import { promptOutputStream } from "../output.js"; import { detectProjectFromCwd, setDefaultProject } from "../project-context.js"; import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js"; import { retryOnLock } from "../lock-retry.js"; @@ -313,7 +314,8 @@ export async function runProjectAdd( // Interactive mode if name or path not provided if (!projectName || !projectPath || options.interactive) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream() }); // Get path if not provided if (!projectPath) { @@ -494,7 +496,7 @@ export async function runProjectRemove(name: string, options: ProjectRemoveOptio } if (!options.force) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); const answer = await rl.question(`Unregister project '${project.name}'? [y/N] `); rl.close(); diff --git a/packages/cli/src/commands/research.ts b/packages/cli/src/commands/research.ts index 0da445995f..e369cd07da 100644 --- a/packages/cli/src/commands/research.ts +++ b/packages/cli/src/commands/research.ts @@ -1,4 +1,5 @@ import { writeFile } from "node:fs/promises"; +import { result } from "../output.js"; import { join, resolve } from "node:path"; import { RESEARCH_EXPORT_FORMATS, @@ -142,7 +143,7 @@ function printRun(run: ResearchRun): void { } function jsonOut(payload: unknown): void { - console.log(JSON.stringify(payload, null, 2)); + result(JSON.stringify(payload, null, 2) + "\n"); } function handleError(error: unknown): never { diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 7f5a9c9cce..87aaf5bf4c 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -13,6 +13,7 @@ import { runGhJsonAsync, } from "@fusion/core/gh-cli"; import { resolveProject, createLocalStore, closeProjectStore, type ProjectContext } from "../project-context.js"; +import { promptOutputStream, result as outputResult } from "../output.js"; import { findNodeByNameOrId } from "./node.js"; import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js"; @@ -382,7 +383,8 @@ async function runCliNearDuplicateCheck(args: { process.exit(1); } - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream() }); try { const answer = (await rl.question("Create anyway? [y/N]: ")).trim().toLowerCase(); if (answer === "y" || answer === "yes") { @@ -400,7 +402,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin let description = descriptionArg; if (!description) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); description = await rl.question("Task description: "); rl.close(); } @@ -506,9 +508,9 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin console.log(` Project: ${context.projectName}`); } if (linkedExisting) { - console.log(` ✓ Linked existing ${resolvedTask.id}: ${label}`); + outputResult(` ✓ Linked existing ${resolvedTask.id}: ${label}\n`); } else { - console.log(` ✓ Created ${resolvedTask.id}: ${label}`); + outputResult(` ✓ Created ${resolvedTask.id}: ${label}\n`); } console.log(` Column: ${resolvedTask.column}`); if (resolvedTask.dependencies.length > 0) { @@ -1204,7 +1206,7 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam // store resolution, since this is not a board call. let feedback = feedbackArg; if (feedback === undefined) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); feedback = await rl.question("What needs to be refined? "); rl.close(); } @@ -1420,7 +1422,7 @@ export async function runTaskDelete(id: string, force?: boolean, allowResurrecti // Prompt for confirmation unless force is used if (!force) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); const answer = await rl.question(`Are you sure you want to delete ${id}? [y/N] `); rl.close(); @@ -1495,8 +1497,13 @@ export async function runTaskImportGitHubInteractive( return; } - // Display issues with numbers - console.log(` Found ${issues.length} issues:\n`); + /* + * FNXC:CliQuietMode 2026-07-16-00:00: + * Issue choices and validation feedback are required context for this + * selection prompt. Preserve them through the output seam when quiet mode + * gates informational stdout. + */ + outputResult(` Found ${issues.length} issues:\n\n`); for (let i = 0; i < issues.length; i++) { const issue = issues[i]; const importedTask = existingTasks.find((task) => dashboard.isGitHubIssueAlreadyImported(task, { @@ -1506,13 +1513,13 @@ export async function runTaskImportGitHubInteractive( sourceUrl: issue.html_url, })); const status = importedTask ? ` [Imported as ${importedTask.id}]` : ""; - console.log(` ${i + 1}. #${issue.number} ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${status}`); + outputResult(` ${i + 1}. #${issue.number} ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${status}\n`); } - console.log(); + outputResult("\n"); // Create readline interface for interactive selection - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); let selectedIndices: number[] = []; let validInput = false; @@ -1531,13 +1538,13 @@ export async function runTaskImportGitHubInteractive( .filter((n) => !isNaN(n)); if (nums.length === 0) { - console.log(" Please enter at least one number or 'all'"); + outputResult(" Please enter at least one number or 'all'\n"); continue; } const outOfRange = nums.filter((n) => n < 1 || n > issues.length); if (outOfRange.length > 0) { - console.log(` Invalid selection: ${outOfRange.join(", ")} (range: 1-${issues.length})`); + outputResult(` Invalid selection: ${outOfRange.join(", ")} (range: 1-${issues.length})\n`); continue; } @@ -1595,7 +1602,7 @@ export async function runTaskImportGitHubInteractive( })); const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : ""); - console.log(` ✓ Created ${task.id}: ${label}`); + outputResult(` ✓ Created ${task.id}: ${label}\n`); existingTasks.push(task); created++; } @@ -1768,7 +1775,7 @@ export async function runTaskImportFromGitHub( })); const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : ""); - console.log(` ✓ Created ${task.id}: ${label}`); + outputResult(` ✓ Created ${task.id}: ${label}\n`); existingTasks.push(task); created++; } @@ -1841,7 +1848,7 @@ export async function runTaskImportFromGitLab( await retryBoardCall(context, task.id, "log entry", () => store.logEntry(task.id, resource === "merge-requests" ? "Imported merge request from GitLab" : "Imported from GitLab", item.webUrl)); existingTasks.push(task); created += 1; - console.log(` ✓ Created ${task.id}: ${task.title}`); + outputResult(` ✓ Created ${task.id}: ${task.title}\n`); } console.log(`\n ✓ Imported ${created} GitLab tasks${skipped > 0 ? ` (${skipped} skipped)` : ""}\n`); } finally { @@ -1853,7 +1860,7 @@ export async function runTaskComment(id: string, message?: string, author = "use // Interactive prompt runs BEFORE store resolution (not a board call). let text = message; if (text === undefined) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); text = await rl.question("Comment: "); rl.close(); } @@ -1910,7 +1917,7 @@ export async function runTaskSteer(id: string, message?: string, projectName?: s // resolution (not a board call). let text = message; if (text === undefined) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); text = await rl.question("Message: "); rl.close(); } @@ -1975,14 +1982,20 @@ function clearThinking(): void { } /** Prompt for text (multi-line) question */ -async function promptText(question: PlanningQuestion): Promise { - console.log(`\n ${question.question}`); - if (question.description) { - console.log(` ${question.description}`); - } - console.log(" (Enter your response. Type DONE on its own line when finished):\n"); +// FNXC:CliQuietMode 2026-07-16-01:00: Planning questions, choices, and +// validation are required prompt UI and must bypass the global stdout gate. +function writePromptUi(text: string): void { + outputResult(text); +} - const rl = createInterface({ input: process.stdin, output: process.stdout }); +async function promptText(question: PlanningQuestion): Promise { + writePromptUi(`\n ${question.question}\n`); + if (question.description) { + writePromptUi(` ${question.description}\n`); + } + writePromptUi(" (Enter your response. Type DONE on its own line when finished):\n\n"); + + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); const lines: string[] = []; return new Promise((resolve) => { @@ -2003,11 +2016,11 @@ async function promptText(question: PlanningQuestion): Promise { /** Prompt for single_select question */ async function promptSingleSelect(question: PlanningQuestion): Promise { - console.log(`\n ${question.question}`); + writePromptUi(`\n ${question.question}\n`); if (question.description) { - console.log(` ${question.description}`); + writePromptUi(` ${question.description}\n`); } - console.log(); + writePromptUi("\n"); if (!question.options || question.options.length === 0) { throw new Error("Single select question has no options"); @@ -2015,13 +2028,13 @@ async function promptSingleSelect(question: PlanningQuestion): Promise { for (let i = 0; i < question.options.length; i++) { const opt = question.options[i]; - console.log(` ${i + 1}. ${opt.label}`); + writePromptUi(` ${i + 1}. ${opt.label}\n`); if (opt.description) { - console.log(` ${opt.description}`); + writePromptUi(` ${opt.description}\n`); } } - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); while (true) { const answer = await rl.question("\n Select (1-" + question.options.length + "): "); @@ -2032,17 +2045,17 @@ async function promptSingleSelect(question: PlanningQuestion): Promise { return question.options[num - 1].id; } - console.log(` Invalid selection. Please enter a number between 1 and ${question.options.length}`); + writePromptUi(` Invalid selection. Please enter a number between 1 and ${question.options.length}\n`); } } /** Prompt for multi_select question */ async function promptMultiSelect(question: PlanningQuestion): Promise { - console.log(`\n ${question.question}`); + writePromptUi(`\n ${question.question}\n`); if (question.description) { - console.log(` ${question.description}`); + writePromptUi(` ${question.description}\n`); } - console.log(" (Enter comma-separated numbers, e.g., 1,3,4):\n"); + writePromptUi(" (Enter comma-separated numbers, e.g., 1,3,4):\n\n"); if (!question.options || question.options.length === 0) { throw new Error("Multi select question has no options"); @@ -2050,13 +2063,13 @@ async function promptMultiSelect(question: PlanningQuestion): Promise for (let i = 0; i < question.options.length; i++) { const opt = question.options[i]; - console.log(` ${i + 1}. ${opt.label}`); + writePromptUi(` ${i + 1}. ${opt.label}\n`); if (opt.description) { - console.log(` ${opt.description}`); + writePromptUi(` ${opt.description}\n`); } } - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); while (true) { const answer = await rl.question("\n Select (comma-separated): "); @@ -2066,13 +2079,13 @@ async function promptMultiSelect(question: PlanningQuestion): Promise .filter((n) => !isNaN(n)); if (nums.length === 0) { - console.log(" Please select at least one option"); + writePromptUi(" Please select at least one option\n"); continue; } const invalid = nums.filter((n) => n < 1 || n > question.options!.length); if (invalid.length > 0) { - console.log(` Invalid selection: ${invalid.join(", ")}. Range: 1-${question.options.length}`); + writePromptUi(` Invalid selection: ${invalid.join(", ")}. Range: 1-${question.options.length}\n`); continue; } @@ -2083,12 +2096,12 @@ async function promptMultiSelect(question: PlanningQuestion): Promise /** Prompt for confirm question */ async function promptConfirm(question: PlanningQuestion): Promise { - console.log(`\n ${question.question}`); + writePromptUi(`\n ${question.question}\n`); if (question.description) { - console.log(` ${question.description}`); + writePromptUi(` ${question.description}\n`); } - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); const answer = await rl.question("\n [Y/n]: "); rl.close(); @@ -2180,7 +2193,7 @@ export async function runTaskPlan( // If no initial plan, prompt interactively if (!initialPlan && !resumeSessionId) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); console.log("\n Let's plan your task. What would you like to accomplish?\n"); initialPlan = await rl.question(" Describe your idea: "); rl.close(); @@ -2392,7 +2405,7 @@ export async function runTaskPlan( // Ask for confirmation (unless --yes flag) let confirmed = yesFlag; if (!yesFlag) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); const answer = await rl.question(" Create this task? [Y/n]: "); rl.close(); const trimmed = answer.trim().toLowerCase(); @@ -2413,7 +2426,7 @@ export async function runTaskPlan( createTaskFromPlanSession(sessionId, store, { baseBranch: baseBranch?.trim() || undefined })); console.log(); - console.log(` ${alreadyCreated ? "✓ Task already created from this plan:" : "✓ Created"} ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}`); + outputResult(` ${alreadyCreated ? "✓ Task already created from this plan:" : "✓ Created"} ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}\n`); console.log(` Column: ${task.column ?? "triage"}`); if (task.dependencies.length > 0) { console.log(` Dependencies: ${task.dependencies.join(", ")}`); @@ -2430,7 +2443,7 @@ export async function runTaskPlan( */ if (!yesFlag) { // FNXC:PlanningMultiTask 2026-07-24-03:20: close the interface on every path, including thrown prompts (review finding — a leaked readline keeps the process alive). - const rlContinue = createInterface({ input: process.stdin, output: process.stdout }); + const rlContinue = createInterface({ input: process.stdin, output: promptOutputStream() }); let wantsMore = false; let focus = ""; try { diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 7376a2871e..e08fecafed 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,4 +1,5 @@ import { exec } from "node:child_process"; +import { result } from "../output.js"; import { existsSync, readFileSync } from "node:fs"; import { promisify } from "node:util"; import { dirname, resolve } from "node:path"; @@ -323,7 +324,7 @@ function printStatus(status: UpdateStatus, checkOnly: boolean): void { } function printJson(status: UpdateStatus): void { - console.log(JSON.stringify(status)); + result(JSON.stringify(status) + "\n"); } /* diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index e4d945ad3f..3d1b3dd3d1 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { createTaskStoreForBackend, type TaskStore } from "@fusion/core"; import { validateWorkflowIrDryRun } from "@fusion/engine"; import { cleanupProjectResolution, getStore } from "../project-resolver.js"; +import { result } from "../output.js"; export interface RunWorkflowValidateOptions { workflowId?: string; @@ -30,7 +31,7 @@ async function resolveStore(projectName?: string): Promise { } function printJsonAndExit(payload: unknown, code: number): never { - console.log(JSON.stringify(payload, null, 2)); + result(JSON.stringify(payload, null, 2) + "\n"); process.exit(code); } @@ -53,7 +54,7 @@ export async function runWorkflowValidate(opts: RunWorkflowValidateOptions): Pro const store = owned.store; /* FNXC:PostgresCliLifecycle 2026-07-14-19:10: Workflow validation must await the exact startup owner before any process exit; a finally block is insufficient because process.exit skips pending cleanup. */ const exitWithStore = async (payload: unknown | undefined, code: number): Promise => { - if (payload !== undefined) console.log(JSON.stringify(payload, null, 2)); + if (payload !== undefined) result(JSON.stringify(payload, null, 2) + "\n"); const current = owned; owned = undefined; await current!.shutdown(); @@ -81,14 +82,14 @@ export async function runWorkflowValidate(opts: RunWorkflowValidateOptions): Pro ir = def.ir; } - const result = await validateWorkflowIrDryRun(store, ir, false); - if (opts.json) return await exitWithStore(result.valid ? { valid: true } : { valid: false, errors: result.errors }, result.valid ? 0 : 1); - if (result.valid) { + const validation = await validateWorkflowIrDryRun(store, ir, false); + if (opts.json) return await exitWithStore(validation.valid ? { valid: true } : { valid: false, errors: validation.errors }, validation.valid ? 0 : 1); + if (validation.valid) { console.log("✓ Workflow IR is valid. No workflow was created or mutated."); return await exitWithStore(undefined, 0); } console.error("✗ Workflow IR is invalid:"); - for (const error of result.errors) console.error(` - ${error.message}`); + for (const error of validation.errors) console.error(` - ${error.message}`); return await exitWithStore(undefined, 1); } finally { const current = owned; diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts new file mode 100644 index 0000000000..21368c425c --- /dev/null +++ b/packages/cli/src/output.ts @@ -0,0 +1,104 @@ +import { Writable } from "node:stream"; + +/* + * FNXC:CliQuietMode 2026-07-16-00:00: + * Quiet mode suppresses console and raw stdout chatter while preserving stderr, + * interactive prompts, and result-bearing output. JSON, help/version, and + * exempt live surfaces (including the Ink TUI) resolve quiet off per invocation. + * The presence-preserving flag beats the environment, and this dynamic gate is + * reversible so repeated in-process CLI runs never leak output state. + */ + +const originalConsoleLog = console.log; +const originalConsoleInfo = console.info; +const originalStdoutWrite = process.stdout.write.bind(process.stdout); + +let quietMode = false; +let gateInstalled = false; + +function isTruthyEnvFlag(value: string | undefined): boolean { + if (value === undefined) return false; + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + +export function resolveQuietMode({ flag, env }: { flag?: boolean; env?: string }): boolean { + return flag === undefined ? isTruthyEnvFlag(env) : flag; +} + +export function setQuietMode(enabled: boolean): void { + quietMode = enabled; +} + +export function isQuietMode(): boolean { + return quietMode; +} + +export function resetQuietMode(): void { + quietMode = false; +} + +/** Install one dynamic stdout gate; reinstallation deliberately never double-wraps. */ +export function installQuietGate(): void { + if (gateInstalled) return; + gateInstalled = true; + + console.log = (...args: unknown[]) => { + if (!isQuietMode()) originalConsoleLog(...args); + }; + console.info = (...args: unknown[]) => { + if (!isQuietMode()) originalConsoleInfo(...args); + }; + + process.stdout.write = ((chunk: unknown, encoding?: unknown, callback?: unknown): boolean => { + if (!isQuietMode()) { + return originalStdoutWrite(chunk as never, encoding as never, callback as never); + } + const done = typeof encoding === "function" ? encoding : callback; + if (typeof done === "function") { + done(); + } + return true; + }) as typeof process.stdout.write; +} + +export function uninstallQuietGate(): void { + if (!gateInstalled) return; + console.log = originalConsoleLog; + console.info = originalConsoleInfo; + process.stdout.write = originalStdoutWrite as typeof process.stdout.write; + gateInstalled = false; +} + +/** Write machine-consumable command output without consulting the quiet gate. */ +export function result(text: string): void { + originalStdoutWrite(text); +} + +/** + * Readline needs stdout's terminal metadata as well as its writer. This proxy + * bypasses quiet only for prompt rendering while forwarding all other stream + * behaviour to the real stdout instance. + */ +export function promptOutputStream(): NodeJS.WritableStream { + const proxy = new Writable({ + write(chunk, encoding, callback) { + originalStdoutWrite(chunk, encoding, callback); + }, + }); + + return new Proxy(proxy, { + get(target, property, receiver) { + if (property === "columns" || property === "rows" || property === "isTTY") { + return Reflect.get(process.stdout, property); + } + if (property === "write") { + return (chunk: unknown, encoding?: unknown, callback?: unknown) => + originalStdoutWrite(chunk as never, encoding as never, callback as never); + } + const value = Reflect.get(target, property, receiver); + if (value !== undefined) return typeof value === "function" ? value.bind(target) : value; + const stdoutValue = Reflect.get(process.stdout, property); + return typeof stdoutValue === "function" ? stdoutValue.bind(process.stdout) : stdoutValue; + }, + }) as unknown as NodeJS.WritableStream; +} diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index 1d031ca2bb..12ef636b27 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -11,6 +11,7 @@ import { existsSync, statSync } from "node:fs"; import { basename, dirname, join, normalize, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; +import { promptOutputStream, result as outputResult } from "./output.js"; import { CentralCore, createTaskStoreForBackend, @@ -144,11 +145,14 @@ async function promptProjectSelection( projects: RegisteredProject[], message = "Select a project:" ): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, /* FNXC:CliQuietMode 2026-07-16-00:00: Readline prompts bypass the quiet stdout gate so interactive questions remain visible. */ + output: promptOutputStream() }); - console.log(`\n ${message}`); + // FNXC:CliQuietMode 2026-07-16-01:00: Selection labels and validation are + // part of the interactive prompt, so they bypass quiet mode with readline. + outputResult(`\n ${message}\n`); for (let i = 0; i < projects.length; i++) { - console.log(` ${i + 1}. ${projects[i].name} (${projects[i].path})`); + outputResult(` ${i + 1}. ${projects[i].name} (${projects[i].path})\n`); } while (true) { @@ -160,7 +164,7 @@ async function promptProjectSelection( return projects[num - 1]; } - console.log(` Invalid selection. Please enter a number between 1 and ${projects.length}`); + outputResult(` Invalid selection. Please enter a number between 1 and ${projects.length}\n`); } } @@ -168,7 +172,7 @@ async function promptProjectSelection( * Prompt for yes/no confirmation. */ async function promptConfirm(message: string, defaultYes = false): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = createInterface({ input: process.stdin, output: promptOutputStream() }); const prompt = defaultYes ? "[Y/n]" : "[y/N]"; const answer = await rl.question(` ${message} ${prompt}: `); rl.close(); @@ -307,7 +311,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise