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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-25 00:54:46 -07:00
parent 0056d75314
commit b31bee03a8
22 changed files with 390 additions and 92 deletions

View File

@@ -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.

View File

@@ -99,8 +99,16 @@ fn <command> <subcommand> [options]
| Option | Description |
|---|---|
| `--project <name>`, `-P <name>` | 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 <command>` (or `FUSION_QUIET=1 fn <command>`) 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:

View File

@@ -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(() => {});

View File

@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
let output: typeof import("../output.js") | undefined;
let stdoutSpy: ReturnType<typeof vi.spyOn> | 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();
});
});

View File

@@ -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)\(/);
}
});
});

View File

@@ -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 <labels> 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 `<bin> --version`; without an

View File

@@ -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<string, number>([
]);
function printJson(payload: unknown): void {
console.log(JSON.stringify(payload, null, 2));
result(JSON.stringify(payload, null, 2) + "\n");
}
function printPlan(plan: Awaited<ReturnType<ExperimentFinalizeService["previewPlan"]>>): void {

View File

@@ -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();

View File

@@ -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()) {

View File

@@ -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: ");

View File

@@ -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();

View File

@@ -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 = () => {

View File

@@ -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<void> {
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(); }
}

View File

@@ -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<string>((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();

View File

@@ -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

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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<string> {
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<string> {
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<string> {
/** Prompt for single_select question */
async function promptSingleSelect(question: PlanningQuestion): Promise<string> {
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<string> {
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<string> {
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<string[]> {
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<string[]>
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<string[]>
.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<string[]>
/** Prompt for confirm question */
async function promptConfirm(question: PlanningQuestion): Promise<boolean> {
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 {

View File

@@ -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");
}
/*

View File

@@ -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<OwnedWorkflowStore> {
}
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<never> => {
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;

104
packages/cli/src/output.ts Normal file
View File

@@ -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;
}

View File

@@ -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<RegisteredProject> {
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<boolean> {
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<Reso
const shouldRegister = await promptConfirm("Register this project now?", true);
if (shouldRegister) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
const defaultName = basename(fusionDir) || "unnamed";
const name = await rl.question(` Project name [${defaultName}]: `);
rl.close();
@@ -741,7 +745,7 @@ export async function registerProjectInteractive(
// Determine project name
let name = options.name;
if (!name && interactive) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
const suggested = suggestProjectName(absPath);
const input = await rl.question(` Project name [${suggested}]: `);
rl.close();
@@ -793,7 +797,7 @@ export async function registerProjectInteractive(
try {
let prefix = suggestTaskPrefix(name);
if (interactive) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const rl = createInterface({ input: process.stdin, output: promptOutputStream() });
const prefixInput = await rl.question(`\n Task prefix [${prefix}]: `);
rl.close();
const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, "");