Skip to content

feat(installer): stream subprocess output during install/update with -v#20

Merged
kevinelliott merged 1 commit intomainfrom
feat/install-progress-streaming
Apr 21, 2026
Merged

feat(installer): stream subprocess output during install/update with -v#20
kevinelliott merged 1 commit intomainfrom
feat/install-progress-streaming

Conversation

@kevinelliott
Copy link
Copy Markdown
Owner

Summary

Addresses the "sits silent for minutes" UX when agent install or agent update runs a long-lived subprocess (brew upgrade, npm install -g of a large package, uv tool install, etc.). Opt-in via the existing -v/--verbose flag so default output stays compact.

Changes

Provider layer

  • New pkg/installer/providers/progress.go: WithProgressWriter(ctx, io.Writer) attaches a writer to a context; ProgressWriter(ctx) 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

  • verboseInstallOutput(cfg) checks the root-level -v/--verbose flag (the root command already maps it to cfg.Logging.Level=\"debug\").
  • 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 headers followed by the live subprocess output, then a summary line.
  • Default behavior (no -v) is unchanged.

Demo

# before — no output for 30+s during a big brew upgrade
$ agentmgr agent update aider
⠙ Updating aider via brew...
✓ Updated aider to 0.86.2

# after — with -v, live brew output:
$ agentmgr agent update -v aider
Updating aider via brew...
==> Fetching aider
==> Downloading https://...
######################################################################## 100.0%
==> Pouring aider-0.86.2.arm64_sequoia.bottle.tar.gz
🍺  /opt/homebrew/Cellar/aider/0.86.2: 234 files, 17.6MB
Updated aider to 0.86.2

Tests

  • pkg/installer/providers/progress_test.go: round-trip, default-discard, and nil-writer-is-noop invariants.

Test plan

  • go build ./... clean
  • go test ./... -race -short green
  • make lint clean (v1.64.8)
  • Manual: agentmgr agent install -v codex streams npm output
  • Manual: agentmgr agent update -v --all streams per-agent output
  • Manual: default agentmgr agent update --all unchanged (spinner only)

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings April 21, 2026 08:29
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and native providers to stream output via io.MultiWriter while 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.

Comment on lines +13 to +23
// 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
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +34
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
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
@kevinelliott kevinelliott merged commit 3702171 into main Apr 21, 2026
19 checks passed
@kevinelliott kevinelliott deleted the feat/install-progress-streaming branch April 21, 2026 08:39
kevinelliott added a commit that referenced this pull request Apr 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants