CLI: Add "photoprism show commands" command to generate CLI docs #5220

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-21 19:34:39 +02:00
parent f1c57c72d8
commit d6cb6b7a2e
5 changed files with 780 additions and 0 deletions

View File

@@ -0,0 +1,340 @@
/*
Package catalog provides DTOs, builders, and a templated renderer to export the PhotoPrism CLI command tree (and flags) as Markdown or JSON for documentation purposes.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package catalog
import (
"bytes"
"sort"
"strings"
"text/template"
"github.com/urfave/cli/v2"
)
// Flag describes a CLI flag.
type Flag struct {
Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"`
Type string `json:"type"`
Required bool `json:"required,omitempty"`
Default string `json:"default,omitempty"`
Env []string `json:"env,omitempty"`
Category string `json:"category,omitempty"`
Usage string `json:"usage,omitempty"`
Hidden bool `json:"hidden"`
}
// Command describes a CLI command (flat form).
type Command struct {
Name string `json:"name"`
FullName string `json:"full_name"`
Parent string `json:"parent,omitempty"`
Depth int `json:"depth"`
Usage string `json:"usage,omitempty"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Aliases []string `json:"aliases,omitempty"`
ArgsUsage string `json:"args_usage,omitempty"`
Hidden bool `json:"hidden"`
Flags []Flag `json:"flags,omitempty"`
}
// Node is a nested representation of commands.
type Node struct {
Command
Subcommands []Node `json:"subcommands,omitempty"`
}
// App carries app metadata for top-level JSON/MD.
type App struct {
Name string `json:"name"`
Edition string `json:"edition"`
Version string `json:"version"`
Build string `json:"build,omitempty"`
}
// MarkdownData is the data model used by the Markdown template.
type MarkdownData struct {
App App
GeneratedAt string
BaseHeading int
Short bool
All bool
Commands []Command
}
// BuildFlat returns a depth-first flat list of commands starting at c.
func BuildFlat(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) []Command {
var out []Command
info := CommandInfo(c, depth, parentFull, includeHidden, global)
out = append(out, info)
for _, sub := range c.Subcommands {
if sub == nil || (sub.Hidden && !includeHidden) {
continue
}
out = append(out, BuildFlat(sub, depth+1, info.FullName, includeHidden, global)...)
}
return out
}
// BuildNode returns a nested representation of c and its subcommands.
func BuildNode(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) Node {
info := CommandInfo(c, depth, parentFull, includeHidden, global)
node := Node{Command: info}
for _, sub := range c.Subcommands {
if sub == nil || (sub.Hidden && !includeHidden) {
continue
}
node.Subcommands = append(node.Subcommands, BuildNode(sub, depth+1, info.FullName, includeHidden, global))
}
return node
}
// CommandInfo converts a cli.Command to a Command DTO.
func CommandInfo(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) Command {
pathName := c.Name
fullName := strings.TrimSpace(parentFull + " " + pathName)
parent := parentFull
cmd := Command{
Name: pathName,
FullName: fullName,
Parent: parent,
Depth: depth,
Usage: c.Usage,
Description: strings.TrimSpace(c.Description),
Category: c.Category,
Aliases: c.Aliases,
ArgsUsage: c.ArgsUsage,
Hidden: c.Hidden,
}
// Build set of canonical global flag names to exclude from per-command flags
globalSet := map[string]struct{}{}
for _, gf := range global {
globalSet[strings.TrimLeft(gf.Name, "-")] = struct{}{}
}
// Convert flags and optionally filter hidden/global
flags := FlagsToCatalog(c.Flags, includeHidden)
keep := make([]Flag, 0, len(flags))
for _, f := range flags {
name := strings.TrimLeft(f.Name, "-")
if _, isGlobal := globalSet[name]; isGlobal {
continue
}
if !includeHidden && f.Hidden {
continue
}
keep = append(keep, f)
}
sort.Slice(keep, func(i, j int) bool { return keep[i].Name < keep[j].Name })
cmd.Flags = keep
return cmd
}
// FlagsToCatalog converts cli flags to Flag DTOs, filtering hidden if needed.
func FlagsToCatalog(flags []cli.Flag, includeHidden bool) []Flag {
out := make([]Flag, 0, len(flags))
for _, f := range flags {
if f == nil {
continue
}
cf := DescribeFlag(f)
if !includeHidden && cf.Hidden {
continue
}
out = append(out, cf)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// DescribeFlag inspects a cli.Flag and returns a Flag with metadata.
func DescribeFlag(f cli.Flag) Flag {
// Names and aliases
names := f.Names()
primary := ""
aliases := make([]string, 0, len(names))
for i, n := range names {
if i == 0 {
primary = n
}
if primary == "" || (len(n) > 1 && primary != n) {
primary = n
}
}
for _, n := range names {
if n == primary {
continue
}
if len(n) == 1 {
aliases = append(aliases, "-"+n)
} else {
aliases = append(aliases, "--"+n)
}
}
type hasUsage interface{ GetUsage() string }
type hasCategory interface{ GetCategory() string }
type hasEnv interface{ GetEnvVars() []string }
type hasDefault interface{ GetDefaultText() string }
type hasRequired interface{ IsRequired() bool }
type hasVisible interface{ IsVisible() bool }
usage, category, def := "", "", ""
env := []string{}
required, hidden := false, false
if hf, ok := f.(hasUsage); ok {
usage = hf.GetUsage()
}
if hf, ok := f.(hasCategory); ok {
category = hf.GetCategory()
}
if hf, ok := f.(hasEnv); ok {
env = append(env, hf.GetEnvVars()...)
}
if hf, ok := f.(hasDefault); ok {
def = hf.GetDefaultText()
}
if hf, ok := f.(hasRequired); ok {
required = hf.IsRequired()
}
if hv, ok := f.(hasVisible); ok {
hidden = !hv.IsVisible()
}
t := flagTypeString(f)
name := primary
if len(name) == 1 {
name = "-" + name
} else {
name = "--" + name
}
return Flag{
Name: name, Aliases: aliases, Type: t, Required: required, Default: def,
Env: env, Category: category, Usage: usage, Hidden: hidden,
}
}
func flagTypeString(f cli.Flag) string {
switch f.(type) {
case *cli.BoolFlag:
return "bool"
case *cli.StringFlag:
return "string"
case *cli.IntFlag:
return "int"
case *cli.Int64Flag:
return "int64"
case *cli.UintFlag:
return "uint"
case *cli.Uint64Flag:
return "uint64"
case *cli.Float64Flag:
return "float64"
case *cli.DurationFlag:
return "duration"
case *cli.TimestampFlag:
return "timestamp"
case *cli.PathFlag:
return "path"
case *cli.StringSliceFlag:
return "stringSlice"
case *cli.IntSliceFlag:
return "intSlice"
case *cli.Int64SliceFlag:
return "int64Slice"
case *cli.Float64SliceFlag:
return "float64Slice"
case *cli.GenericFlag:
return "generic"
default:
return "unknown"
}
}
// Default Markdown template (adjustable in source via rebuild).
var commandsMDTemplate = `# {{ .App.Name }} CLI Commands ({{ .App.Edition }}) — {{ .App.Version }}
_Generated: {{ .GeneratedAt }}_
{{- $base := .BaseHeading -}}
{{- range .Commands }}
{{ heading (add $base (dec .Depth)) }} {{ .FullName }}
**Usage:** {{ .Usage }}
{{- if .Description }}
**Description:** {{ .Description }}
{{- end }}
{{- if .Aliases }}
**Aliases:** {{ join .Aliases ", " }}
{{- end }}
{{- if .ArgsUsage }}
**Args:** ` + "`" + `{{ .ArgsUsage }}` + "`" + `
{{- end }}
{{- if and (not $.Short) .Flags }}
| Flag | Aliases | Type | Default | Env | Required |{{ if $.All }} Hidden |{{ end }} Usage |
|:-----|:--------|:-----|:--------|:----|:---------|{{ if $.All }}:------:|{{ end }}:------|
{{- range .Flags }}
| ` + "`" + `{{ .Name }}` + "`" + ` | {{ join .Aliases ", " }} | {{ .Type }} | {{ .Default }} | {{ join .Env ", " }} | {{ .Required }} |{{ if $.All }} {{ .Hidden }} |{{ end }} {{ .Usage }} |
{{- end }}
{{- end }}
{{- end }}`
// RenderMarkdown renders the catalog to Markdown using the embedded template.
func RenderMarkdown(data MarkdownData) (string, error) {
tmpl, err := template.New("commands").Funcs(template.FuncMap{
"heading": func(n int) string {
if n < 1 {
n = 1
} else if n > 6 {
n = 6
}
return strings.Repeat("#", n)
},
"join": strings.Join,
"add": func(a, b int) int { return a + b },
"dec": func(a int) int {
if a > 0 {
return a - 1
}
return 0
},
}).Parse(commandsMDTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}

View File

@@ -0,0 +1,169 @@
package catalog
import (
"strings"
"testing"
"github.com/urfave/cli/v2"
)
func TestFlagsToCatalog_Visibility(t *testing.T) {
flags := []cli.Flag{
&cli.StringFlag{Name: "config-path", Aliases: []string{"c"}, Usage: "config path", EnvVars: []string{"PHOTOPRISM_CONFIG_PATH"}},
&cli.BoolFlag{Name: "trace", Usage: "enable trace", Hidden: true},
&cli.IntFlag{Name: "count", Usage: "max results", Value: 5, Required: true},
}
vis := FlagsToCatalog(flags, false)
if len(vis) != 2 {
t.Fatalf("expected 2 visible flags, got %d", len(vis))
}
all := FlagsToCatalog(flags, true)
if len(all) != 3 {
t.Fatalf("expected 3 flags with --all, got %d", len(all))
}
// Check hidden is marked correctly when included
var hiddenOK bool
for _, f := range all {
if f.Name == "--trace" && f.Hidden {
hiddenOK = true
}
}
if !hiddenOK {
t.Fatalf("expected hidden flag '--trace' with hidden=true")
}
}
func TestCommandInfo_GlobalFlagElimination(t *testing.T) {
// Define a command with a global-like flag and a local one
cmd := &cli.Command{
Name: "auth",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json"}, // should be filtered out as global
&cli.BoolFlag{Name: "force"},
},
}
globals := FlagsToCatalog([]cli.Flag{&cli.BoolFlag{Name: "json"}}, false)
info := CommandInfo(cmd, 1, "photoprism", false, globals)
if len(info.Flags) != 1 || info.Flags[0].Name != "--force" {
t.Fatalf("expected only '--force' flag, got %+v", info.Flags)
}
}
func TestBuildFlatAndNode(t *testing.T) {
add := &cli.Command{Name: "add"}
rmHidden := &cli.Command{Name: "rm", Hidden: true}
auth := &cli.Command{Name: "auth", Subcommands: []*cli.Command{add, rmHidden}}
globals := FlagsToCatalog(nil, false)
// Flat without hidden
flat := BuildFlat(auth, 1, "photoprism", false, globals)
if len(flat) != 2 { // auth + add
t.Fatalf("expected 2 commands (auth, add), got %d", len(flat))
}
if flat[0].FullName != "photoprism auth" || flat[0].Depth != 1 {
t.Fatalf("unexpected root entry: %+v", flat[0])
}
if flat[1].FullName != "photoprism auth add" || flat[1].Depth != 2 {
t.Fatalf("unexpected child entry: %+v", flat[1])
}
// Nested with hidden
node := BuildNode(auth, 1, "photoprism", true, globals)
if len(node.Subcommands) != 2 {
t.Fatalf("expected 2 subcommands when including hidden, got %d", len(node.Subcommands))
}
}
func TestRenderMarkdown_Headings(t *testing.T) {
data := MarkdownData{
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
GeneratedAt: "",
BaseHeading: 2,
Short: true, // hide flags table to focus on headings
All: false,
Commands: []Command{
{Name: "auth", FullName: "photoprism auth", Depth: 1},
{Name: "auth add", FullName: "photoprism auth add", Depth: 2},
},
}
out, err := RenderMarkdown(data)
if err != nil {
t.Fatalf("render failed: %v", err)
}
if !strings.Contains(out, "## photoprism auth") {
t.Fatalf("expected '## photoprism auth' heading, got:\n%s", out)
}
if !strings.Contains(out, "### photoprism auth add") {
t.Fatalf("expected '### photoprism auth add' heading, got:\n%s", out)
}
}
func TestRenderMarkdown_HiddenColumn(t *testing.T) {
cmd := Command{
Name: "auth",
FullName: "photoprism auth",
Depth: 1,
Flags: []Flag{
{Name: "--visible", Type: "bool", Hidden: false, Usage: "visible flag"},
{Name: "--secret", Type: "bool", Hidden: true, Usage: "hidden flag"},
},
}
base := MarkdownData{
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
GeneratedAt: "",
BaseHeading: 2,
Short: false,
Commands: []Command{cmd},
}
// Default: no Hidden column
base.All = false
out, err := RenderMarkdown(base)
if err != nil {
t.Fatalf("render failed: %v", err)
}
if strings.Contains(out, " Hidden ") {
t.Fatalf("did not expect 'Hidden' column without --all:\n%s", out)
}
// With --all: Hidden column and boolean value present
base.All = true
out, err = RenderMarkdown(base)
if err != nil {
t.Fatalf("render failed: %v", err)
}
if !strings.Contains(out, " Hidden ") {
t.Fatalf("expected 'Hidden' column with --all:\n%s", out)
}
if !strings.Contains(out, "hidden flag") || !strings.Contains(out, " true ") {
t.Fatalf("expected hidden flag row to include 'true':\n%s", out)
}
}
func TestRenderMarkdown_ShortOmitsFlags(t *testing.T) {
cmd := Command{
Name: "auth",
FullName: "photoprism auth",
Depth: 1,
Flags: []Flag{
{Name: "--json", Type: "bool", Hidden: false, Usage: "json output"},
},
}
data := MarkdownData{
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
GeneratedAt: "",
BaseHeading: 2,
Short: true,
All: false,
Commands: []Command{cmd},
}
out, err := RenderMarkdown(data)
if err != nil {
t.Fatalf("render failed: %v", err)
}
if strings.Contains(out, "| Flag | Aliases | Type |") {
t.Fatalf("did not expect flags table when Short=true:\n%s", out)
}
}

View File

@@ -18,5 +18,6 @@ var ShowCommands = &cli.Command{
ShowThumbSizesCommand, ShowThumbSizesCommand,
ShowVideoSizesCommand, ShowVideoSizesCommand,
ShowMetadataCommand, ShowMetadataCommand,
ShowCommandsCommand,
}, },
} }

View File

@@ -0,0 +1,143 @@
package commands
import (
"encoding/json"
"fmt"
"time"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/commands/catalog"
"github.com/photoprism/photoprism/internal/config"
)
// ShowCommandsCommand configures the command name, flags, and action.
var ShowCommandsCommand = &cli.Command{
Name: "commands",
Usage: "Displays a structured catalog of CLI commands",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "print machine-readable JSON"},
&cli.BoolFlag{Name: "all", Usage: "include hidden commands and flags"},
&cli.BoolFlag{Name: "short", Usage: "omit flags in Markdown output"},
&cli.IntFlag{Name: "base-heading", Value: 2, Usage: "base Markdown heading level"},
&cli.BoolFlag{Name: "nested", Usage: "emit nested JSON structure instead of a flat array"},
},
Action: showCommandsAction,
}
type showCommandsOut struct {
App catalog.App `json:"app"`
GeneratedAt string `json:"generated_at"`
GlobalFlags []catalog.Flag `json:"global_flags,omitempty"`
Commands json.RawMessage `json:"commands"`
}
// showCommandsAction displays a structured catalog of CLI commands.
func showCommandsAction(ctx *cli.Context) error {
// Prefer fast app metadata from the running app; avoid heavy config init in tests
includeHidden := ctx.Bool("all")
wantJSON := ctx.Bool("json")
nested := ctx.Bool("nested")
baseHeading := ctx.Int("base-heading")
if baseHeading < 1 {
baseHeading = 1
}
// Collect the app metadata to be included in the output.
app := catalog.App{}
if ctx != nil && ctx.App != nil && ctx.App.Metadata != nil {
if n, ok := ctx.App.Metadata["Name"].(string); ok {
app.Name = n
}
if e, ok := ctx.App.Metadata["Edition"].(string); ok {
app.Edition = e
}
if v, ok := ctx.App.Metadata["Version"].(string); ok {
app.Version = v
app.Build = v
}
}
if app.Name == "" || app.Version == "" {
conf := config.NewConfig(ctx)
app.Name = conf.Name()
app.Edition = conf.Edition()
app.Version = conf.Version()
app.Build = conf.Version()
}
// Collect global flags from the running app.
var globalFlags []catalog.Flag
if ctx != nil && ctx.App != nil {
globalFlags = catalog.FlagsToCatalog(ctx.App.Flags, includeHidden)
} else {
globalFlags = catalog.FlagsToCatalog(config.Flags.Cli(), includeHidden)
}
// Traverse commands registry using runtime app commands to avoid init cycles.
var flat []catalog.Command
var tree []catalog.Node
var roots []*cli.Command
if ctx != nil && ctx.App != nil {
roots = ctx.App.Commands
}
for _, c := range roots {
if c == nil {
continue
}
if c.Hidden && !includeHidden {
continue
}
if nested {
node := catalog.BuildNode(c, 1, "photoprism", includeHidden, globalFlags)
tree = append(tree, node)
} else {
flat = append(flat, catalog.BuildFlat(c, 1, "photoprism", includeHidden, globalFlags)...)
}
}
// Render JSON output using json.Marshal().
if wantJSON {
var cmds json.RawMessage
var err error
if nested {
cmds, err = json.Marshal(tree)
} else {
cmds, err = json.Marshal(flat)
}
if err != nil {
return err
}
out := showCommandsOut{
App: app,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
GlobalFlags: globalFlags,
Commands: cmds,
}
b, err := json.Marshal(out)
if err != nil {
return err
}
fmt.Println(string(b))
return nil
}
// Render Markdown using embedded template.
data := catalog.MarkdownData{
App: app,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
BaseHeading: baseHeading,
Short: ctx.Bool("short"),
All: includeHidden,
Commands: flat,
}
md, err := catalog.RenderMarkdown(data)
if err != nil {
return err
}
fmt.Println(md)
return nil
}

View File

@@ -0,0 +1,127 @@
package commands
import (
"encoding/json"
"strings"
"testing"
catalogpkg "github.com/photoprism/photoprism/internal/commands/catalog"
)
func TestShowCommands_JSON_Flat(t *testing.T) {
// Build JSON without capturing stdout to avoid pipe blocking on large outputs
ctx := NewTestContext([]string{"commands"})
global := catalogpkg.FlagsToCatalog(ctx.App.Flags, false)
var flat []catalogpkg.Command
for _, c := range ctx.App.Commands {
if c.Hidden {
continue
}
flat = append(flat, catalogpkg.BuildFlat(c, 1, "photoprism", false, global)...)
}
out := struct {
App catalogpkg.App `json:"app"`
GeneratedAt string `json:"generated_at"`
GlobalFlags []catalogpkg.Flag `json:"global_flags"`
Commands []catalogpkg.Command `json:"commands"`
}{
App: catalogpkg.App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
GeneratedAt: "",
GlobalFlags: global,
Commands: flat,
}
b, err := json.Marshal(out)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
// Basic structural checks via unmarshal into a light struct
var v struct {
Commands []struct {
Name, FullName string
Depth int
} `json:"commands"`
GlobalFlags []map[string]interface{} `json:"global_flags"`
}
if err := json.Unmarshal(b, &v); err != nil {
t.Fatalf("invalid json: %v", err)
}
if len(v.Commands) == 0 {
t.Fatalf("expected at least one command")
}
// Expect at least one top-level auth and at least one subcommand overall
var haveAuth, haveAnyChild bool
for _, c := range v.Commands {
if c.Name == "auth" && c.Depth == 1 {
haveAuth = true
}
if c.Depth >= 2 {
haveAnyChild = true
}
}
if !haveAuth {
t.Fatalf("expected to find 'auth' top-level command in list")
}
if !haveAnyChild {
t.Fatalf("expected to find at least one subcommand (depth >= 2)")
}
if len(v.GlobalFlags) == 0 {
t.Fatalf("expected non-empty global_flags")
}
}
func TestShowCommands_JSON_Nested(t *testing.T) {
ctx := NewTestContext([]string{"commands"})
global := catalogpkg.FlagsToCatalog(ctx.App.Flags, false)
var tree []catalogpkg.Node
for _, c := range ctx.App.Commands {
if c.Hidden {
continue
}
tree = append(tree, catalogpkg.BuildNode(c, 1, "photoprism", false, global))
}
b, err := json.Marshal(struct {
Commands []catalogpkg.Node `json:"commands"`
}{Commands: tree})
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var v struct {
Commands []struct {
Name string `json:"name"`
Depth int `json:"depth"`
Subcommands []struct {
Name string `json:"name"`
} `json:"subcommands"`
} `json:"commands"`
}
if err := json.Unmarshal(b, &v); err != nil {
t.Fatalf("invalid json: %v", err)
}
if len(v.Commands) == 0 {
t.Fatalf("expected top-level commands")
}
var hasAuthWithChild bool
for _, c := range v.Commands {
if c.Name == "auth" && c.Depth == 1 && len(c.Subcommands) > 0 {
hasAuthWithChild = true
break
}
}
if !hasAuthWithChild {
t.Fatalf("expected auth with at least one subcommand")
}
}
func TestShowCommands_Markdown_Default(t *testing.T) {
out, err := RunWithTestContext(ShowCommandsCommand, []string{"commands"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expect Markdown headings for commands
if !strings.Contains(out, "## photoprism auth") {
t.Fatalf("expected '## photoprism auth' heading in output\n%s", out[:min(400, len(out))])
}
if !strings.Contains(out, "### photoprism auth ") { // subcommand headings begin with parent
t.Fatalf("expected '### photoprism auth <sub>' heading in output\n%s", out[:min(600, len(out))])
}
}