From fd39d636e78905557c394744abb8f6b25d3fb379 Mon Sep 17 00:00:00 2001 From: Kunal Dawar Date: Sat, 11 Jul 2026 01:51:13 +0530 Subject: [PATCH] feat: add commands to list the command tree for easier discovery Add a top-level `auth0 commands` that prints the full command tree with short descriptions, so the whole CLI surface can be discovered without inspecting each --help page individually. Supports three output shapes: - default nested tree with descriptions - --flat for one runnable command per line, ideal for scanning or intent matching - --json (with optional --detailed) exposing usage, arguments, flags, aliases and whether authentication is required Accepts an optional command path (e.g. `auth0 commands apps`) to scope the output to a single branch, and a --depth flag to limit nesting. --- docs/auth0_commands.md | 50 ++++++ docs/index.md | 1 + internal/cli/commands.go | 326 ++++++++++++++++++++++++++++++++++ internal/cli/commands_test.go | 138 ++++++++++++++ internal/cli/root.go | 3 + 5 files changed, 518 insertions(+) create mode 100644 docs/auth0_commands.md create mode 100644 internal/cli/commands.go create mode 100644 internal/cli/commands_test.go diff --git a/docs/auth0_commands.md b/docs/auth0_commands.md new file mode 100644 index 000000000..bcceb11c7 --- /dev/null +++ b/docs/auth0_commands.md @@ -0,0 +1,50 @@ +--- +layout: default +has_toc: false +--- +# auth0 commands + +List every command in a compact tree, along with a short description of what it does. + +This gives you (or an AI agent) a single overview of the whole CLI surface, so the right command can be found without inspecting each `--help` page individually. + +Pass a command path to expand only that branch instead of the whole tree, for example `auth0 commands apps` or `auth0 commands apps create`. This keeps the output focused when you only care about one area. + +Use `--flat` to list every runnable command on its own line, which is the easiest form to scan or match an intent against. Use `--json` for a machine-readable representation, and add `--detailed` to include usage lines, flags, arguments and whether authentication is required, which is enough for an agent to construct a valid invocation on its own. + +## Usage +``` +auth0 commands [command] +``` + +## Examples + +``` + auth0 commands + auth0 commands --flat + auth0 commands apps + auth0 commands apps create --detailed + auth0 commands apps --json --detailed +``` + + +## Flags + +``` + --depth int Maximum depth to display. 0 shows all levels. Ignored with --flat. + --detailed Include usage, flags, arguments and auth requirements. Best used with --json. + --flat List every runnable command on its own line, best for scanning or intent matching. + --json Output in json format. +``` + + +## Inherited Flags + +``` + --debug Enable debug mode. + --no-color Disable colors. + --no-input Disable interactivity. + --tenant string Specific tenant to use. +``` + + diff --git a/docs/index.md b/docs/index.md index 0454ce86b..11fc21e85 100644 --- a/docs/index.md +++ b/docs/index.md @@ -84,6 +84,7 @@ Authenticating as a user is not supported for **private cloud** tenants. Instead - [auth0 api](auth0_api.md) - Makes an authenticated HTTP request to the Auth0 Management API - [auth0 apis](auth0_apis.md) - Manage resources for APIs - [auth0 apps](auth0_apps.md) - Manage resources for applications +- [auth0 commands](auth0_commands.md) - List all commands in a tree structure - [auth0 completion](auth0_completion.md) - Setup autocomplete features for this CLI on your terminal - [auth0 domains](auth0_domains.md) - Manage custom domains - [auth0 email](auth0_email.md) - Manage email settings and configure email providers diff --git a/internal/cli/commands.go b/internal/cli/commands.go new file mode 100644 index 000000000..4ce506032 --- /dev/null +++ b/internal/cli/commands.go @@ -0,0 +1,326 @@ +package cli + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/auth0/auth0-cli/internal/ansi" + "github.com/auth0/auth0-cli/internal/iostream" +) + +// commandFlag is a serializable description of a single command flag. +type commandFlag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Usage string `json:"usage,omitempty"` + Type string `json:"type,omitempty"` + Default string `json:"default,omitempty"` +} + +// commandNode is a serializable representation of a command in the tree. +// +// It is intentionally structured so that an AI agent can, from a single +// call, discover which command does what and learn enough to invoke it +// (usage line, flags, whether authentication is needed) without having to +// call `--help` on each command individually. +type commandNode struct { + Path string `json:"path"` + Name string `json:"name"` + Short string `json:"short"` + Description string `json:"description,omitempty"` + Usage string `json:"usage,omitempty"` + Example string `json:"example,omitempty"` + Arguments []string `json:"arguments,omitempty"` + ValidArgs []string `json:"validArgs,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Runnable bool `json:"runnable"` + RequiresAuth bool `json:"requiresAuth"` + Flags []commandFlag `json:"flags,omitempty"` + Subcommands []commandNode `json:"subcommands,omitempty"` +} + +func commandsCmd(cli *cli) *cobra.Command { + var ( + depth int + detailed bool + flat bool + ) + + cmd := &cobra.Command{ + Use: "commands [command]", + Args: cobra.ArbitraryArgs, + Short: "List all commands in a tree structure", + Long: "List every command in a compact tree, along with a short description of what it does.\n\n" + + "This gives you (or an AI agent) a single overview of the whole CLI surface, so the right " + + "command can be found without inspecting each `--help` page individually.\n\n" + + "Pass a command path to expand only that branch instead of the whole tree, for example " + + "`auth0 commands apps` or `auth0 commands apps create`. This keeps the output focused when " + + "you only care about one area.\n\n" + + "Use `--flat` to list every runnable command on its own line, which is the easiest form to " + + "scan or match an intent against. Use `--json` for a machine-readable representation, and add " + + "`--detailed` to include usage lines, flags, arguments and whether authentication is required, " + + "which is enough for an agent to construct a valid invocation on its own.", + Example: ` auth0 commands + auth0 commands --flat + auth0 commands apps + auth0 commands apps create --detailed + auth0 commands apps --json --detailed`, + DisableFlagsInUseLine: true, + RunE: func(cmd *cobra.Command, args []string) error { + root := cmd.Root() + + // If a command path is provided, scope the tree to that branch. + start := root + if len(args) > 0 { + target, _, err := root.Find(args) + if err != nil || target == root { + return fmt.Errorf("unknown command %q for %q", strings.Join(args, " "), root.Name()) + } + start = target + } + + // When scoped to a specific command, describe that command + // itself (with its subtree). At the root we list its children. + scoped := start != root + + if flat { + nodes := flattenCommands(start, scoped, detailed) + if cli.json { + return renderCommandTreeJSON(nodes) + } + renderCommandsFlatText(nodes) + return nil + } + + if cli.json { + var tree []commandNode + if scoped { + tree = []commandNode{buildNode(start, 1, depth, detailed)} + } else { + tree = buildCommandTree(start, depth, detailed) + } + return renderCommandTreeJSON(tree) + } + + renderCommandTreeText(start, depth) + return nil + }, + } + + cmd.Flags().BoolVar(&cli.json, "json", false, "Output in json format.") + cmd.Flags().BoolVar(&flat, "flat", false, "List every runnable command on its own line, best for scanning or intent matching.") + cmd.Flags().BoolVar(&detailed, "detailed", false, "Include usage, flags, arguments and auth requirements. Best used with --json.") + cmd.Flags().IntVar(&depth, "depth", 0, "Maximum depth to display. 0 shows all levels. Ignored with --flat.") + + return cmd +} + +// buildCommandTree converts the cobra command tree into a slice of +// commandNode, honoring the requested depth (0 means unlimited). +func buildCommandTree(cmd *cobra.Command, maxDepth int, detailed bool) []commandNode { + return collectChildren(cmd, 1, maxDepth, detailed) +} + +func collectChildren(cmd *cobra.Command, level, maxDepth int, detailed bool) []commandNode { + var nodes []commandNode + + for _, child := range availableChildren(cmd) { + nodes = append(nodes, buildNode(child, level, maxDepth, detailed)) + } + + return nodes +} + +// buildNode builds a commandNode for a single command, recursing into its +// subcommands until maxDepth is reached (0 means unlimited). +func buildNode(cmd *cobra.Command, level, maxDepth int, detailed bool) commandNode { + node := commandNode{ + Path: cmd.CommandPath(), + Name: cmd.Name(), + Short: cmd.Short, + Runnable: cmd.Runnable(), + RequiresAuth: commandRequiresAuthentication(cmd.CommandPath()), + } + + if detailed { + // Prefer the longer description when it adds detail beyond Short. + if long := strings.TrimSpace(cmd.Long); long != "" && long != cmd.Short { + node.Description = long + } + node.Usage = cmd.UseLine() + node.Example = strings.TrimSpace(cmd.Example) + node.Arguments = extractArguments(cmd) + node.ValidArgs = cmd.ValidArgs + node.Aliases = cmd.Aliases + node.Flags = collectFlags(cmd) + } + + if maxDepth == 0 || level < maxDepth { + node.Subcommands = collectChildren(cmd, level+1, maxDepth, detailed) + } + + return node +} + +// flattenCommands returns every runnable (leaf) command under start as a flat +// list, without nesting. This is the easiest shape for scanning or matching an +// intent against, since each command is a single self-contained entry. When +// scoped is true and start itself is runnable, it is included as well. +func flattenCommands(start *cobra.Command, scoped, detailed bool) []commandNode { + var nodes []commandNode + + if scoped && start.Runnable() { + node := buildNode(start, 1, 1, detailed) + node.Subcommands = nil + nodes = append(nodes, node) + } + + var walk func(cmd *cobra.Command) + walk = func(cmd *cobra.Command) { + for _, child := range availableChildren(cmd) { + if child.Runnable() { + node := buildNode(child, 1, 1, detailed) + node.Subcommands = nil + nodes = append(nodes, node) + } + walk(child) + } + } + walk(start) + + return nodes +} + +// renderCommandsFlatText prints one command per line as "path — short". +func renderCommandsFlatText(nodes []commandNode) { + for _, node := range nodes { + line := ansi.Bold(node.Path) + if node.Short != "" { + line += " " + ansi.Faint(node.Short) + } + fmt.Fprintln(iostream.Output, line) + } +} + +// collectFlags returns the command's local (non-inherited) flags, so an +// agent sees only the flags meaningful to that specific command. +func collectFlags(cmd *cobra.Command) []commandFlag { + var flags []commandFlag + + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + // Skip hidden flags and the ubiquitous --help flag, which are + // noise for an agent trying to construct an invocation. + if f.Hidden || f.Name == "help" { + return + } + + flags = append(flags, commandFlag{ + Name: f.Name, + Shorthand: f.Shorthand, + Usage: f.Usage, + Type: f.Value.Type(), + Default: f.DefValue, + }) + }) + + if len(flags) == 0 { + return nil + } + + return flags +} + +// argumentPlaceholder matches positional argument placeholders written as +// or [app-id]. The CLI documents positional arguments this way in +// its Use and Example lines, so we surface them as an explicit list. +var argumentPlaceholder = regexp.MustCompile(`^[<\[][a-zA-Z][a-zA-Z0-9_-]*[>\]]$`) + +// extractArguments discovers the positional arguments a command accepts by +// scanning its Use line and examples for / [name] placeholders. This +// gives an agent an explicit, deduplicated list instead of forcing it to +// parse free-form example text. +// +// A placeholder only counts as positional when it is not the value of a flag +// (for example `--description ` is a flag value, not a positional +// argument), so we skip any token that directly follows a flag. +func extractArguments(cmd *cobra.Command) []string { + seen := make(map[string]bool) + var args []string + + for _, line := range append([]string{cmd.Use}, strings.Split(cmd.Example, "\n")...) { + tokens := strings.Fields(line) + for i, token := range tokens { + if !argumentPlaceholder.MatchString(token) { + continue + } + // Skip placeholders that are the value of a preceding flag. + if i > 0 && strings.HasPrefix(tokens[i-1], "-") { + continue + } + + // Normalize to form regardless of the bracket style used. + name := "<" + strings.Trim(token, "<>[]") + ">" + if seen[name] { + continue + } + seen[name] = true + args = append(args, name) + } + } + + return args +} + +func renderCommandTreeJSON(tree []commandNode) error { + encoder := json.NewEncoder(iostream.Output) + encoder.SetIndent("", " ") + return encoder.Encode(tree) +} + +// renderCommandTreeText prints the tree with box-drawing connectors, +// keeping command names aligned with their short descriptions. +func renderCommandTreeText(root *cobra.Command, maxDepth int) { + fmt.Fprintln(iostream.Output, ansi.Bold(root.CommandPath())) + printChildren(root, "", 1, maxDepth) +} + +func printChildren(cmd *cobra.Command, prefix string, level, maxDepth int) { + children := availableChildren(cmd) + + for i, child := range children { + isLast := i == len(children)-1 + + connector := "├── " + childPrefix := prefix + "│ " + if isLast { + connector = "└── " + childPrefix = prefix + " " + } + + line := prefix + connector + ansi.Bold(child.Name()) + if child.Short != "" { + line += " " + ansi.Faint(child.Short) + } + fmt.Fprintln(iostream.Output, line) + + if maxDepth == 0 || level < maxDepth { + printChildren(child, childPrefix, level+1, maxDepth) + } + } +} + +func availableChildren(cmd *cobra.Command) []*cobra.Command { + var children []*cobra.Command + for _, child := range cmd.Commands() { + if !child.IsAvailableCommand() || child.IsAdditionalHelpTopicCommand() { + continue + } + children = append(children, child) + } + return children +} diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go new file mode 100644 index 000000000..75e42d586 --- /dev/null +++ b/internal/cli/commands_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// findSubcommand returns the node with the given name from a slice of nodes. +func findSubcommand(nodes []commandNode, name string) (commandNode, bool) { + for _, n := range nodes { + if n.Name == name { + return n, true + } + } + return commandNode{}, false +} + +func newTestCommandTree() *cobra.Command { + root := &cobra.Command{Use: "auth0"} + + apps := &cobra.Command{ + Use: "apps", + Short: "Manage resources for applications", + } + + show := &cobra.Command{ + Use: "show", + Short: "Show an application", + Long: "Display the name, description, app type, and other information about an application.", + Example: ` auth0 apps show + auth0 apps show + auth0 apps show --reveal-secrets`, + Run: func(*cobra.Command, []string) {}, + } + show.Flags().Bool("reveal-secrets", false, "Display the application secrets.") + + create := &cobra.Command{ + Use: "create", + Short: "Create a new application", + Example: ` auth0 apps create + auth0 apps create --name myapp --description `, + Run: func(*cobra.Command, []string) {}, + } + create.Flags().String("name", "", "Name of the application.") + create.Flags().String("description", "", "Description of the application.") + + apps.AddCommand(show) + apps.AddCommand(create) + root.AddCommand(apps) + + return root +} + +func TestBuildCommandTree(t *testing.T) { + root := newTestCommandTree() + + tree := buildCommandTree(root, 0, false) + + assert.Len(t, tree, 1) + assert.Equal(t, "auth0 apps", tree[0].Path) + assert.Equal(t, "apps", tree[0].Name) + assert.False(t, tree[0].Runnable) + assert.Len(t, tree[0].Subcommands, 2) + + show, ok := findSubcommand(tree[0].Subcommands, "show") + assert.True(t, ok) + assert.True(t, show.Runnable) +} + +func TestBuildCommandTreeRespectsDepth(t *testing.T) { + root := newTestCommandTree() + + tree := buildCommandTree(root, 1, false) + + assert.Len(t, tree, 1) + assert.Empty(t, tree[0].Subcommands, "depth 1 should not include grandchildren") +} + +func TestBuildCommandTreeDetailed(t *testing.T) { + root := newTestCommandTree() + + tree := buildCommandTree(root, 0, true) + show, ok := findSubcommand(tree[0].Subcommands, "show") + assert.True(t, ok) + + assert.Equal(t, "Display the name, description, app type, and other information about an application.", show.Description) + assert.Equal(t, []string{""}, show.Arguments) + + var flagNames []string + for _, f := range show.Flags { + flagNames = append(flagNames, f.Name) + } + assert.Contains(t, flagNames, "reveal-secrets") + assert.NotContains(t, flagNames, "help", "the --help flag should be filtered out") +} + +func TestFlattenCommands(t *testing.T) { + root := newTestCommandTree() + + // Unscoped: only the runnable leaf commands, no group nodes. + nodes := flattenCommands(root, false, false) + + var paths []string + for _, n := range nodes { + paths = append(paths, n.Path) + assert.True(t, n.Runnable, "flat mode should only include runnable commands") + assert.Empty(t, n.Subcommands, "flat nodes should not nest") + } + + assert.ElementsMatch(t, []string{"auth0 apps show", "auth0 apps create"}, paths) + assert.NotContains(t, paths, "auth0 apps", "the non-runnable group should be excluded") +} + +func TestFlattenCommandsScopedIncludesRunnableStart(t *testing.T) { + root := newTestCommandTree() + + apps, _, err := root.Find([]string{"apps", "show"}) + assert.NoError(t, err) + + // Scoped to a runnable leaf: it should include itself. + nodes := flattenCommands(apps, true, false) + assert.Len(t, nodes, 1) + assert.Equal(t, "auth0 apps show", nodes[0].Path) +} + +func TestExtractArgumentsIgnoresFlagValues(t *testing.T) { + root := newTestCommandTree() + + tree := buildCommandTree(root, 0, true) + create, ok := findSubcommand(tree[0].Subcommands, "create") + assert.True(t, ok) + + // is the value of the --description flag, not a positional + // argument, so it must not appear in the arguments list. + assert.Empty(t, create.Arguments) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 75739f6f6..2838b0189 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -132,6 +132,7 @@ func buildRootCmd(cli *cli) *cobra.Command { func commandRequiresAuthentication(invokedCommandName string) bool { commandsWithNoAuthRequired := []string{ + "auth0 commands", "auth0 completion", "auth0 help", "auth0 login", @@ -193,6 +194,8 @@ func addSubCommands(rootCmd *cobra.Command, cli *cli) { rootCmd.AddCommand(tenantSettingsCmd(cli)) rootCmd.AddCommand(tokenExchangeCmd(cli)) + rootCmd.AddCommand(commandsCmd(cli)) + // Keep completion at the bottom. rootCmd.AddCommand(completionCmd(cli)) }