From 3f18b0c7ad160cb516308b8f4cf32aac01ac0988 Mon Sep 17 00:00:00 2001 From: Bob Lail Date: Tue, 18 Aug 2026 17:13:33 -0700 Subject: [PATCH] feat: Discover OpenCLI subcommands progressively A large CLI shouldn't have to enumerate its entire command tree in one --help-opencli response. When a subcommand's entry in its parent's document lists no children but declares the option --help-opencli, Exoskeleton now defers discovery: Subcommands() invokes the subcommand with --help-opencli (e.g. `tool sub --help-opencli`) and parses its document, recursively and on demand. Deferral surfaces two latent bugs, both fixed here: - describeOpenCLI's cache key was the executable's path alone, so a subcommand's document would collide with its root's. The key now includes the subcommand's arguments. - toCommands built sibling args with append() on a shared slice, so siblings four levels deep could overwrite each other's backing array. Args are now allocated exactly per command. Summary() also no longer triggers discovery when the summary is already known from the parent's document, so rendering a menu of a deferred subcommand's siblings stays free of exec calls. Co-Authored-By: Claude Fable 5 --- commands_test.go | 20 ++++++++++ contract.go | 5 +++ contract_opencli.go | 23 +++++++++++- contract_opencli_test.go | 80 +++++++++++++++++++++++++++++++++++++++- executable_command.go | 26 +++++++++---- fixtures/opencli-tool | 30 +++++++++++++++ 6 files changed, 175 insertions(+), 9 deletions(-) diff --git a/commands_test.go b/commands_test.go index dce5c94..0d79cbb 100644 --- a/commands_test.go +++ b/commands_test.go @@ -121,6 +121,26 @@ func TestExpand(t *testing.T) { } } +func TestToCommandsThreadsArgsToDeepDescendants(t *testing.T) { + descriptors := []*commandDescriptor{ + {Name: "a", Commands: []*commandDescriptor{ + {Name: "b", Commands: []*commandDescriptor{ + {Name: "c", Commands: []*commandDescriptor{ + {Name: "d1"}, + {Name: "d2"}, + }}, + }}, + }}, + } + parent := &executableCommand{path: "/tool", name: "tool"} + + cmds := toCommands(parent, descriptors, nil, &discoverer{maxDepth: -1}) + + c := cmds.Find("a").(*executableCommand).cmds.Find("b").(*executableCommand).cmds.Find("c").(*executableCommand) + assert.Equal(t, []string{"a", "b", "c", "d1"}, c.cmds.Find("d1").(*executableCommand).args) + assert.Equal(t, []string{"a", "b", "c", "d2"}, c.cmds.Find("d2").(*executableCommand).args) +} + func TestExpandWithDepthZeroDoesNotCallSubcommands(t *testing.T) { stub := &stubParent{name: "stub"} diff --git a/contract.go b/contract.go index 38cc783..6acb45f 100644 --- a/contract.go +++ b/contract.go @@ -194,6 +194,11 @@ type commandDescriptor struct { // is not populated; children are represented by Commands above. It is nil for // commands discovered via contracts other than OpenCLI. openCLI *opencli.Command + + // describedBy, when set, indicates that the command can describe its own + // subcommands (e.g. by responding to --help-opencli) even though Commands + // is empty. toCommands uses it to defer discovery to the command itself. + describedBy describeFunc } func readSummaryFromShellScript(cmd *shellScriptCommand) (string, error) { diff --git a/contract_opencli.go b/contract_opencli.go index 8837b9d..ddfc7d6 100644 --- a/contract_opencli.go +++ b/contract_opencli.go @@ -16,6 +16,11 @@ import ( // // Unlike ExecutableContract, this contract does not require any particular // file extension. Any executable file is eligible. +// +// A document may describe its subcommands progressively: when a subcommand's +// entry lists no children but declares the option "--help-opencli", its +// subcommands are discovered on demand by invoking the subcommand with +// --help-opencli (e.g. `tool sub --help-opencli`). type OpenCLIContract struct{} // OpenCLIDescriber is implemented by Commands that can describe themselves @@ -78,7 +83,10 @@ func (c *OpenCLIContract) BuildCommand(path string, info fs.DirEntry, parent Com // describeOpenCLI is a describeFunc that invokes --help-opencli // and parses the OpenCLI JSON output into a commandDescriptor. func describeOpenCLI(cmd *executableCommand) (*commandDescriptor, error) { - out, err := cmd.cache.Fetch(cmd, "help-opencli", func() (string, error) { + // Subcommands that respond to --help-opencli themselves share the + // executable's path, so the cache key must include their arguments. + key := strings.Join(append([]string{"help-opencli"}, cmd.args...), " ") + out, err := cmd.cache.Fetch(cmd, key, func() (string, error) { return helpOpenCLIRaw(cmd) }) if err != nil { @@ -150,6 +158,19 @@ func opencliToDescriptor(cmd opencli.Command) *commandDescriptor { for i, sub := range cmd.Commands { d.Commands[i] = opencliToDescriptor(sub) } + } else if respondsToHelpOpenCLI(cmd) { + d.describedBy = describeOpenCLI } return d } + +// respondsToHelpOpenCLI returns true if the command declares --help-opencli +// among its options, signaling that it can describe its own subcommands. +func respondsToHelpOpenCLI(cmd opencli.Command) bool { + for _, o := range cmd.Options { + if o.Name == "--help-opencli" { + return true + } + } + return false +} diff --git a/contract_opencli_test.go b/contract_opencli_test.go index 0cc7bdd..fcd43bc 100644 --- a/contract_opencli_test.go +++ b/contract_opencli_test.go @@ -3,6 +3,7 @@ package exoskeleton import ( "io/fs" "os" + "os/exec" "path/filepath" "testing" @@ -88,10 +89,11 @@ func TestOpenCLICommandDiscovery(t *testing.T) { // Subcommands are discovered from OpenCLI output cmds, err := cmd.Subcommands() assert.NoError(t, err) - assert.Len(t, cmds, 3) + assert.Len(t, cmds, 4) assert.Equal(t, "build", cmds[0].Name()) assert.Equal(t, "mod", cmds[1].Name()) assert.Equal(t, "hidden-cmd", cmds[2].Name()) + assert.Equal(t, "google-drive", cmds[3].Name()) // Nested subcommands modCmds, err := cmds[1].Subcommands() @@ -147,6 +149,82 @@ func TestOpenCLICommandExposesMetadata(t *testing.T) { assert.Empty(t, node.Commands) } +// buildOpenCLITool builds a Command from the opencli-tool fixture. +func buildOpenCLITool(t *testing.T, d *discoverer) Command { + t.Helper() + path := filepath.Join(fixtures, "opencli-tool") + info, err := os.Lstat(path) + assert.NoError(t, err) + cmd, err := (&OpenCLIContract{}).BuildCommand(path, fs.FileInfoToDirEntry(info), nil, d) + assert.NoError(t, err) + return cmd +} + +func TestOpenCLIProgressiveDiscovery(t *testing.T) { + execs := 0 + countingExecutor := func(c *exec.Cmd) error { execs++; return c.Run() } + cmd := buildOpenCLITool(t, &discoverer{maxDepth: -1, executor: countingExecutor}) + + cmds, err := cmd.Subcommands() + assert.NoError(t, err) + assert.Equal(t, 1, execs) + + // The root document lists no subcommands for google-drive but declares + // that it responds to --help-opencli. Its sigil and summary come from + // the root document without executing anything... + gd := cmds.Find("google-drive") + assert.True(t, HasSubcommands(gd)) + summary, err := gd.Summary() + assert.NoError(t, err) + assert.Equal(t, "Work with Google Drive", summary) + assert.Equal(t, 1, execs) + + // ...and Subcommands() invokes `opencli-tool google-drive --help-opencli`. + gdCmds, err := gd.Subcommands() + assert.NoError(t, err) + assert.Equal(t, 2, execs) + assert.Len(t, gdCmds, 2) + + // Descendants are invoked with the full path of arguments. + docs := gdCmds[0].(*executableCommand) + assert.Equal(t, []string{cmd.Path(), "google-drive", "docs"}, docs.Command().Args) + + // OpenCLICommand() returns google-drive's own description of itself, + // which is richer than its entry in the root document. + node, err := gd.(OpenCLIDescriber).OpenCLICommand() + assert.NoError(t, err) + assert.Equal(t, "Work with files in Google Drive", *node.Description) +} + +func TestOpenCLIProgressiveDiscoveryRespectsMaxDepth(t *testing.T) { + cmd := buildOpenCLITool(t, &discoverer{maxDepth: 1, executor: defaultExecutor}) + + cmds, err := cmd.Subcommands() + assert.NoError(t, err) + + gdCmds, err := cmds.Find("google-drive").Subcommands() + assert.NoError(t, err) + assert.Empty(t, gdCmds) +} + +func TestOpenCLIProgressiveDiscoveryCacheKeysDoNotCollide(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "cache.json") + + subcommandsOfGoogleDrive := func() Commands { + cmd := buildOpenCLITool(t, &discoverer{maxDepth: -1, executor: defaultExecutor, cache: &FileCache{Path: cachePath}}) + cmds, err := cmd.Subcommands() + assert.NoError(t, err) + gdCmds, err := cmds.Find("google-drive").Subcommands() + assert.NoError(t, err) + return gdCmds + } + + // The second call reads google-drive's document from the cache; a key + // collision would return the root document instead. + assert.Len(t, subcommandsOfGoogleDrive(), 2) + assert.Len(t, subcommandsOfGoogleDrive(), 2) +} + func TestOpenCLICommandForNonOpenCLIContract(t *testing.T) { // A command that was not discovered via the OpenCLI contract has no // OpenCLI metadata; its richer fields come from the Command interface. diff --git a/executable_command.go b/executable_command.go index 0e74755..403c3b5 100644 --- a/executable_command.go +++ b/executable_command.go @@ -82,7 +82,7 @@ func (cmd *executableCommand) Complete(_ *Entrypoint, args, env []string) ([]str // The executable is expected to write the summary to standard output and exit // successfully. func (cmd *executableCommand) Summary() (string, error) { - if cmd.discoverer != nil && cmd.cmds == nil { + if cmd.summary == nil && cmd.discoverer != nil && cmd.cmds == nil { if err := cmd.discover(); err != nil { return "", err } @@ -184,7 +184,7 @@ func (cmd *executableCommand) discover() error { cmd.summary = descriptor.Summary cmd.defaultSubcommand = descriptor.DefaultCommand cmd.openCLI = descriptor.openCLI - cmd.cmds = toCommands(cmd, descriptor.Commands, nil, cmd.discoverer) + cmd.cmds = toCommands(cmd, descriptor.Commands, cmd.args, cmd.discoverer) return nil } @@ -195,7 +195,9 @@ func (cmd *executableCommand) discover() error { // The returned Command describes only this node. Its Commands field is not // populated; walk Subcommands() to describe the command tree. func (cmd *executableCommand) OpenCLICommand() (*opencli.Command, error) { - if cmd.openCLI == nil && cmd.discoverer != nil && cmd.cmds == nil { + // Discover before answering: a command that describes its own subcommands + // provides a richer self-description than its entry in its parent's document. + if cmd.discoverer != nil && cmd.cmds == nil { if err := cmd.discover(); err != nil { return nil, err } @@ -210,11 +212,14 @@ func (cmd *executableCommand) OpenCLICommand() (*opencli.Command, error) { func toCommands(parent *executableCommand, descriptors []*commandDescriptor, args []string, d DiscoveryContext) Commands { cmds := Commands{} for _, descriptor := range descriptors { + // Allocate exactly, so that sibling commands never share a backing array. + cmdArgs := append(append(make([]string, 0, len(args)+1), args...), descriptor.Name) + c := &executableCommand{ parent: parent, discoveredIn: parent.discoveredIn, path: parent.path, - args: append(args, descriptor.Name), + args: cmdArgs, name: descriptor.Name, aliases: descriptor.Aliases, summary: descriptor.Summary, @@ -225,9 +230,16 @@ func toCommands(parent *executableCommand, descriptors []*commandDescriptor, arg contract: parent.contract, } - if len(descriptor.Commands) > 0 && d.MaxDepth() != 0 { - c.discoverer = d.Next() - c.cmds = toCommands(c, descriptor.Commands, append(args, c.name), d.Next()) + if d.MaxDepth() != 0 { + if len(descriptor.Commands) > 0 { + c.discoverer = d.Next() + c.cmds = toCommands(c, descriptor.Commands, cmdArgs, d.Next()) + } else if descriptor.describedBy != nil { + // The command can describe its own subcommands: defer + // discovery until Subcommands() is called. + c.discoverer = d.Next() + c.describe = descriptor.describedBy + } } cmds = append(cmds, c) } diff --git a/fixtures/opencli-tool b/fixtures/opencli-tool index 375312e..4313e4a 100755 --- a/fixtures/opencli-tool +++ b/fixtures/opencli-tool @@ -1,6 +1,29 @@ #!/usr/bin/env bash # SUMMARY: An OpenCLI tool +if [[ "$1" == "google-drive" && "$2" == "--help-opencli" ]]; then + cat << EOF +{ + "opencli": "0.1-block.1", + "name": "google-drive", + "info": {"version": "1.0.0"}, + "summary": "Work with Google Drive", + "description": "Work with files in Google Drive", + "commands": [ + { + "name": "docs", + "summary": "Work with Google Docs" + }, + { + "name": "sheets", + "summary": "Work with Google Sheets" + } + ] +} +EOF + exit 0 +fi + if [[ "$1" == "--help-opencli" ]]; then cat << EOF { @@ -39,6 +62,13 @@ if [[ "$1" == "--help-opencli" ]]; then "arguments": [ {"name": "file"} ] + }, + { + "name": "google-drive", + "summary": "Work with Google Drive", + "options": [ + {"name": "--help-opencli", "hidden": true} + ] } ] }