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:
- 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.
- 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.
Summary
Setting
root.Writeron a top-level*cli.Commanddoes not always propagate to subcommands' Actions in v3. Subcommand Actions read fromcmd.Writer(wherecmdis the subcommand the Action was attached to), which appears to default toos.Stdouteven when the parent'sWriterwas overridden.Reproduction
Workaround
I recursively assign
WriterandErrWriterto every command in the tree before invokingRun: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:
cmd.Writerduring MCP tool calls.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:
Action-receiving code resolvecmd.Writerby walking upcmd.parentto the first non-nil Writer (and falling back toos.Stdout). This makes "set Writer on the root" do the obvious thing.Command.Writergodoc and blesssetWriters-style recursion as the supported pattern.Happy to send either - just want to confirm intent before submitting.