Skip to content
Merged
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
155 changes: 155 additions & 0 deletions internal/cli/args.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cli

import (
"encoding/json"
"regexp"
"strings"

"github.com/spf13/cobra"
)

// ArgInfo describes a declared positional argument parsed from a command's Use: string.
type ArgInfo struct {
Name string `json:"name"`
Required bool `json:"required"`
Variadic bool `json:"variadic,omitempty"`
Description string `json:"description"`
Kind string `json:"kind"`
}

// argPattern matches bracket-delimited tokens: <required>, [optional], with optional trailing ...
var argPattern = regexp.MustCompile(`[<\[]([^>\]]+)[>\]](\.\.\.)?`)

// ParseArgs extracts structured arg metadata from a cobra command.
// Returns the annotation override if arg_schema is set.
// Returns nil if the command is not runnable (pure group command).
// Otherwise parses the Use: string after stripping [flags] and [action].
func ParseArgs(cmd *cobra.Command) []ArgInfo {
if !cmd.Runnable() {
return nil
}

// Annotation override: return parsed JSON if present.
if schema, ok := cmd.Annotations["arg_schema"]; ok && schema != "" {
var args []ArgInfo
if err := json.Unmarshal([]byte(schema), &args); err == nil {
return args
}
}

use := cmd.Use

// Strip [flags] — cobra convention, never a real arg.
use = strings.ReplaceAll(use, " [flags]", "")

// Strip [action] — subcommand placeholder convention.
use = strings.ReplaceAll(use, " [action]", "")

// Drop the command name (first token) to isolate the arg tokens.
if idx := strings.IndexByte(use, ' '); idx >= 0 {
use = use[idx+1:]
} else {
// No args after command name.
return nil
}

matches := argPattern.FindAllStringSubmatch(use, -1)
if len(matches) == 0 {
return nil
}

args := make([]ArgInfo, 0, len(matches))
for _, m := range matches {
name := m[1]
// Determine required vs optional from the original bracket type.
// Required args use <>, optional use [].
full := m[0]
required := full[0] == '<'
variadic := m[2] == "..."

args = append(args, ArgInfo{
Name: name,
Required: required,
Variadic: variadic,
Description: humanizeArgName(name),
Kind: argKind(name),
})
}
return args
}

// humanizeArgName converts a Use: token name into a human-readable description.
// Rules: | → " or ", -/_ → space, title case with smart casing for id→ID, url→URL.
func humanizeArgName(name string) string {
// Split on | first
parts := strings.Split(name, "|")
for i, p := range parts {
parts[i] = humanizeToken(p)
}
return strings.Join(parts, " or ")
}

// humanizeToken converts a single dash/underscore-separated token to title case.
func humanizeToken(s string) string {
// Replace - and _ with space
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "_", " ")

words := strings.Fields(s)
for i, w := range words {
lower := strings.ToLower(w)
switch lower {
case "id":
words[i] = "ID"
case "url":
words[i] = "URL"
default:
// Title case: capitalize first letter
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
}
return strings.Join(words, " ")
}

// argKind derives a kind string from the arg name by matching whole tokens
// after splitting on |, -, and _. This avoids false positives like "video"
// matching "id".
func argKind(name string) string {
// Split into tokens on delimiters
tokens := strings.FieldsFunc(strings.ToLower(name), func(r rune) bool {
return r == '|' || r == '-' || r == '_'
})
for _, tok := range tokens {
if tok == "id" || tok == "url" {
return "identifier"
}
}
for _, tok := range tokens {
if tok == "date" {
return "date"
}
}
return "text"
}

// ArgDisplay reconstructs bracket notation for display: <content>, [body], <id|url>...
func ArgDisplay(a ArgInfo) string {
var b strings.Builder
if a.Required {
b.WriteByte('<')
} else {
b.WriteByte('[')
}
b.WriteString(a.Name)
if a.Required {
b.WriteByte('>')
} else {
b.WriteByte(']')
}
if a.Variadic {
b.WriteString("...")
}
return b.String()
}
133 changes: 133 additions & 0 deletions internal/cli/args_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package cli

import (
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseArgs(t *testing.T) {
tests := []struct {
use string
runnable bool
want []ArgInfo
}{
{"list", true, nil},
{"todo <content>", true, []ArgInfo{
{Name: "content", Required: true, Description: "Content", Kind: "text"},
}},
{"complete <id|url>...", true, []ArgInfo{
{Name: "id|url", Required: true, Variadic: true, Description: "ID or URL", Kind: "identifier"},
}},
{"show [type] <id|url>", true, []ArgInfo{
{Name: "type", Required: false, Description: "Type", Kind: "text"},
{Name: "id|url", Required: true, Description: "ID or URL", Kind: "identifier"},
}},
{"card <title> [body]", true, []ArgInfo{
{Name: "title", Required: true, Description: "Title", Kind: "text"},
{Name: "body", Required: false, Description: "Body", Kind: "text"},
}},
{"add <person-id>...", true, []ArgInfo{
{Name: "person-id", Required: true, Variadic: true, Description: "Person ID", Kind: "identifier"},
}},
{"create <url> [flags]", true, []ArgInfo{
{Name: "url", Required: true, Description: "URL", Kind: "identifier"},
}},
{"people [action]", true, nil},
{"schedule [action]", true, nil},
{"completion [shell]", true, []ArgInfo{
{Name: "shell", Required: false, Description: "Shell", Kind: "text"},
}},
{"timeline [me]", true, []ArgInfo{
{Name: "me", Required: false, Description: "Me", Kind: "text"},
}},
{"group [action]", false, nil},
}

for _, tt := range tests {
t.Run(tt.use, func(t *testing.T) {
cmd := &cobra.Command{Use: tt.use}
if tt.runnable {
cmd.RunE = func(*cobra.Command, []string) error { return nil }
}
got := ParseArgs(cmd)
assert.Equal(t, tt.want, got)
})
}
}

func TestParseArgsAnnotationOverride(t *testing.T) {
cmd := &cobra.Command{
Use: "dispatch [action]",
Annotations: map[string]string{
"arg_schema": `[{"name":"id","required":true,"description":"Record ID","kind":"identifier"}]`,
},
RunE: func(*cobra.Command, []string) error { return nil },
}
got := ParseArgs(cmd)
require.Len(t, got, 1)
assert.Equal(t, "id", got[0].Name)
assert.True(t, got[0].Required)
assert.Equal(t, "Record ID", got[0].Description)
assert.Equal(t, "identifier", got[0].Kind)
}

func TestHumanizeArgName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"content", "Content"},
{"id|url", "ID or URL"},
{"person-id", "Person ID"},
{"boost-id|url", "Boost ID or URL"},
{"todo_id", "Todo ID"},
{"start_date", "Start Date"},
{"query", "Query"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
assert.Equal(t, tt.want, humanizeArgName(tt.input))
})
}
}

func TestArgKind(t *testing.T) {
tests := []struct {
name string
want string
}{
{"id|url", "identifier"},
{"person-id", "identifier"},
{"url", "identifier"},
{"content", "text"},
{"title", "text"},
{"date", "date"},
{"start_date", "date"},
{"shell", "text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, argKind(tt.name))
})
}
}

func TestArgDisplay(t *testing.T) {
tests := []struct {
arg ArgInfo
want string
}{
{ArgInfo{Name: "content", Required: true}, "<content>"},
{ArgInfo{Name: "body", Required: false}, "[body]"},
{ArgInfo{Name: "id|url", Required: true, Variadic: true}, "<id|url>..."},
{ArgInfo{Name: "shell", Required: false}, "[shell]"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
assert.Equal(t, tt.want, ArgDisplay(tt.arg))
})
}
}
27 changes: 27 additions & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,33 @@ func renderCommandHelp(cmd *cobra.Command) {
b.WriteString("\n")
}

// ARGUMENTS
if args := ParseArgs(cmd); len(args) > 0 {
b.WriteString("\n")
b.WriteString(r.Header.Render("ARGUMENTS"))
b.WriteString("\n")

// Compute max display width for alignment
maxDisplay := 0
displays := make([]string, len(args))
for i, a := range args {
displays[i] = ArgDisplay(a)
if len(displays[i]) > maxDisplay {
maxDisplay = len(displays[i])
}
}
for i, a := range args {
desc := a.Description
if !a.Required {
desc += " (optional)"
}
if a.Variadic {
desc += " (one or more)"
}
fmt.Fprintf(&b, " %-*s %s\n", maxDisplay, displays[i], desc)
}
}

// COMMANDS
if cmd.HasAvailableSubCommands() {
var entries []helpEntry
Expand Down
61 changes: 61 additions & 0 deletions internal/cli/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,64 @@ func TestRootHelpUsesLiveCommandDescriptions(t *testing.T) {
// The help screen should show the command's actual Short, not the catalog's
assert.Contains(t, out, search.Short)
}

func TestAgentHelpIncludesArgs(t *testing.T) {
isolateHelpTest(t)

var buf bytes.Buffer
cmd := NewRootCmd()
cmd.AddCommand(commands.NewTodoCmd())
cmd.SetOut(&buf)
cmd.SetArgs([]string{"todo", "--help", "--agent"})
_ = cmd.Execute()

out := buf.String()
assert.Contains(t, out, `"args"`)
assert.Contains(t, out, `"name":"content"`)
assert.Contains(t, out, `"required":true`)
assert.Contains(t, out, `"kind":"text"`)
}

func TestAgentHelpOmitsArgsWhenEmpty(t *testing.T) {
isolateHelpTest(t)

var buf bytes.Buffer
cmd := NewRootCmd()
cmd.AddCommand(commands.NewProjectsCmd())
cmd.SetOut(&buf)
cmd.SetArgs([]string{"projects", "list", "--help", "--agent"})
_ = cmd.Execute()

out := buf.String()
assert.NotContains(t, out, `"args"`)
}

func TestLeafCommandHelpShowsArguments(t *testing.T) {
isolateHelpTest(t)

var buf bytes.Buffer
cmd := NewRootCmd()
cmd.AddCommand(commands.NewShowCmd())
cmd.SetOut(&buf)
cmd.SetArgs([]string{"show", "--help"})
_ = cmd.Execute()

out := buf.String()
assert.Contains(t, out, "ARGUMENTS")
assert.Contains(t, out, "[type]")
assert.Contains(t, out, "<id|url>")
}

func TestGroupCommandHelpOmitsArguments(t *testing.T) {
isolateHelpTest(t)

var buf bytes.Buffer
cmd := NewRootCmd()
cmd.AddCommand(commands.NewPeopleCmd())
cmd.SetOut(&buf)
cmd.SetArgs([]string{"people", "--help"})
_ = cmd.Execute()

out := buf.String()
assert.NotContains(t, out, "ARGUMENTS")
}
Loading
Loading