diff --git a/cmd/agent_context.go b/cmd/agent_context.go new file mode 100644 index 0000000..4a4bcd5 --- /dev/null +++ b/cmd/agent_context.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// cobra stores flag-group constraints in cmd.Annotations under these keys. +// the keys arent exported in cobra/flag_groups.go, so we hardcode the +// literal strings. each value is a []string where every element is one +// group with member flag names joined by a single space. +const ( + annotOneRequired = "cobra_annotation_one_required" + annotMutuallyExclusive = "cobra_annotation_mutually_exclusive" + annotRequiredTogether = "cobra_annotation_required_if_others_set" +) + +type AgentContext struct { + Name string `json:"name"` + Version string `json:"version"` + Commit string `json:"commit"` + Short string `json:"short"` + Long string `json:"long"` + GlobalFlags []Flag `json:"globalFlags"` + Commands []Command `json:"commands"` +} + +type Command struct { + Name string `json:"name"` + Path []string `json:"path"` + Use string `json:"use"` + Short string `json:"short"` + Long string `json:"long"` + Hidden bool `json:"hidden"` + Runnable bool `json:"runnable"` + Args []Positional `json:"args"` + Flags []Flag `json:"flags"` + FlagGroups FlagGroups `json:"flagGroups"` + Commands []Command `json:"commands"` +} + +type Flag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand"` + Type string `json:"type"` + Default string `json:"default"` + Description string `json:"description"` + Required bool `json:"required"` + Persistent bool `json:"persistent"` +} + +type FlagGroups struct { + OneRequired [][]string `json:"oneRequired"` + MutuallyExclusive [][]string `json:"mutuallyExclusive"` + RequiredTogether [][]string `json:"requiredTogether"` +} + +func buildAgentContext(root *cobra.Command) AgentContext { + rootPersistent := persistentFlagNames(root) + return AgentContext{ + Name: root.Name(), + Version: version, + Commit: commit, + Short: root.Short, + Long: root.Long, + GlobalFlags: collectRootPersistentFlags(root), + Commands: walkChildren(root, []string{}, rootPersistent), + } +} + +func persistentFlagNames(cmd *cobra.Command) map[string]struct{} { + out := map[string]struct{}{} + cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { out[f.Name] = struct{}{} }) + return out +} + +func collectRootPersistentFlags(root *cobra.Command) []Flag { + out := []Flag{} + root.PersistentFlags().VisitAll(func(f *pflag.Flag) { + out = append(out, flagFromPFlag(f, true)) + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func walkChildren(parent *cobra.Command, parentPath []string, rootPersistent map[string]struct{}) []Command { + out := []Command{} + for _, c := range parent.Commands() { + // filter out help, completion, man commands that are added by cobra and fang + switch c.Name() { + case "help", "completion", "man": + continue + } + path := append(append([]string{}, parentPath...), c.Name()) + out = append(out, Command{ + Name: c.Name(), + Path: path, + Use: c.Use, + Short: c.Short, + Long: c.Long, + Hidden: c.Hidden, + Runnable: c.RunE != nil || c.Run != nil, + Args: parsePositionals(c.Use), + Flags: walkLocalFlags(c, rootPersistent), + FlagGroups: extractFlagGroups(c), + Commands: walkChildren(c, path, rootPersistent), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func walkLocalFlags(cmd *cobra.Command, rootPersistent map[string]struct{}) []Flag { + out := []Flag{} + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Name == "help" { + return + } + if _, skip := rootPersistent[f.Name]; skip { + return + } + persistent := cmd.PersistentFlags().Lookup(f.Name) != nil + out = append(out, flagFromPFlag(f, persistent)) + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func flagFromPFlag(f *pflag.Flag, persistent bool) Flag { + _, required := f.Annotations[cobra.BashCompOneRequiredFlag] + return Flag{ + Name: f.Name, + Shorthand: f.Shorthand, + Type: f.Value.Type(), + Default: f.DefValue, + Description: f.Usage, + Required: required, + Persistent: persistent, + } +} + +func extractFlagGroups(cmd *cobra.Command) FlagGroups { + groups := FlagGroups{ + OneRequired: [][]string{}, + MutuallyExclusive: [][]string{}, + RequiredTogether: [][]string{}, + } + // each flag in a group carries the same annotation value. dedupe by raw + // joined string before splitting back into a name slice. + seen := map[string]map[string]struct{}{ + annotOneRequired: {}, + annotMutuallyExclusive: {}, + annotRequiredTogether: {}, + } + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + for _, key := range []string{annotOneRequired, annotMutuallyExclusive, annotRequiredTogether} { + for _, raw := range f.Annotations[key] { + if _, dup := seen[key][raw]; dup { + continue + } + seen[key][raw] = struct{}{} + names := strings.Split(raw, " ") + switch key { + case annotOneRequired: + groups.OneRequired = append(groups.OneRequired, names) + case annotMutuallyExclusive: + groups.MutuallyExclusive = append(groups.MutuallyExclusive, names) + case annotRequiredTogether: + groups.RequiredTogether = append(groups.RequiredTogether, names) + } + } + } + }) + for _, g := range []*[][]string{&groups.OneRequired, &groups.MutuallyExclusive, &groups.RequiredTogether} { + sort.Slice(*g, func(i, j int) bool { + return strings.Join((*g)[i], " ") < strings.Join((*g)[j], " ") + }) + } + return groups +} + +var agentContextCmd = &cobra.Command{ + Use: "agent-context", + Short: "Print a JSON description of all CLI commands and flags", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return printJSON(cmd.OutOrStdout(), buildAgentContext(rootCmd)) + }, +} + +func init() { + rootCmd.AddCommand(agentContextCmd) +} diff --git a/cmd/agent_context_test.go b/cmd/agent_context_test.go new file mode 100644 index 0000000..15f52e8 --- /dev/null +++ b/cmd/agent_context_test.go @@ -0,0 +1,288 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestBuildAgentContext_TopLevel(t *testing.T) { + ctx := buildAgentContext(rootCmd) + + if ctx.Name != "loops" { + t.Errorf("name = %q, want %q", ctx.Name, "loops") + } + if ctx.Short == "" { + t.Error("short should not be empty") + } + if ctx.Long == "" { + t.Error("long should not be empty") + } + + names := agentFlagNames(ctx.GlobalFlags) + for _, want := range []string{"debug", "output", "team"} { + if !agentSliceContains(names, want) { + t.Errorf("globalFlags missing %q (got %v)", want, names) + } + } +} + +func TestBuildAgentContext_TreeInvariants(t *testing.T) { + ctx := buildAgentContext(rootCmd) + walkAgentCommands(ctx.Commands, func(c Command) { + if c.Name == "" { + t.Errorf("empty name at path %v", c.Path) + } + if len(c.Path) == 0 { + t.Errorf("empty path for command %q", c.Name) + } else if c.Path[len(c.Path)-1] != c.Name { + t.Errorf("path %v doesn't end with name %q", c.Path, c.Name) + } + if c.Name == "help" || c.Name == "completion" { + t.Errorf("auto-injected command leaked: %v", c.Path) + } + if findAgentFlag(c.Flags, "help") != nil { + t.Errorf("--help flag leaked into %v", c.Path) + } + }) +} + +func TestBuildAgentContext_Deterministic(t *testing.T) { + a, err := json.Marshal(buildAgentContext(rootCmd)) + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(buildAgentContext(rootCmd)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, b) { + t.Error("output is not byte-deterministic across calls") + } +} + +func TestBuildAgentContext_ContactsCreate(t *testing.T) { + ctx := buildAgentContext(rootCmd) + create := findAgentCommand(ctx.Commands, "contacts", "create") + if create == nil { + t.Fatal("contacts create not found") + } + email := findAgentFlag(create.Flags, "email") + if email == nil { + t.Fatal("contacts create --email not found") + } + if !email.Required { + t.Error("contacts create --email should be required") + } +} + +func TestBuildAgentContext_DepthThree(t *testing.T) { + ctx := buildAgentContext(rootCmd) + check := findAgentCommand(ctx.Commands, "contacts", "suppression", "check") + if check == nil { + t.Fatal("contacts suppression check not found") + } + if findAgentFlag(check.Flags, "email") == nil { + t.Error("contacts suppression check missing --email") + } + if findAgentFlag(check.Flags, "user-id") == nil { + t.Error("contacts suppression check missing --user-id") + } +} + +func TestBuildAgentContext_FlagGroups(t *testing.T) { + ctx := buildAgentContext(rootCmd) + upd := findAgentCommand(ctx.Commands, "email-messages", "update") + if upd == nil { + t.Fatal("email-messages update not found") + } + if !groupListContains(upd.FlagGroups.MutuallyExclusive, []string{"lmx", "lmx-file"}) { + t.Errorf("email-messages update missing mutex group [lmx lmx-file]; got %v", + upd.FlagGroups.MutuallyExclusive) + } + if len(upd.FlagGroups.OneRequired) != 1 { + t.Errorf("email-messages update should have exactly 1 oneRequired group; got %v", + upd.FlagGroups.OneRequired) + } +} + +func TestBuildAgentContext_NoRootPersistentLeak(t *testing.T) { + ctx := buildAgentContext(rootCmd) + rootPersistent := []string{"output", "team", "debug"} + walkAgentCommands(ctx.Commands, func(c Command) { + for _, name := range rootPersistent { + if findAgentFlag(c.Flags, name) != nil { + t.Errorf("root persistent flag %q leaked into %v", name, c.Path) + } + } + }) +} + +func TestExtractFlagGroups_AllAnnotationKeys(t *testing.T) { + cmd := &cobra.Command{Use: "synth"} + for _, name := range []string{"a", "b", "c", "d", "e", "f"} { + cmd.Flags().String(name, "", "") + } + cmd.MarkFlagsOneRequired("a", "b") + cmd.MarkFlagsMutuallyExclusive("c", "d") + cmd.MarkFlagsRequiredTogether("e", "f") + + groups := extractFlagGroups(cmd) + if !groupListContains(groups.OneRequired, []string{"a", "b"}) { + t.Errorf("oneRequired missing [a b]; got %v", groups.OneRequired) + } + if !groupListContains(groups.MutuallyExclusive, []string{"c", "d"}) { + t.Errorf("mutuallyExclusive missing [c d]; got %v", groups.MutuallyExclusive) + } + if !groupListContains(groups.RequiredTogether, []string{"e", "f"}) { + t.Errorf("requiredTogether missing [e f]; got %v", groups.RequiredTogether) + } +} + +func TestBuildAgentContext_HiddenCommands(t *testing.T) { + ctx := buildAgentContext(rootCmd) + spam := findAgentCommand(ctx.Commands, "spam") + if spam == nil { + t.Fatal("spam not found in output (hidden commands should still be included)") + } + if !spam.Hidden { + t.Error("spam should be hidden=true") + } +} + +func TestBuildAgentContext_PositionalArgs(t *testing.T) { + ctx := buildAgentContext(rootCmd) + + login := findAgentCommand(ctx.Commands, "auth", "login") + if login == nil { + t.Fatal("auth login not found") + } + if len(login.Args) != 1 { + t.Fatalf("auth login should have 1 positional, got %d", len(login.Args)) + } + if login.Args[0].Name != "name" || !login.Args[0].Required { + t.Errorf("auth login positional = %+v, want {name required}", login.Args[0]) + } + + use := findAgentCommand(ctx.Commands, "auth", "use") + if use == nil { + t.Fatal("auth use not found") + } + if len(use.Args) != 1 || use.Args[0].Required { + t.Errorf("auth use should have 1 optional positional, got %+v", use.Args) + } +} + +func TestBuildAgentContext_SelfPresent(t *testing.T) { + ctx := buildAgentContext(rootCmd) + self := findAgentCommand(ctx.Commands, "agent-context") + if self == nil { + t.Fatal("agent-context not in own output") + } + if self.Hidden { + t.Error("agent-context should not be hidden") + } +} + +func TestAgentContextCmd_RunEmitsValidJSON(t *testing.T) { + var buf bytes.Buffer + agentContextCmd.SetOut(&buf) + t.Cleanup(func() { agentContextCmd.SetOut(nil) }) + + if err := agentContextCmd.RunE(agentContextCmd, nil); err != nil { + t.Fatal(err) + } + var v map[string]any + if err := json.Unmarshal(buf.Bytes(), &v); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if v["name"] != "loops" { + t.Errorf("output name = %v, want loops", v["name"]) + } +} + +func TestParsePositionals(t *testing.T) { + cases := []struct { + use string + want []Positional + }{ + {"login ", []Positional{{Name: "name", Required: true}}}, + {"use [name]", []Positional{{Name: "name"}}}, + {"thing ", []Positional{{Name: "a", Required: true}, {Name: "b", Required: true}}}, + {"mixed [extra]", []Positional{{Name: "id", Required: true}, {Name: "extra"}}}, + {"bare", nil}, + } + for _, tc := range cases { + got := parsePositionals(tc.use) + if len(got) != len(tc.want) { + t.Errorf("parsePositionals(%q) = %+v, want %+v", tc.use, got, tc.want) + continue + } + for i, p := range got { + if p != tc.want[i] { + t.Errorf("parsePositionals(%q)[%d] = %+v, want %+v", tc.use, i, p, tc.want[i]) + } + } + } +} + +func walkAgentCommands(cmds []Command, fn func(Command)) { + for _, c := range cmds { + fn(c) + walkAgentCommands(c.Commands, fn) + } +} + +func findAgentCommand(cmds []Command, path ...string) *Command { + if len(path) == 0 { + return nil + } + for i := range cmds { + if cmds[i].Name == path[0] { + if len(path) == 1 { + return &cmds[i] + } + return findAgentCommand(cmds[i].Commands, path[1:]...) + } + } + return nil +} + +func findAgentFlag(flags []Flag, name string) *Flag { + for i := range flags { + if flags[i].Name == name { + return &flags[i] + } + } + return nil +} + +func agentFlagNames(flags []Flag) []string { + out := make([]string, len(flags)) + for i, f := range flags { + out[i] = f.Name + } + return out +} + +func agentSliceContains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func groupListContains(groups [][]string, want []string) bool { + wantStr := strings.Join(want, " ") + for _, g := range groups { + if strings.Join(g, " ") == wantStr { + return true + } + } + return false +} diff --git a/cmd/args.go b/cmd/args.go index 6c2f0a7..245098b 100644 --- a/cmd/args.go +++ b/cmd/args.go @@ -10,6 +10,32 @@ import ( var requiredArgRe = regexp.MustCompile(`<(\w+)>`) +// match both required `` and optional `[name]` positionals +// - capture group 1 is the required name (empty if optional) +// - capture group 2 is the optional name (empty if required) +var positionalArgRe = regexp.MustCompile(`<(\w+)>|\[(\w+)\]`) + +type Positional struct { + Name string `json:"name"` + Required bool `json:"required"` +} + +// extract every positional argument from a Cobra `Use` string in the +// order they appear. Required args use angle brackets (``); +// optional args use square brackets (`[name]`). +func parsePositionals(use string) []Positional { + matches := positionalArgRe.FindAllStringSubmatch(use, -1) + out := make([]Positional, 0, len(matches)) + for _, m := range matches { + if m[1] != "" { + out = append(out, Positional{Name: m[1], Required: true}) + } else if m[2] != "" { + out = append(out, Positional{Name: m[2]}) + } + } + return out +} + func wrapArgsWithNames(cmd *cobra.Command) { if cmd.Args != nil { v := cmd.Args diff --git a/go.mod b/go.mod index 7d1c3c0..199247c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/loops-so/loops-go v0.1.3 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/zalando/go-keyring v0.2.6 golang.org/x/term v0.41.0 gopkg.in/yaml.v2 v2.4.0 @@ -46,7 +47,6 @@ require ( github.com/spf13/cast v1.4.1 // indirect github.com/spf13/cobra-cli v1.3.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.10.1 // indirect github.com/subosito/gotenv v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect