Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -203,14 +233,26 @@ 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
if args[0] == "--version" || args[0] == "-v" {
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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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 <command> [options]")
Expand All @@ -283,11 +339,19 @@ func (c *CLI) showHelp() error {

fmt.Println()
fmt.Println("Use 'multiclaude <command> --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 != "" {
Expand Down
65 changes: 64 additions & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -2822,7 +2885,7 @@ func TestShowHelpNoPanic(t *testing.T) {
}
}()

cli.showHelp()
cli.showHelp(false)
}

func TestExecuteEmptyArgs(t *testing.T) {
Expand Down