diff --git a/contract.go b/contract.go index 29c2617..38cc783 100644 --- a/contract.go +++ b/contract.go @@ -10,6 +10,7 @@ import ( "os" "strings" + "github.com/block/opencli-go" "github.com/square/exit" "github.com/square/exoskeleton/v2/pkg/shellcomp" ) @@ -187,6 +188,12 @@ type commandDescriptor struct { Summary *string `json:"summary,omitempty"` Commands []*commandDescriptor `json:"commands,omitempty"` DefaultCommand string `json:"defaultCommand,omitempty"` + + // openCLI holds the node's OpenCLI metadata (arguments, options, description, + // examples, exit codes, ...) captured from --help-opencli. Its Commands field + // is not populated; children are represented by Commands above. It is nil for + // commands discovered via contracts other than OpenCLI. + openCLI *opencli.Command } func readSummaryFromShellScript(cmd *shellScriptCommand) (string, error) { diff --git a/contract_opencli.go b/contract_opencli.go index 558eeac..8837b9d 100644 --- a/contract_opencli.go +++ b/contract_opencli.go @@ -18,6 +18,25 @@ import ( // file extension. Any executable file is eligible. type OpenCLIContract struct{} +// OpenCLIDescriber is implemented by Commands that can describe themselves +// using the OpenCLI model (github.com/block/opencli-go). Not every Command +// implements it; reflect on a discovered Command to obtain its metadata: +// +// if d, ok := cmd.(OpenCLIDescriber); ok { +// if c, _ := d.OpenCLICommand(); c != nil { +// // c.Arguments, c.Options, c.Description, c.Examples, ... +// } +// } +// +// OpenCLICommand returns nil when the command has no OpenCLI metadata (for +// example, a command discovered via a contract other than OpenCLI). +// +// The returned Command describes only this node; its Commands field is not +// populated. Walk Subcommands() to describe the command tree. +type OpenCLIDescriber interface { + OpenCLICommand() (*opencli.Command, error) +} + func (c *OpenCLIContract) BuildCommand(path string, info fs.DirEntry, parent Command, d DiscoveryContext) (Command, error) { if info.IsDir() { return nil, ErrNotApplicable @@ -118,6 +137,14 @@ func opencliToDescriptor(cmd opencli.Command) *commandDescriptor { if cmd.DefaultCommand != nil { d.DefaultCommand = *cmd.DefaultCommand } + + // Retain the node's OpenCLI metadata so it can be surfaced via + // OpenCLIDescriber. Strip Commands: children are represented by + // d.Commands and reachable through Subcommands(). + nodeLocal := cmd + nodeLocal.Commands = nil + d.openCLI = &nodeLocal + if len(cmd.Commands) > 0 { d.Commands = make([]*commandDescriptor, len(cmd.Commands)) for i, sub := range cmd.Commands { diff --git a/contract_opencli_test.go b/contract_opencli_test.go index 98c0172..0cc7bdd 100644 --- a/contract_opencli_test.go +++ b/contract_opencli_test.go @@ -108,6 +108,54 @@ func TestOpenCLICommandDiscovery(t *testing.T) { assert.Equal(t, "tidy", cmds[1].DefaultSubcommand().Name()) } +func TestOpenCLICommandExposesMetadata(t *testing.T) { + contract := &OpenCLIContract{} + path := filepath.Join(fixtures, "opencli-tool") + info, err := os.Lstat(path) + assert.NoError(t, err) + + d := &discoverer{maxDepth: -1, executor: defaultExecutor} + cmd, err := contract.BuildCommand(path, fs.FileInfoToDirEntry(info), nil, d) + assert.NoError(t, err) + + // The root command is an OpenCLIDescriber. + describer, ok := cmd.(OpenCLIDescriber) + assert.True(t, ok) + + // Root node metadata (triggers discovery). Commands are not populated on + // the node; they are reachable via Subcommands(). + root, err := describer.OpenCLICommand() + assert.NoError(t, err) + assert.Equal(t, "opencli-tool", root.Name) + assert.Equal(t, "An OpenCLI tool", *root.Summary) + assert.Empty(t, root.Commands) + + cmds, err := cmd.Subcommands() + assert.NoError(t, err) + + // The "hidden-cmd" subcommand carries options, arguments, and the hidden flag. + hidden, ok := cmds[2].(OpenCLIDescriber) + assert.True(t, ok) + node, err := hidden.OpenCLICommand() + assert.NoError(t, err) + assert.True(t, node.Hidden) + assert.Len(t, node.Options, 1) + assert.Equal(t, "--verbose", node.Options[0].Name) + assert.Equal(t, []string{"-v"}, node.Options[0].Aliases) + assert.Len(t, node.Arguments, 1) + assert.Equal(t, "file", node.Arguments[0].Name) + assert.Empty(t, node.Commands) +} + +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. + cmd := &executableCommand{name: "plain", aliases: []string{"p"}} + node, err := cmd.OpenCLICommand() + assert.NoError(t, err) + assert.Nil(t, node) +} + func TestParseOpenCLI(t *testing.T) { cmd := &executableCommand{path: "/test"} out := `{ diff --git a/executable_command.go b/executable_command.go index b1e10cd..9d5d792 100644 --- a/executable_command.go +++ b/executable_command.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" + "github.com/block/opencli-go" "github.com/square/exoskeleton/v2/pkg/shellcomp" ) @@ -29,6 +30,7 @@ type executableCommand struct { cache Cache describe describeFunc contract string + openCLI *opencli.Command } func (cmd *executableCommand) Parent() Command { return cmd.parent } @@ -172,10 +174,30 @@ 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) return nil } +// OpenCLICommand returns this command's OpenCLI metadata (github.com/block/opencli-go), +// or nil when it has none — for example, a command discovered via a contract +// other than OpenCLI. +// +// 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 { + if err := cmd.discover(); err != nil { + return nil, err + } + } + if cmd.openCLI == nil { + return nil, nil + } + node := *cmd.openCLI + return &node, nil +} + func toCommands(parent *executableCommand, descriptors []*commandDescriptor, args []string, d DiscoveryContext) Commands { cmds := Commands{} for _, descriptor := range descriptors { @@ -188,6 +210,7 @@ func toCommands(parent *executableCommand, descriptors []*commandDescriptor, arg aliases: descriptor.Aliases, summary: descriptor.Summary, defaultSubcommand: descriptor.DefaultCommand, + openCLI: descriptor.openCLI, executor: parent.executor, cache: parent.cache, contract: parent.contract,