From 7aba20e32749f8dfd0699093140b3202eea1a3f9 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Mon, 23 Mar 2026 01:26:34 -0700 Subject: [PATCH] fix(ux): deduplicate install messages, add newlines to SSH polling, clarify completion messages (#2900) - Suppress stdout+stderr from `claude install --force` to prevent duplicate "successfully installed" messages (was printed up to 4x) - Make logStepInline fall back to newline-separated output when stderr is not a TTY, so SSH port polling status is readable in piped/captured contexts - Consolidate post-install completion messages into a single clear milestone: "Agent setup complete -- {agent} is ready on {cloud}" - Bump CLI version to 0.25.16 Fixes #2899 Agent: ux-engineer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/ui-cov.test.ts | 10 ++++++---- packages/cli/src/shared/agent-setup.ts | 4 ++-- packages/cli/src/shared/orchestrate.ts | 4 +--- packages/cli/src/shared/ui.ts | 13 ++++++++++--- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 87a08a6a..df57e8b1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.25.15", + "version": "0.25.16", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/ui-cov.test.ts b/packages/cli/src/__tests__/ui-cov.test.ts index 64f05e66..9b4e97bc 100644 --- a/packages/cli/src/__tests__/ui-cov.test.ts +++ b/packages/cli/src/__tests__/ui-cov.test.ts @@ -85,17 +85,19 @@ describe("logging functions", () => { expect(stderrOutput.join("")).toContain("test step"); }); - it("logStepInline writes without newline", () => { + it("logStepInline writes message (newline-terminated in non-TTY)", () => { logStepInline("inline msg"); const output = stderrOutput.join(""); expect(output).toContain("inline msg"); - expect(output).not.toEndWith("\n"); + // In non-TTY (test environment), output ends with newline instead of \r overwrite + expect(output).toEndWith("\n"); }); - it("logStepDone clears the line", () => { + it("logStepDone is no-op in non-TTY", () => { logStepDone(); const output = stderrOutput.join(""); - expect(output).toContain("\r"); + // In non-TTY (test environment), logStepDone writes nothing + expect(output).toBe(""); }); it("logDebug only outputs when SPAWN_DEBUG=1", () => { diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 536255a7..52df9310 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -102,7 +102,7 @@ async function installClaudeCode(runner: CloudRunner): Promise { const claudePath = "$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$HOME/.n/bin"; const pathSetup = `for rc in ~/.bashrc ~/.profile ~/.bash_profile ~/.zshrc; do grep -q '.claude/local/bin' "$rc" 2>/dev/null || printf '\\n# Claude Code PATH\\nexport PATH="$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH"\\n' >> "$rc"; done`; - const finalize = `claude install --force 2>/dev/null || true; ${pathSetup}`; + const finalize = `claude install --force >/dev/null 2>&1 || true; ${pathSetup}`; const script = [ `export PATH="${claudePath}:$PATH"`, @@ -125,7 +125,7 @@ async function installClaudeCode(runner: CloudRunner): Promise { logError("Claude Code installation failed"); throw new Error("Claude Code install failed"); } - logInfo("Claude Code installed"); + logInfo("Claude Code agent installed successfully"); } async function setupClaudeCodeConfig(runner: CloudRunner, apiKey: string): Promise { diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 0fe2fc93..32f1c9ad 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -532,9 +532,7 @@ async function postInstall( } // Launch agent - logInfo(`${agent.name} is ready`); - process.stderr.write("\n"); - logInfo(`${cloud.cloudLabel} setup completed successfully!`); + logInfo(`Agent setup complete — ${agent.name} is ready on ${cloud.cloudLabel}`); process.stderr.write("\n"); const launchCmd = agent.launchCmd(); diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index 7ac5c83e..e68ee599 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -38,14 +38,21 @@ export function logStep(msg: string): void { process.stderr.write(`${CYAN}${msg}${NC}\n`); } -/** Overwrite the current line with a status message (no newline). Call logStepDone() when finished. */ +/** Overwrite the current line with a status message (no newline). Call logStepDone() when finished. + * Falls back to newline-separated output when stderr is not a TTY (e.g., piped or captured). */ export function logStepInline(msg: string): void { - process.stderr.write(`\r${CYAN}${msg}${NC}\x1b[K`); + if (process.stderr.isTTY) { + process.stderr.write(`\r${CYAN}${msg}${NC}\x1b[K`); + } else { + process.stderr.write(`${CYAN}${msg}${NC}\n`); + } } /** End an inline status line by moving to the next line. */ export function logStepDone(): void { - process.stderr.write("\r\x1b[K"); + if (process.stderr.isTTY) { + process.stderr.write("\r\x1b[K"); + } } /** Prompt for a line of user input. Throws if non-interactive.