From c75f6435cd1c577f28889e02a2b0505ad3fe581e Mon Sep 17 00:00:00 2001 From: David Aronchick Date: Thu, 29 Jan 2026 13:56:10 -0500 Subject: [PATCH] feat: Add --json flag for LLM-friendly CLI help output Add support for machine-readable JSON output of the command tree: - `multiclaude --json` outputs full command tree as JSON - `multiclaude --help --json` same as above - `multiclaude --json` outputs that command's schema This enables LLMs and automation tools to programmatically discover available commands, their descriptions, usage patterns, and subcommands. Includes CommandSchema struct for clean JSON serialization that filters internal commands (prefixed with _) from output. Co-Authored-By: Claude Opus 4.5 --- internal/cli/cli.go | 76 ++++++++++++++++++++++++++++++++++++---- internal/cli/cli_test.go | 65 +++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e06628..99c1bd3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -80,6 +80,36 @@ type Command struct { Subcommands map[string]*Command } +// CommandSchema is a JSON-serializable representation of a command for LLM parsing +type CommandSchema struct { + Name string `json:"name"` + Description string `json:"description"` + Usage string `json:"usage,omitempty"` + Subcommands map[string]*CommandSchema `json:"subcommands,omitempty"` +} + +// toSchema converts a Command to its JSON-serializable schema +func (cmd *Command) toSchema() *CommandSchema { + schema := &CommandSchema{ + Name: cmd.Name, + Description: cmd.Description, + Usage: cmd.Usage, + } + + if len(cmd.Subcommands) > 0 { + schema.Subcommands = make(map[string]*CommandSchema) + for name, subcmd := range cmd.Subcommands { + // Skip internal commands (prefixed with _) + if strings.HasPrefix(name, "_") { + continue + } + schema.Subcommands[name] = subcmd.toSchema() + } + } + + return schema +} + // CLI manages the command-line interface type CLI struct { rootCmd *Command @@ -203,7 +233,7 @@ func sanitizeTmuxSessionName(repoName string) string { // Execute executes the CLI with the given arguments func (c *CLI) Execute(args []string) error { if len(args) == 0 { - return c.showHelp() + return c.showHelp(false) } // Check for --version or -v flag at top level @@ -211,6 +241,18 @@ func (c *CLI) Execute(args []string) error { return c.showVersion() } + // Check for --help or -h with optional --json at top level + if args[0] == "--help" || args[0] == "-h" { + flags, _ := ParseFlags(args) + outputJSON := flags["json"] == "true" + return c.showHelp(outputJSON) + } + + // Check for --json alone (output full command tree) + if args[0] == "--json" { + return c.showHelp(true) + } + return c.executeCommand(c.rootCmd, args) } @@ -248,12 +290,19 @@ func (c *CLI) executeCommand(cmd *Command, args []string) error { if cmd.Run != nil { return cmd.Run([]string{}) } - return c.showCommandHelp(cmd) + return c.showCommandHelp(cmd, false) } - // Check for --help or -h flag + // Check for --help or -h flag with optional --json if args[0] == "--help" || args[0] == "-h" { - return c.showCommandHelp(cmd) + flags, _ := ParseFlags(args) + outputJSON := flags["json"] == "true" + return c.showCommandHelp(cmd, outputJSON) + } + + // Check for --json alone (output command schema) + if args[0] == "--json" { + return c.showCommandHelp(cmd, true) } // Check for subcommands @@ -270,7 +319,14 @@ func (c *CLI) executeCommand(cmd *Command, args []string) error { } // showHelp shows the main help message -func (c *CLI) showHelp() error { +func (c *CLI) showHelp(outputJSON bool) error { + if outputJSON { + schema := c.rootCmd.toSchema() + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(schema) + } + fmt.Println("multiclaude - repo-centric orchestrator for Claude Code") fmt.Println() fmt.Println("Usage: multiclaude [options]") @@ -283,11 +339,19 @@ func (c *CLI) showHelp() error { fmt.Println() fmt.Println("Use 'multiclaude --help' for more information about a command.") + fmt.Println("Use 'multiclaude --json' for machine-readable command tree (LLM-friendly).") return nil } // showCommandHelp shows help for a specific command -func (c *CLI) showCommandHelp(cmd *Command) error { +func (c *CLI) showCommandHelp(cmd *Command, outputJSON bool) error { + if outputJSON { + schema := cmd.toSchema() + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(schema) + } + fmt.Printf("%s - %s\n", cmd.Name, cmd.Description) fmt.Println() if cmd.Usage != "" { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 498577d..ea54f42 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2811,6 +2811,69 @@ func TestVersionCommandJSON(t *testing.T) { } } +func TestHelpJSON(t *testing.T) { + cli, _, cleanup := setupTestEnvironment(t) + defer cleanup() + + // Test --json flag at root level + err := cli.Execute([]string{"--json"}) + if err != nil { + t.Errorf("Execute(--json) failed: %v", err) + } + + // Test --help --json combination + err = cli.Execute([]string{"--help", "--json"}) + if err != nil { + t.Errorf("Execute(--help --json) failed: %v", err) + } + + // Test subcommand --json + err = cli.Execute([]string{"agent", "--json"}) + if err != nil { + t.Errorf("Execute(agent --json) failed: %v", err) + } +} + +func TestCommandSchemaConversion(t *testing.T) { + cmd := &Command{ + Name: "test", + Description: "test command", + Usage: "multiclaude test [args]", + Subcommands: map[string]*Command{ + "sub": { + Name: "sub", + Description: "subcommand", + Usage: "multiclaude test sub", + }, + "_internal": { + Name: "_internal", + Description: "internal command", + }, + }, + } + + schema := cmd.toSchema() + + if schema.Name != "test" { + t.Errorf("expected name 'test', got '%s'", schema.Name) + } + if schema.Description != "test command" { + t.Errorf("expected description 'test command', got '%s'", schema.Description) + } + if schema.Usage != "multiclaude test [args]" { + t.Errorf("expected usage 'multiclaude test [args]', got '%s'", schema.Usage) + } + if len(schema.Subcommands) != 1 { + t.Errorf("expected 1 subcommand (internal should be filtered), got %d", len(schema.Subcommands)) + } + if _, exists := schema.Subcommands["sub"]; !exists { + t.Error("expected 'sub' subcommand to exist") + } + if _, exists := schema.Subcommands["_internal"]; exists { + t.Error("internal commands should be filtered from schema") + } +} + func TestShowHelpNoPanic(t *testing.T) { cli, _, cleanup := setupTestEnvironment(t) defer cleanup() @@ -2822,7 +2885,7 @@ func TestShowHelpNoPanic(t *testing.T) { } }() - cli.showHelp() + cli.showHelp(false) } func TestExecuteEmptyArgs(t *testing.T) {