urfave / cli

A simple, fast, and fun package for building command line apps in Go
https://cli.urfave.org
MIT License
22.33k stars 1.71k forks source link

Global flag not accessible with many levels of sub-commands #585

Closed vadmeste closed 2 years ago

vadmeste commented 7 years ago

Hello community,

My aim is to have some global flags that can be inserted anywhere. The code below registers the admin command which has a sub-command service which itself has another sub-command called status. Debug flag is global. However, when I type the following command, debug flag is not activated

$ ./binary admin -d service status
status: => local (false), global (false)

The code:

package main

import (
    "fmt"
    "os"
    "strconv"

    "github.com/urfave/cli"
)

func main() {

    globalFlags := []cli.Flag{
        cli.BoolFlag{Name: "debug, d", Usage: "Run in debug mode"},
    }

    adminServiceStatusCmd := cli.Command{
        Name:  "status",
        Flags: append([]cli.Flag{}, globalFlags...),
        Action: func(c *cli.Context) {
            global := strconv.FormatBool(c.GlobalBool("debug"))
            local := strconv.FormatBool(c.Bool("debug"))
            fmt.Printf("%s: => local (%s), global (%s)\n", c.Command.Name, local, global)
        },
    }

    adminServiceCmd := cli.Command{
        Name:        "service",
        Flags:       append([]cli.Flag{}, globalFlags...),
        Subcommands: []cli.Command{adminServiceStatusCmd},
    }

    adminCmd := cli.Command{
        Name:        "admin",
        Flags:       append([]cli.Flag{}, globalFlags...),
        Subcommands: []cli.Command{adminServiceCmd},
    }

    app := cli.NewApp()
    app.Name = "lookup"
    app.Flags = append([]cli.Flag{}, globalFlags...)
    app.Commands = []cli.Command{adminCmd}

    app.Run(os.Args)
}

Is this a bug ? or am I just misunderstanding the global flag concept ?

Thanks,

jszwedko commented 7 years ago

Ouch, definitely feels like a bug.

What's happening in your example is that the flags are being refedined at each level so that c.Bool/c.GlobalBool are going to look at the flag defined at the status level (which is why ... admin service status -d works). Theoretically, you should be able to write code like:

package main

import (
        "fmt"
        "os"
        "strconv"

        "github.com/urfave/cli"
)

func main() {

        globalFlags := []cli.Flag{
                cli.BoolFlag{Name: "debug, d", Usage: "Run in debug mode"},
        }

        adminServiceStatusCmd := cli.Command{
                Name:  "status",
                Action: func(c *cli.Context) {
                        global := strconv.FormatBool(c.GlobalBool("debug"))
                        local := strconv.FormatBool(c.Bool("debug"))
                        fmt.Printf("%s: => local (%s), global (%s)\n", c.Command.Name, local, global)
                },
        }

        adminServiceCmd := cli.Command{
                Name:        "service",
                Subcommands: []cli.Command{adminServiceStatusCmd},
        }

        adminCmd := cli.Command{
                Name:        "admin",
                Subcommands: []cli.Command{adminServiceCmd},
        }

        app := cli.NewApp()
        app.Name = "lookup"
        app.Flags = append([]cli.Flag{}, globalFlags...)
        app.Commands = []cli.Command{adminCmd}

        app.Run(os.Args)
}

Where -d would be valid at any level, but this is not currently supported by the library. In this example ... -d admin service status works, but it doesn't permit the -d flag to be injected in-between the other commands as it should.

jszwedko commented 7 years ago

Far from ideal, but you can do something like:

package main

import (
        "fmt"
        "os"

        "github.com/urfave/cli"
)

var debug = false

func setDebug(c *cli.Context) error {
        if c.IsSet("debug") {
                debug = true
        }
        return nil
}

func main() {

        globalFlags := []cli.Flag{
                cli.BoolFlag{Name: "debug, d", Usage: "Run in debug mode"},
        }

        adminServiceStatusCmd := cli.Command{
                Name:   "status",
                Before: setDebug,
                Flags:  append([]cli.Flag{}, globalFlags...),
                Action: func(c *cli.Context) {
                        fmt.Printf("%s\n", debug)
                },
        }

        adminServiceCmd := cli.Command{
                Name:        "service",
                Before:      setDebug,
                Flags:       append([]cli.Flag{}, globalFlags...),
                Subcommands: []cli.Command{adminServiceStatusCmd},
        }

        adminCmd := cli.Command{
                Name:        "admin",
                Before:      setDebug,
                Flags:       append([]cli.Flag{}, globalFlags...),
                Subcommands: []cli.Command{adminServiceCmd},
        }

        app := cli.NewApp()
        app.Name = "lookup"
        app.Flags = append([]cli.Flag{}, globalFlags...)
        app.Commands = []cli.Command{adminCmd}
        app.Before = setDebug

        app.Run(os.Args)
}

as a workaround (or use c.App.Metadata["debug"] rather than a global debug variable).

I took a brief look and solving this one unfortunately doesn't appear very straight forward. I think we'll need to curry along the "global" flagset to each subcommand that includes the app flagset as well as any flags defined on its parent.

enrichman commented 5 years ago

Pretty old issue but is still this the only way to handle a global flag under different levels of commands/subcommands?

coilysiren commented 5 years ago

The example above is probably still true! I would be very much in favor of someone creating a PR to make this better 🙏

stale[bot] commented 4 years ago

This issue or PR has been automatically marked as stale because it has not had recent activity. Please add a comment bumping this if you're still interested in it's resolution! Thanks for your help, please let us know if you need anything else.

coilysiren commented 4 years ago

I think this issue is fixed as of v2?

stale[bot] commented 4 years ago

This issue or PR has been bumped and is no longer marked as stale! Feel free to bump it again in the future, if it's still relevant.

stale[bot] commented 4 years ago

This issue or PR has been automatically marked as stale because it has not had recent activity. Please add a comment bumping this if you're still interested in it's resolution! Thanks for your help, please let us know if you need anything else.

AndreasBackx commented 4 years ago

Could this perhaps be documented? Took me some time to figure out this behaviour. Many cli libraries seem to be having problems with this. Thanks for the workaround though. 🙂

Edit: on further inspection. This seems to be very finicky though. It is very sensitive to the placement of the global flags.

stale[bot] commented 4 years ago

This issue or PR has been bumped and is no longer marked as stale! Feel free to bump it again in the future, if it's still relevant.

coilysiren commented 4 years ago

Could this perhaps be documented?

@AndreasBackx this issue is currently marked as help wanted and available for anyone who wants to add documentation about it

stale[bot] commented 4 years ago

This issue or PR has been automatically marked as stale because it has not had recent activity. Please add a comment bumping this if you're still interested in it's resolution! Thanks for your help, please let us know if you need anything else.

stale[bot] commented 4 years ago

Closing this as it has become stale.

jalavosus commented 2 years ago

@AndreasBackx I know I'm necrobumping this and I hope you've found some workaround, but for other devs who are seeing this and thinking of XKCD 979, here's something I've cobbled together which actually works for finding flag values from variously nested levels of cli.Contexts.


import (
    "time"

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

func flagExistsInContext(c *cli.Context, flagName string) bool {
    for _, f := range c.LocalFlagNames() {
        if f == flagName {
            return true
        }
    }

    return false
}

func contextWithFlag(c *cli.Context, flagName string) (*cli.Context, bool) {
    var (
        ctx = c
        ok  = false
    )

    lineage := c.Lineage()
    if len(lineage) == 1 {
        return c, flagExistsInContext(c, flagName)
    }

    for i := range lineage {
        if flagExistsInContext(lineage[i], flagName) {
            ctx = lineage[i]
            ok = true
            break
        }
    }

    return ctx, ok
}

func GetInt64Slice(c *cli.Context, flagName string) (val []int64) {
    flagCtx, ok := contextWithFlag(c, flagName)
    if ok {
        val = flagCtx.Int64Slice(flagName)
    } else {
        val = make([]int64, 0)
    }

    return
}

func GetStringSlice(c *cli.Context, flagName string) (val []string) {
    flagCtx, ok := contextWithFlag(c, flagName)
    if ok {
        val = flagCtx.StringSlice(flagName)
    } else {
        val = make([]string, 0)
    }

    return
}

func GetString(c *cli.Context, flagName string, defaultValue ...string) (val string) {
    flagCtx, ok := contextWithFlag(c, flagName)
    if ok {
        val = flagCtx.String(flagName)
    } else if len(defaultValue) > 0 {
        val = defaultValue[0]
    }

    return
}

func GetInt64(c *cli.Context, flagName string, defaultValue ...int64) (val int64) {
    flagCtx, ok := contextWithFlag(c, flagName)
    if ok {
        val = flagCtx.Int64(flagName)
    } else if len(defaultValue) > 0 {
        val = defaultValue[0]
    }

    return
}

func GetUint64(c *cli.Context, flagName string, defaultValue ...uint64) (val uint64) {
    flagCtx, ok := contextWithFlag(c, flagName)
    if ok {
        val = flagCtx.Uint64(flagName)
    } else if len(defaultValue) > 0 {
        val = defaultValue[0]
    }

    return
}

func GetDuration(c *cli.Context, flagName string, defaultValue ...time.Duration) (val time.Duration) {
    flagCtx, ok := contextWithFlag(c, flagName)
    if ok {
        val = flagCtx.Duration(flagName)
    } else if len(defaultValue) > 0 {
        val = defaultValue[0]
    }

    return
}

func GetBool(c *cli.Context, flagName string, defaultValue ...bool) (val bool) {
    flagCtx, ok := contextWithFlag(c, flagName)
    if ok {
        val = flagCtx.Bool(flagName)
    } else if len(defaultValue) > 0 {
        val = defaultValue[0]
    }

    return
}

func GetPath(c *cli.Context, flagName string, defaultValue ...string) (val string) {
    flagCtx, ok := contextWithFlag(c, flagName)
    if ok {
        val = flagCtx.Path(flagName)
    } else if (len(defaultValue)) > 0 {
        val = defaultValue[0]
    }

    return
}
dearchap commented 2 years ago

This behaviour is not present in v2

package main

import (
    "fmt"
    "log"
    "os"

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

func main() {
    app := cli.NewApp()
    app.Name = "myprogramname"
    app.Action = func(c *cli.Context) error {
        fmt.Println("c.App.Name for app.Action is", c.App.Name)
        return nil
    }
    app.Flags = []cli.Flag{
        &cli.Int64Flag{
            Name:  "myi",
            Value: 10,
        },
    }
    app.Commands = []*cli.Command{
        {
            Name: "foo",
            Action: func(c *cli.Context) error {
                fmt.Println("c.App.Name for app.Commands.Action is", c.App.Name)
                return nil
            },
            Subcommands: []*cli.Command{
                {
                    Name: "bar",
                    /*Before: func(c *cli.Context) error {
                        return fmt.Errorf("before error")
                    },*/
                    Action: func(c *cli.Context) error {
                        log.Printf("%v", c.Int64("myi"))
                        fmt.Println("c.App.Name for App.Commands.Subcommands.Action is", c.App.Name)
                        return nil
                    },
                },
            },
        },
    }

    err := app.Run(os.Args)
    if err != nil {
        log.Fatal()
    }
}
$ go run main.go foo bar
2022/10/21 18:06:57 10
c.App.Name for App.Commands.Subcommands.Action is myprogramname
$ go run main.go -myi 11 foo bar
2022/10/21 18:07:06 11
c.App.Name for App.Commands.Subcommands.Action is myprogramname

Since there are workaround for v1 I am closing this issue