feat(installer): stream subprocess output during install/update with -v#20
Conversation
Addresses the "sits silent for minutes" UX during long-running installs and updates (brew upgrade, npm install -g of a large package, uv tool install, etc.). Provider layer - New pkg/installer/providers/progress.go: WithProgressWriter attaches an io.Writer to a ctx; ProgressWriter reads it back (io.Discard if unset). Keeps the knob decoupled from every provider's signature. - npm, brew, pip, and native providers: Install + Update now tee subprocess stdout/stderr through the context writer via io.MultiWriter. Result.Output (full captured output) is preserved exactly, so non-streaming callers see no change. CLI layer - internal/cli/agent.go: verboseInstallOutput(cfg) checks the root-level -v/--verbose flag (which the root command already maps to cfg.Logging.Level="debug"), and withInstallProgress(ctx, cfg) attaches os.Stderr as the writer when enabled. - agent install, agent update (single), agent update --all: when -v is on, we suppress the rolling spinner (ANSI would garble the interleaved output) and print plain "Installing X..." / "Updating X..." headers followed by the live subprocess output, then a final summary line. Default behavior (no -v) is unchanged. Tests - pkg/installer/providers/progress_test.go: round-trip, default-discard, and nil-writer-is-noop guarantees. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds opt-in live streaming of installer subprocess output (stdout/stderr) during agent install / agent update when -v/--verbose is enabled, improving UX for long-running installs/updates while preserving existing captured output behavior.
Changes:
- Introduces a context-carried progress writer (
WithProgressWriter/ProgressWriter) for providers to tee subprocess output. - Updates
npm,brew,pip, andnativeproviders to stream output viaio.MultiWriterwhile still capturing full output. - Updates CLI install/update flows to disable spinners in verbose mode and print plain headers + streamed output.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/installer/providers/progress.go | Adds context helpers to attach/retrieve a progress writer for streaming output. |
| pkg/installer/providers/progress_test.go | Adds unit tests for progress writer round-trips and defaults. |
| pkg/installer/providers/pip.go | Tees pip/pipx/uv subprocess output to the context progress writer. |
| pkg/installer/providers/npm.go | Tees npm subprocess output to the context progress writer. |
| pkg/installer/providers/brew.go | Tees brew subprocess output to the context progress writer. |
| pkg/installer/providers/native.go | Tees native shell command output to the context progress writer. |
| internal/cli/agent.go | Enables streaming to os.Stderr under verbose mode and adjusts spinner behavior accordingly. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // WithProgressWriter returns a new context that carries the given writer. | ||
| // Providers use it to tee the subprocess stdout/stderr while they run, so | ||
| // callers can surface live output for long-running installs or updates. | ||
| // | ||
| // A nil or zero writer disables streaming (provider behaves as before). | ||
| // Providers always still capture the full output into Result.Output, so | ||
| // callers that ignore the stream lose no information. | ||
| func WithProgressWriter(ctx context.Context, w io.Writer) context.Context { | ||
| if w == nil { | ||
| return ctx | ||
| } |
There was a problem hiding this comment.
The doc comment says "A nil or zero writer disables streaming", but WithProgressWriter treats nil as a no-op (it returns the original context and does not override a previously attached writer). Please update the comment to match the actual semantics (e.g., "nil writer is ignored"), and if you intend to support disabling, provide an explicit mechanism (e.g., WithProgressWriter(ctx, io.Discard) or a dedicated ClearProgressWriter).
| func WithProgressWriter(ctx context.Context, w io.Writer) context.Context { | ||
| if w == nil { | ||
| return ctx | ||
| } | ||
| return context.WithValue(ctx, progressWriterKey{}, w) | ||
| } | ||
|
|
||
| // ProgressWriter returns the writer associated with the context, or | ||
| // io.Discard if none was set. Callers should use the returned writer | ||
| // unconditionally. | ||
| func ProgressWriter(ctx context.Context) io.Writer { | ||
| if w, ok := ctx.Value(progressWriterKey{}).(io.Writer); ok && w != nil { | ||
| return w | ||
| } | ||
| return io.Discard |
There was a problem hiding this comment.
ProgressWriter(ctx) can be used by providers to tee both cmd.Stdout and cmd.Stderr. os/exec writes stdout and stderr concurrently, so the same progress writer may receive concurrent Write calls. Since WithProgressWriter accepts any io.Writer, this can cause data races or corruption for non-thread-safe writers (e.g., bytes.Buffer). Consider wrapping the provided writer in a small mutex-protected writer inside WithProgressWriter (or documenting/enforcing that the writer must be safe for concurrent use).
Promote the Unreleased section to 1.2.0 and capture the UX / operability work landed since v1.1.0: - #20 install/update progress streaming with -v - #21 gRPC server wire-up in helper - #22, #26 test coverage (cli/output 0→82%, systray 0.2→3.2%) - #24 RefreshOnStart field removal - #27 bubbles v0.21 -> v1.0 - #28 go:embed baseline catalog (fresh `go install` works offline) - #29 pkg/logging slog wrapper wired into main and grpc recovery Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Addresses the "sits silent for minutes" UX when
agent installoragent updateruns a long-lived subprocess (brew upgrade,npm install -gof a large package,uv tool install, etc.). Opt-in via the existing-v/--verboseflag so default output stays compact.Changes
Provider layer
pkg/installer/providers/progress.go:WithProgressWriter(ctx, io.Writer)attaches a writer to a context;ProgressWriter(ctx)reads it back (io.Discardif unset). Keeps the knob decoupled from every provider's signature.npm,brew,pip, andnativeproviders: Install + Update now tee subprocess stdout/stderr through the context writer viaio.MultiWriter.Result.Output(full captured output) is preserved exactly, so non-streaming callers see no change.CLI layer
verboseInstallOutput(cfg)checks the root-level-v/--verboseflag (the root command already maps it tocfg.Logging.Level=\"debug\").withInstallProgress(ctx, cfg)attachesos.Stderras the writer when enabled.agent install,agent update(single),agent update --all: when-vis on, we suppress the rolling spinner (ANSI would garble the interleaved output) and print plain headers followed by the live subprocess output, then a summary line.-v) is unchanged.Demo
Tests
pkg/installer/providers/progress_test.go: round-trip, default-discard, and nil-writer-is-noop invariants.Test plan
go build ./...cleango test ./... -race -shortgreenmake lintclean (v1.64.8)agentmgr agent install -v codexstreams npm outputagentmgr agent update -v --allstreams per-agent outputagentmgr agent update --allunchanged (spinner only)🤖 Generated with Claude Code