// Package cmd is the kettle command tree. // // Commands are values, not init() side effects on a framework: each one carries // the metadata a human needs (what it does, what it takes, worked examples) in // the same struct that carries the code. That is deliberate — the skills kettle // writes into a project are generated from this list, so a command whose flags // changed cannot ship with documentation that says otherwise. // // The tree is flat. `kettle new`, not `kettle issue new`: an agent pays for // every token of every invocation, and the grouping that matters for reading is // carried in Group and only shows up in the docs. package cmd import ( "flag" "fmt" "os" "sort" "strings" ) // Groups, in the order they are presented. They name the layer a command // belongs to, which is the one thing a reader has to keep straight: the domain // works offline and the tracker does not exist to it. const ( GroupProject = "project" GroupIssue = "issue" GroupSync = "sync" GroupAPI = "api" ) var groupOrder = []string{GroupProject, GroupIssue, GroupSync, GroupAPI} var groupBlurb = map[string]string{ GroupProject: "the project itself", GroupIssue: "issues as units of work — offline, no tracker involved", GroupSync: "moving issues between the store and the tracker", GroupAPI: "everything else Gitea has, reached directly — not issues", } // Example is one worked invocation. Both halves are shown in help and in the // generated skill docs. type Example struct { Cmd string What string } // Command is one verb. type Command struct { // Name is what the user types. Name string // Group is the layer it belongs to; documentation only. Group string // Args is the positional-argument spec, e.g. " […]". Args string // Short is one line, shown in the command list. Short string // Long is the full explanation, shown by `kettle help `. Long string // Examples are worked invocations. Examples []Example // Setup registers this command's flags on fs and returns the function that // runs it, closing over them. Splitting it this way lets the doc generator // walk the flags without running anything. Setup func(fs *flag.FlagSet) func(args []string) error } var registry []*Command func register(c *Command) { registry = append(registry, c) } // Commands lists every command, sorted by group and then by name. func Commands() []*Command { out := append([]*Command{}, registry...) sort.SliceStable(out, func(i, j int) bool { gi, gj := groupIndex(out[i].Group), groupIndex(out[j].Group) if gi != gj { return gi < gj } return out[i].Name < out[j].Name }) return out } // Lookup finds a command by name. func Lookup(name string) *Command { for _, c := range registry { if c.Name == name { return c } } return nil } // Flags returns this command's flags without running it — what the doc // generator walks. func (c *Command) Flags() []*flag.Flag { fs := flag.NewFlagSet(c.Name, flag.ContinueOnError) fs.SetOutput(discard{}) c.Setup(fs) var out []*flag.Flag fs.VisitAll(func(f *flag.Flag) { out = append(out, f) }) return out } // Usage is the one-line synopsis. func (c *Command) Usage() string { s := "kettle " + c.Name if c.Args != "" { s += " " + c.Args } return s } // SilentError carries an exit status for a command that has already said // everything it has to say. `check` uses it: findings went to stdout and a // second copy on stderr would be noise. type SilentError struct{ Code int } func (e SilentError) Error() string { return "" } // Fail is the error every command returns for an ordinary failure. Main // prefixes it with the command name. func Fail(format string, a ...any) error { return fmt.Errorf(format, a...) } // Main runs argv (without the program name) and returns the exit status. func Main(argv []string) int { if len(argv) == 0 { printUsage(os.Stdout) return 0 } name := argv[0] switch name { case "help", "-h", "--help": if len(argv) > 1 { c := Lookup(argv[1]) if c == nil { fmt.Fprintf(os.Stderr, "kettle: no command %q\n", argv[1]) return 2 } printCommand(os.Stdout, c) return 0 } printUsage(os.Stdout) return 0 } c := Lookup(name) if c == nil { fmt.Fprintf(os.Stderr, "kettle: no command %q — try `kettle help`\n", name) return 2 } fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.Usage = func() { printCommand(os.Stderr, c) } run := c.Setup(fs) if err := fs.Parse(permute(fs, argv[1:])); err != nil { if err == flag.ErrHelp { return 0 } return 2 } switch err := run(fs.Args()).(type) { case nil: return 0 case SilentError: return err.Code default: fmt.Fprintf(os.Stderr, "kettle %s: %v\n", name, err) return 1 } } func printUsage(w *os.File) { fmt.Fprint(w, "kettle — issues as local markdown, and the tracker they sync with\n\n") fmt.Fprint(w, "usage: kettle [flags] [args]\n") current := "" for _, c := range Commands() { if c.Group != current { current = c.Group fmt.Fprintf(w, "\n%s — %s\n", current, groupBlurb[current]) } fmt.Fprintf(w, " %-11s %s\n", c.Name, c.Short) } fmt.Fprint(w, "\n`kettle help ` for one command in full.\n") } func printCommand(w *os.File, c *Command) { fmt.Fprintf(w, "%s\n\n%s\n", c.Usage(), c.Short) if c.Long != "" { fmt.Fprintf(w, "\n%s\n", strings.TrimSpace(c.Long)) } if flags := c.Flags(); len(flags) > 0 { fmt.Fprint(w, "\nflags:\n") for _, f := range flags { name := "--" + f.Name if f.DefValue != "" && f.DefValue != "false" { name += "=" + f.DefValue } fmt.Fprintf(w, " %-22s %s\n", name, f.Usage) } } if len(c.Examples) > 0 { fmt.Fprint(w, "\nexamples:\n") for _, e := range c.Examples { fmt.Fprintf(w, " %s\n %s\n", e.Cmd, e.What) } } } // permute moves flags ahead of positional arguments. // // The standard flag package stops parsing at the first non-flag argument, so // `kettle ac --check 3` would hand --check to the command as a positional // and tick nothing. Every other CLI an operator uses interleaves the two, and // a tool that silently ignores a flag because of where it was typed is worse // than one that rejects it. // // A flag that takes a value swallows the next argument, which is why this needs // the FlagSet: only the set knows whether --check wants one. `--` ends the // permutation, and everything after it is positional whatever it looks like. func permute(fs *flag.FlagSet, args []string) []string { var flags, positional []string for i := 0; i < len(args); i++ { a := args[i] if a == "--" { positional = append(positional, args[i+1:]...) break } if len(a) < 2 || a[0] != '-' { positional = append(positional, a) continue } flags = append(flags, a) if strings.Contains(a, "=") { continue } f := fs.Lookup(strings.TrimLeft(a, "-")) // An unknown flag consumes nothing; Parse will reject it by name in a // moment, which is a better message than one about its value. if f == nil || isBoolFlag(f.Value) { continue } if i+1 < len(args) { i++ flags = append(flags, args[i]) } } return append(flags, positional...) } func isBoolFlag(v flag.Value) bool { b, ok := v.(interface{ IsBoolFlag() bool }) return ok && b.IsBoolFlag() } func groupIndex(g string) int { for i, name := range groupOrder { if name == g { return i } } return len(groupOrder) } type discard struct{} func (discard) Write(p []byte) (int, error) { return len(p), nil }