Skip to content

Root Command.Writer doesn't propagate to subcommand Actions #2325

@coilysiren

Description

@coilysiren

Summary

Setting root.Writer on a top-level *cli.Command does not always propagate to subcommands' Actions in v3. Subcommand Actions read from cmd.Writer (where cmd is the subcommand the Action was attached to), which appears to default to os.Stdout even when the parent's Writer was overridden.

Reproduction

package main

import (
    "bytes"
    "context"
    "fmt"

    "github.com/urfave/cli/v3"
)

func main() {
    var buf bytes.Buffer
    app := &cli.Command{
        Name:   "demo",
        Writer: &buf,
        Commands: []*cli.Command{
            {
                Name: "sub",
                Action: func(_ context.Context, c *cli.Command) error {
                    fmt.Fprintln(c.Writer, "from sub")
                    return nil
                },
            },
        },
    }
    _ = app.Run(context.Background(), []string{"demo", "sub"})
    fmt.Printf("buf=%q\n", buf.String())
    // Observed: buf="" (text went to os.Stdout)
    // Expected: buf="from sub\n"
}

Workaround

I recursively assign Writer and ErrWriter to every command in the tree before invoking Run:

func setWriters(cmd *cli.Command, w io.Writer) {
    cmd.Writer, cmd.ErrWriter = w, w
    for _, sub := range cmd.Commands {
        setWriters(sub, w)
    }
}

This is the workaround in cli-web-ops and cli-mcp (via per-call writer override). It works but is fragile - new subcommands added at runtime would need to be re-walked.

Motivation

Two third-party urfave/cli extensions hit this:

  • cli-mcp captures tool output via cmd.Writer during MCP tool calls.
  • cli-web-ops streams stdout over SSE by setting a pipe as the writer.

In both cases the natural pattern is "set Writer at the root, run the command, read the buffer." The propagation gap forces a recursive walk.

Proposed fix

Either:

  1. Have Action-receiving code resolve cmd.Writer by walking up cmd.parent to the first non-nil Writer (and falling back to os.Stdout). This makes "set Writer on the root" do the obvious thing.
  2. Or document the current behavior clearly in the Command.Writer godoc and bless setWriters-style recursion as the supported pattern.

Happy to send either - just want to confirm intent before submitting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions