Skip to content

Commit e8c8bb2

Browse files
committed
Merge PR dlorenc#335: Add --json flag for LLM-friendly CLI help output
2 parents a03d3ea + c75f643 commit e8c8bb2

2 files changed

Lines changed: 134 additions & 7 deletions

File tree

internal/cli/cli.go

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,36 @@ type Command struct {
8383
Subcommands map[string]*Command
8484
}
8585

86+
// CommandSchema is a JSON-serializable representation of a command for LLM parsing
87+
type CommandSchema struct {
88+
Name string `json:"name"`
89+
Description string `json:"description"`
90+
Usage string `json:"usage,omitempty"`
91+
Subcommands map[string]*CommandSchema `json:"subcommands,omitempty"`
92+
}
93+
94+
// toSchema converts a Command to its JSON-serializable schema
95+
func (cmd *Command) toSchema() *CommandSchema {
96+
schema := &CommandSchema{
97+
Name: cmd.Name,
98+
Description: cmd.Description,
99+
Usage: cmd.Usage,
100+
}
101+
102+
if len(cmd.Subcommands) > 0 {
103+
schema.Subcommands = make(map[string]*CommandSchema)
104+
for name, subcmd := range cmd.Subcommands {
105+
// Skip internal commands (prefixed with _)
106+
if strings.HasPrefix(name, "_") {
107+
continue
108+
}
109+
schema.Subcommands[name] = subcmd.toSchema()
110+
}
111+
}
112+
113+
return schema
114+
}
115+
86116
// CLI manages the command-line interface
87117
type CLI struct {
88118
rootCmd *Command
@@ -206,14 +236,26 @@ func sanitizeTmuxSessionName(repoName string) string {
206236
// Execute executes the CLI with the given arguments
207237
func (c *CLI) Execute(args []string) error {
208238
if len(args) == 0 {
209-
return c.showHelp()
239+
return c.showHelp(false)
210240
}
211241

212242
// Check for --version or -v flag at top level
213243
if args[0] == "--version" || args[0] == "-v" {
214244
return c.showVersion()
215245
}
216246

247+
// Check for --help or -h with optional --json at top level
248+
if args[0] == "--help" || args[0] == "-h" {
249+
flags, _ := ParseFlags(args)
250+
outputJSON := flags["json"] == "true"
251+
return c.showHelp(outputJSON)
252+
}
253+
254+
// Check for --json alone (output full command tree)
255+
if args[0] == "--json" {
256+
return c.showHelp(true)
257+
}
258+
217259
return c.executeCommand(c.rootCmd, args)
218260
}
219261

@@ -251,12 +293,19 @@ func (c *CLI) executeCommand(cmd *Command, args []string) error {
251293
if cmd.Run != nil {
252294
return cmd.Run([]string{})
253295
}
254-
return c.showCommandHelp(cmd)
296+
return c.showCommandHelp(cmd, false)
255297
}
256298

257-
// Check for --help or -h flag
299+
// Check for --help or -h flag with optional --json
258300
if args[0] == "--help" || args[0] == "-h" {
259-
return c.showCommandHelp(cmd)
301+
flags, _ := ParseFlags(args)
302+
outputJSON := flags["json"] == "true"
303+
return c.showCommandHelp(cmd, outputJSON)
304+
}
305+
306+
// Check for --json alone (output command schema)
307+
if args[0] == "--json" {
308+
return c.showCommandHelp(cmd, true)
260309
}
261310

262311
// Check for subcommands
@@ -273,7 +322,14 @@ func (c *CLI) executeCommand(cmd *Command, args []string) error {
273322
}
274323

275324
// showHelp shows the main help message
276-
func (c *CLI) showHelp() error {
325+
func (c *CLI) showHelp(outputJSON bool) error {
326+
if outputJSON {
327+
schema := c.rootCmd.toSchema()
328+
encoder := json.NewEncoder(os.Stdout)
329+
encoder.SetIndent("", " ")
330+
return encoder.Encode(schema)
331+
}
332+
277333
fmt.Println("multiclaude - repo-centric orchestrator for Claude Code")
278334
fmt.Println()
279335
fmt.Println("Usage: multiclaude <command> [options]")
@@ -286,11 +342,19 @@ func (c *CLI) showHelp() error {
286342

287343
fmt.Println()
288344
fmt.Println("Use 'multiclaude <command> --help' for more information about a command.")
345+
fmt.Println("Use 'multiclaude --json' for machine-readable command tree (LLM-friendly).")
289346
return nil
290347
}
291348

292349
// showCommandHelp shows help for a specific command
293-
func (c *CLI) showCommandHelp(cmd *Command) error {
350+
func (c *CLI) showCommandHelp(cmd *Command, outputJSON bool) error {
351+
if outputJSON {
352+
schema := cmd.toSchema()
353+
encoder := json.NewEncoder(os.Stdout)
354+
encoder.SetIndent("", " ")
355+
return encoder.Encode(schema)
356+
}
357+
294358
fmt.Printf("%s - %s\n", cmd.Name, cmd.Description)
295359
fmt.Println()
296360
if cmd.Usage != "" {

internal/cli/cli_test.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2811,6 +2811,69 @@ func TestVersionCommandJSON(t *testing.T) {
28112811
}
28122812
}
28132813

2814+
func TestHelpJSON(t *testing.T) {
2815+
cli, _, cleanup := setupTestEnvironment(t)
2816+
defer cleanup()
2817+
2818+
// Test --json flag at root level
2819+
err := cli.Execute([]string{"--json"})
2820+
if err != nil {
2821+
t.Errorf("Execute(--json) failed: %v", err)
2822+
}
2823+
2824+
// Test --help --json combination
2825+
err = cli.Execute([]string{"--help", "--json"})
2826+
if err != nil {
2827+
t.Errorf("Execute(--help --json) failed: %v", err)
2828+
}
2829+
2830+
// Test subcommand --json
2831+
err = cli.Execute([]string{"agent", "--json"})
2832+
if err != nil {
2833+
t.Errorf("Execute(agent --json) failed: %v", err)
2834+
}
2835+
}
2836+
2837+
func TestCommandSchemaConversion(t *testing.T) {
2838+
cmd := &Command{
2839+
Name: "test",
2840+
Description: "test command",
2841+
Usage: "multiclaude test [args]",
2842+
Subcommands: map[string]*Command{
2843+
"sub": {
2844+
Name: "sub",
2845+
Description: "subcommand",
2846+
Usage: "multiclaude test sub",
2847+
},
2848+
"_internal": {
2849+
Name: "_internal",
2850+
Description: "internal command",
2851+
},
2852+
},
2853+
}
2854+
2855+
schema := cmd.toSchema()
2856+
2857+
if schema.Name != "test" {
2858+
t.Errorf("expected name 'test', got '%s'", schema.Name)
2859+
}
2860+
if schema.Description != "test command" {
2861+
t.Errorf("expected description 'test command', got '%s'", schema.Description)
2862+
}
2863+
if schema.Usage != "multiclaude test [args]" {
2864+
t.Errorf("expected usage 'multiclaude test [args]', got '%s'", schema.Usage)
2865+
}
2866+
if len(schema.Subcommands) != 1 {
2867+
t.Errorf("expected 1 subcommand (internal should be filtered), got %d", len(schema.Subcommands))
2868+
}
2869+
if _, exists := schema.Subcommands["sub"]; !exists {
2870+
t.Error("expected 'sub' subcommand to exist")
2871+
}
2872+
if _, exists := schema.Subcommands["_internal"]; exists {
2873+
t.Error("internal commands should be filtered from schema")
2874+
}
2875+
}
2876+
28142877
func TestShowHelpNoPanic(t *testing.T) {
28152878
cli, _, cleanup := setupTestEnvironment(t)
28162879
defer cleanup()
@@ -2822,7 +2885,7 @@ func TestShowHelpNoPanic(t *testing.T) {
28222885
}
28232886
}()
28242887

2825-
cli.showHelp()
2888+
cli.showHelp(false)
28262889
}
28272890

28282891
func TestExecuteEmptyArgs(t *testing.T) {

0 commit comments

Comments
 (0)