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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,8 @@ func main() {
},
}

// Add the hidden __opencli command
ourfave.FromCommand(command, ourfave.WithOutput(os.Stdout))
// Add the hidden __opencli command, plus a visible `docgen` alias
ourfave.FromCommand(command, ourfave.WithPublicCommand("docgen"))

app := command.App()
app.Run(context.Background(), preprocessArgs(os.Args))
Expand All @@ -252,6 +252,18 @@ pleasantries __opencli
# ...
```

`__opencli` is always attached and hidden, so it works as a stable machine-facing
contract. `WithPublicCommand` adds a visible command with the same behaviour so
users (and AI agents reading `--help`) can discover it. Both accept `--format yaml|json`
and `-o/--out <file>`.

The `ocli` CLI accepts a binary in place of a spec file and tries `__opencli`
first, then `docgen`, so it works with CLIs that expose either command:

```sh
ocli check ./pleasantries
ocli gen docs --format markdown --out ./docs ./pleasantries
```
## Ecosystem

Community projects built on the OpenCLI Specification:
Expand Down
62 changes: 45 additions & 17 deletions adapters/ocobra/ocobra.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Package ocobra provides the ability to create OpenCLI documents from existing Cobra CLIs.
//
// It attaches a hidden subcommand to an existing Cobra root command that, when
// invoked, walks the entire command tree and emits a valid OpenCLI spec document.
// It attaches a hidden "__opencli" subcommand (and optionally a visible one via
// WithPublicCommand) to an existing Cobra root command that, when invoked,
// walks the entire command tree and emits a valid OpenCLI spec document.
//
// Typical usage:
//
Expand Down Expand Up @@ -30,6 +31,7 @@ type config struct {
globalFlags []spec.FlagItem
output io.Writer
format codec.Format
publicName string
}

func (c *config) getInfo() spec.Info {
Expand Down Expand Up @@ -64,43 +66,69 @@ func WithFormat(f codec.Format) Option {
return func(c *config) { c.format = f }
}

// WithPublicCommand additionally attaches a visible subcommand with the given
// name (e.g. "docgen") that behaves exactly like the hidden "__opencli" command.
// A visible command advertises in help output that the CLI can describe itself.
func WithPublicCommand(name string) Option {
return func(c *config) { c.publicName = name }
}

// WithOutput sets the io.Writer for the generated spec.
// Defaults to os.Stdout if omitted.
func WithOutput(w io.Writer) Option {
return func(c *config) { c.output = w }
}

// FromCommand attaches a hidden "__opencli" subcommand to rootCmd.
// Running that subcommand walks the Cobra command tree and writes an OpenCLI
// spec document to the configured output (default: stdout).
// generatorAnnotation marks subcommands attached by FromCommand so the tree walk
// can skip them.
const generatorAnnotation = "opencli.generator"

// FromCommand attaches a hidden "__opencli" subcommand to rootCmd, plus a visible
// one when WithPublicCommand is used. Running either subcommand walks the Cobra
// command tree and writes an OpenCLI spec document to the configured output
// (default: stdout).
func FromCommand(rootCmd *cobra.Command, opts ...Option) {
c := defaultConfigWithOptions(opts...)
var flagOut string

rootCmd.AddCommand(newGenCmd(rootCmd, c, "__opencli", true))
if c.publicName != "" {
rootCmd.AddCommand(newGenCmd(rootCmd, c, c.publicName, false))
}
}

func newGenCmd(rootCmd *cobra.Command, c *config, name string, hidden bool) *cobra.Command {
var flagOut, flagFormat string

genCmd := &cobra.Command{
Use: "__opencli",
Short: "Generates OpenCLI specification",
Hidden: true,
Args: cobra.NoArgs,
Use: name,
Short: "Generate the OpenCLI specification for this CLI",
Hidden: hidden,
Annotations: map[string]string{generatorAnnotation: "true"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
out := c.output
if flagOut != "" {
// The user has passed a file to output to
f, err := os.OpenFile(flagOut, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return fmt.Errorf("unable to write opencli doc to specified file: %w", err)
}
defer f.Close()

c.output = f
out = f
}
format := c.format
if flagFormat != "" {
format = codec.Format(flagFormat)
}
doc := documentFromCommand(rootCmd, c)
return writeDoc(c.output, doc, c.format)
return writeDoc(out, doc, format)
},
}

genCmd.Flags().StringVarP(&flagOut, "out", "o", "", "The path to the directory where the generated code will be output")
genCmd.Flags().StringVarP(&flagOut, "out", "o", "", "The path to the file where the generated spec will be written (default: stdout)")
genCmd.Flags().StringVar(&flagFormat, "format", "", "The output format: yaml or json")

rootCmd.AddCommand(genCmd)
return genCmd
}

// GenerateDocument walks a Cobra command tree and returns the corresponding
Expand Down Expand Up @@ -222,8 +250,8 @@ func commandFromCobra(cmd *cobra.Command, parent *spec.CommandItem) *spec.Comman

// Recurse into children
for _, child := range cmd.Commands() {
if child.Use == "__opencli" {
continue // skip our own genator command
if child.Annotations[generatorAnnotation] != "" {
continue // skip our own generator commands
}
childItem := commandFromCobra(child, item)
item.Commands = append(item.Commands, childItem)
Expand Down
31 changes: 31 additions & 0 deletions adapters/ocobra/ocobra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,34 @@ func TestGenerateDocument_NonLocalRootFlagNoDuplication(t *testing.T) {
}
}
}

func TestFromCommand_WithPublicCommand(t *testing.T) {
root := &cobra.Command{Use: "myapp"}
buf := new(bytes.Buffer)
FromCommand(root, WithPublicCommand("docgen"), WithOutput(buf))

var pub *cobra.Command
for _, c := range root.Commands() {
if c.Use == "docgen" {
pub = c
}
}
if pub == nil {
t.Fatal("docgen subcommand not found")
}
if pub.Hidden {
t.Error("docgen should be visible")
}

root.SetArgs([]string{"docgen", "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("docgen failed: %v", err)
}
if !strings.HasPrefix(strings.TrimSpace(buf.String()), "{") {
t.Errorf("expected JSON output, got: %s", buf.String())
}
// Neither generator command may appear in the document itself.
if strings.Contains(buf.String(), "docgen") || strings.Contains(buf.String(), "__opencli") {
t.Error("generator commands must be excluded from the generated document")
}
}
64 changes: 47 additions & 17 deletions adapters/ourfave/ourfave.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Package ourfave provides the ability to create OpenCLI documents from existing urfave/cli v3 CLIs.
//
// It attaches a hidden subcommand to an existing urfave/cli root command that, when
// invoked, walks the entire command tree and emits a valid OpenCLI spec document.
// It attaches a hidden "__opencli" subcommand (and optionally a visible one via
// WithPublicCommand) to an existing urfave/cli root command that, when invoked,
// walks the entire command tree and emits a valid OpenCLI spec document.
//
// Typical usage:
//
Expand Down Expand Up @@ -30,6 +31,7 @@ type config struct {
globalFlags []spec.FlagItem
output io.Writer
format codec.Format
publicName string
}

func (c *config) getInfo() spec.Info {
Expand Down Expand Up @@ -64,43 +66,71 @@ func WithFormat(f codec.Format) Option {
return func(c *config) { c.format = f }
}

// WithPublicCommand additionally attaches a visible subcommand with the given
// name (e.g. "docgen") that behaves exactly like the hidden "__opencli" command.
// A visible command advertises in help output that the CLI can describe itself.
func WithPublicCommand(name string) Option {
return func(c *config) { c.publicName = name }
}

// WithOutput sets the io.Writer for the generated spec.
// Defaults to os.Stdout if omitted.
func WithOutput(w io.Writer) Option {
return func(c *config) { c.output = w }
}

// FromCommand attaches a hidden "__opencli" subcommand to rootCmd.
// Running that subcommand walks the urfave/cli command tree and writes an OpenCLI
// spec document to the configured output (default: stdout).
// generatorMetadata marks subcommands attached by FromCommand so the tree walk
// can skip them.
const generatorMetadata = "opencli.generator"

// FromCommand attaches a hidden "__opencli" subcommand to rootCmd, plus a visible
// one when WithPublicCommand is used. Running either subcommand walks the
// urfave/cli command tree and writes an OpenCLI spec document to the configured
// output (default: stdout).
func FromCommand(rootCmd *cli.Command, opts ...Option) {
c := defaultConfigWithOptions(opts...)

rootCmd.Commands = append(rootCmd.Commands, &cli.Command{
Name: "__opencli",
Hidden: true,
rootCmd.Commands = append(rootCmd.Commands, newGenCmd(rootCmd, c, "__opencli", true))
if c.publicName != "" {
rootCmd.Commands = append(rootCmd.Commands, newGenCmd(rootCmd, c, c.publicName, false))
}
}

func newGenCmd(rootCmd *cli.Command, c *config, name string, hidden bool) *cli.Command {
return &cli.Command{
Name: name,
Usage: "Generate the OpenCLI specification for this CLI",
Hidden: hidden,
Metadata: map[string]any{generatorMetadata: true},
Action: func(ctx context.Context, cmd *cli.Command) error {
outPath := cmd.String("out")
if outPath != "" {
out := c.output
if outPath := cmd.String("out"); outPath != "" {
f, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
return fmt.Errorf("unable to write opencli doc to specified file: %w", err)
}
defer f.Close()

c.output = f
out = f
}
format := c.format
if f := cmd.String("format"); f != "" {
format = codec.Format(f)
}
doc := documentFromCommand(rootCmd, c)
return writeDoc(c.output, doc, c.format)
return writeDoc(out, doc, format)
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "out",
Aliases: []string{"o"},
Usage: "The path to the file where the generated spec will be output",
Usage: "The path to the file where the generated spec will be written (default: stdout)",
},
&cli.StringFlag{
Name: "format",
Usage: "The output format: yaml or json",
},
},
})
}
}

// GenerateDocument walks a urfave/cli command tree and returns the corresponding
Expand Down Expand Up @@ -215,8 +245,8 @@ func commandFromUrfave(cmd *cli.Command, parent *spec.CommandItem, isRoot bool)

// Recurse into children (skip our own generator command)
for _, child := range cmd.Commands {
if child.Name == "__opencli" {
continue
if child.Metadata[generatorMetadata] != nil {
continue // skip our own generator commands
}
childItem := commandFromUrfave(child, item, false)
item.Commands = append(item.Commands, childItem)
Expand Down
30 changes: 30 additions & 0 deletions adapters/ourfave/ourfave_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,33 @@ func TestGenerateDocument_NonLocalRootFlagNoDuplication(t *testing.T) {
}
}
}

func TestFromCommand_WithPublicCommand(t *testing.T) {
root := &cli.Command{Name: "myapp"}
buf := new(bytes.Buffer)
FromCommand(root, WithPublicCommand("docgen"), WithOutput(buf))

var pub *cli.Command
for _, c := range root.Commands {
if c.Name == "docgen" {
pub = c
}
}
if pub == nil {
t.Fatal("docgen subcommand not found")
}
if pub.Hidden {
t.Error("docgen should be visible")
}

if err := root.Run(context.Background(), []string{"myapp", "docgen", "--format", "json"}); err != nil {
t.Fatalf("docgen failed: %v", err)
}
if !strings.HasPrefix(strings.TrimSpace(buf.String()), "{") {
t.Errorf("expected JSON output, got: %s", buf.String())
}
// Neither generator command may appear in the document itself.
if strings.Contains(buf.String(), "docgen") || strings.Contains(buf.String(), "__opencli") {
t.Error("generator commands must be excluded from the generated document")
}
}
8 changes: 6 additions & 2 deletions docs/ocli.ocs.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ by parsing it and checking it against the OpenCLI Specification.
> [!TIP]
> The format of the input file will be determined by the file extension.

The path may also point to a CLI binary built with one of the OpenCLI
adapters. In that case `ocli` runs `<binary> __opencli`, falling back to
`<binary> docgen`, and checks the document it emits.


`string`

Expand Down Expand Up @@ -156,7 +160,7 @@ generated using the `--format` flag.

##### `<path-to-spec>`

Path to the OpenCLI Spec document
Path to the OpenCLI Spec document, or a CLI binary that emits one via `__opencli` or `docgen`

`string`

Expand Down Expand Up @@ -222,7 +226,7 @@ various CLI frameworks from an OpenCLI Spec document.

##### `<path-to-spec>`

The spec used to generate the code
The spec used to generate the code, or a CLI binary that emits one via `__opencli` or `docgen`

`string`

Expand Down
Loading
Loading