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) {