Commit c2a6cbdbec

c2a6cbdbec0e5dcf0b9714ad9c287347ce5ee6e3

parent: c5be40147c

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 03:59 UTC

help: noun and verb layouts, flags and examples from the registry

Ref #254
internal/control/control.go +4 −56
@@ -70,10 +70,10 @@ func (c *Ctx) usageWith(msg string) int {
7070
7171// Flag is one flag in a command's help.
7272type Flag struct {
73 Name string // "--state"
74 Arg string // "open|closed|all"; empty for a switch
75 Desc string // what it does, lower case, no full stop
76 Default string // empty for none
73 Name string `json:"name"` // "--state"
74 Arg string `json:"arg,omitempty"` // "open|closed|all"; empty for a switch
75 Desc string `json:"desc,omitempty"` // what it does, lower case, no full stop
76 Default string `json:"default,omitempty"` // empty for none
7777}
7878
7979type Command struct {
@@ -325,58 +325,6 @@ func (c *Ctx) fail(code int, format string, args ...any) int {
325325 return code
326326}
327327
328func init() {
329 register(Command{
330 Path: []string{"help"},
331 Summary: "list available commands",
332 Usage: "help [<prefix>...]",
333 Examples: []string{"help", "help repo"},
334 ReadOnly: true,
335 Run: runHelp,
336 })
337}
338
339// helpEntry is one row of the registry as help reports it.
340type helpEntry struct {
341 Path string `json:"path"`
342 Summary string `json:"summary"`
343 Usage string `json:"usage"`
344}
345
346// runHelp lists the registry, sorted, so a noun's commands sit together.
347// A prefix narrows the listing and adds each command's argument syntax —
348// the only place flags are written down. The unfiltered listing stays one
349// line per command.
350func runHelp(c *Ctx, args []string) int {
351 prefix := joinPath(args)
352 var matched []helpEntry
353 for _, cmd := range registry {
354 p := joinPath(cmd.Path)
355 if prefix != "" && p != prefix && !strings.HasPrefix(p, prefix+" ") {
356 continue
357 }
358 matched = append(matched, helpEntry{Path: p, Summary: cmd.Summary, Usage: cmd.Usage})
359 }
360 if len(matched) == 0 {
361 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
362 }
363 slices.SortFunc(matched, func(a, b helpEntry) int { return strings.Compare(a.Path, b.Path) })
364 return c.emit(matched, func(w io.Writer) {
365 for _, e := range matched {
366 summary := e.Summary
367 if c.Term.Cols > 0 {
368 if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
369 summary = clip(summary, avail)
370 }
371 }
372 fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
373 if prefix != "" {
374 fmt.Fprintf(w, " %s\n", e.Usage)
375 }
376 }
377 })
378}
379
380328func joinPath(p []string) string {
381329 out := ""
382330 for i, s := range p {
internal/control/control_test.go +8 −12
@@ -125,25 +125,21 @@ func TestEveryCommandDocumentsItsUsage(t *testing.T) {
125125 }
126126}
127127
128// TestHelpPrefixNarrowsAndShowsFlags covers the reason the command exists:
129// before this, reading one command's flags meant reading all of them.
130func TestHelpPrefixNarrowsAndShowsFlags(t *testing.T) {
128// TestHelpPrefixNarrowsToTheNoun covers the reason the command exists:
129// before this, reading one command's flags meant reading all of them. A
130// noun prefix now lists its verbs under READ/WRITE; a verb's own flags
131// are on `help <noun> <verb>` (TestHelpVerb, help_test.go).
132func TestHelpPrefixNarrowsToTheNoun(t *testing.T) {
131133 var buf bytes.Buffer
132134 c := &Ctx{Stdout: &buf, Stderr: io.Discard}
133135 if code := runHelp(c, []string{"issue"}); code != protocol.ExitOK {
134136 t.Fatalf("help issue exited %d", code)
135137 }
136138 out := buf.String()
137 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
138 if strings.HasPrefix(line, " ") {
139 continue // the indented usage line
139 for _, want := range []string{"READ\n", " list", "WRITE\n", " create", "issue <verb> --help for flags.\n"} {
140 if !strings.Contains(out, want) {
141 t.Errorf("missing %q in:\n%s", want, out)
140142 }
141 if !strings.HasPrefix(line, "issue ") {
142 t.Errorf("help issue listed an unrelated command: %q", line)
143 }
144 }
145 if !strings.Contains(out, "--state open|closed|all") {
146 t.Error("help issue did not print issue list's flags")
147143 }
148144}
149145
internal/control/help.go added +230
@@ -0,0 +1,230 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "slices"
7 "strings"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/termtext"
11)
12
13func init() {
14 register(Command{
15 Path: []string{"help"},
16 Summary: "list available commands",
17 Usage: "help [<prefix>...]",
18 Examples: []string{"help", "help repo"},
19 ReadOnly: true,
20 Run: runHelp,
21 })
22}
23
24// nounSummaries is one line per distinct first path element in the
25// registry, reusing the CLI's group short texts (cmd/gitbay/main.go)
26// where the noun matches, so the two agree.
27var nounSummaries = map[string]string{
28 "account": "export or import your account, for instance migration",
29 "admin": "instance administration (admins)",
30 "audit": "instance audit log (admins)",
31 "build": "CI builds",
32 "dashboard": "pinned repos, open MRs, assigned issues, recent builds",
33 "email": "manage email addresses",
34 "explore": "public repositories on this instance",
35 "feed": "activity on repositories you can reach",
36 "help": "list available commands",
37 "issue": "issues",
38 "keys": "manage SSH keys",
39 "label": "issue labels",
40 "milestone": "group issues and MRs toward a release",
41 "mr": "merge requests",
42 "notifications": "your notification inbox",
43 "org": "organizations",
44 "pgp": "manage OpenPGP keys",
45 "profile": "user and org profiles",
46 "register": "create an account on this instance",
47 "release": "tag-anchored releases with notes and assets",
48 "repo": "create and manage repositories",
49 "runner": "the claim/report loop CI runners use",
50 "search": "find repositories, issues and merge requests",
51 "snippet": "shared text files, outside any repository",
52 "status": "commit statuses (CI)",
53 "token": "API tokens (minted over SSH, used with the JSON API)",
54 "web": "browser session",
55 "webhook": "outbound event delivery",
56 "whoami": "show the authenticated account",
57 "wiki": "a repository's wiki pages",
58}
59
60// NounSummaries returns nounSummaries, for the CLI group-text agreement
61// test (task 4.5).
62func NounSummaries() map[string]string { return nounSummaries }
63
64// helpEntry is one row of the registry as help reports it.
65type helpEntry struct {
66 Path string `json:"path"`
67 Summary string `json:"summary"`
68 Usage string `json:"usage"`
69 Flags []Flag `json:"flags,omitempty"`
70 Examples []string `json:"examples,omitempty"`
71}
72
73// runHelp lists the registry, sorted, so a noun's commands sit together.
74// Bare `help` stays one line per command. A prefix that names one command
75// exactly renders its full USAGE/FLAGS/EXAMPLES; a prefix that names a
76// noun with several commands under it renders a READ/WRITE summary.
77func runHelp(c *Ctx, args []string) int {
78 prefix := joinPath(args)
79 var matched []Command
80 for _, cmd := range registry {
81 p := joinPath(cmd.Path)
82 if prefix == "" || p == prefix || strings.HasPrefix(p, prefix+" ") {
83 matched = append(matched, cmd)
84 }
85 }
86 if len(matched) == 0 {
87 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
88 }
89 slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
90 entries := make([]helpEntry, len(matched))
91 for i, cmd := range matched {
92 entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
93 }
94 return c.emit(entries, func(w io.Writer) {
95 switch {
96 case prefix == "":
97 for _, e := range entries {
98 summary := e.Summary
99 if c.Term.Cols > 0 {
100 if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
101 summary = clip(summary, avail)
102 }
103 }
104 fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
105 }
106 case joinPath(matched[0].Path) == prefix:
107 c.helpVerb(w, matched[0], matched[1:])
108 default:
109 c.helpNoun(w, prefix, matched)
110 }
111 })
112}
113
114// program is how help spells the command it documents: the CLI at a
115// terminal (only the CLI sends GITBAY_TERM), ssh otherwise.
116func (c *Ctx) program() string {
117 if c.Term.Cols > 0 {
118 return "gitbay"
119 }
120 return "ssh git@" + hostOf(c.Cfg.Server.SiteURL)
121}
122
123func (c *Ctx) heading(w io.Writer, s string) {
124 fmt.Fprintln(w, c.Term.paint(sgrBold, s))
125}
126
127// wrapLine prints prefix+text, wrapping text at Term.Cols with a hanging
128// indent under prefix when it would overflow. Plain output never wraps.
129func (c *Ctx) wrapLine(w io.Writer, prefix, text string) {
130 if c.Term.Cols <= 0 || cells(prefix+text) <= c.Term.Cols {
131 fmt.Fprintln(w, prefix+text)
132 return
133 }
134 indent := cells(prefix)
135 width := c.Term.Cols - indent
136 for i, line := range termtext.Wrap(text, width) {
137 if i == 0 {
138 fmt.Fprintln(w, prefix+line)
139 } else {
140 fmt.Fprintln(w, strings.Repeat(" ", indent)+line)
141 }
142 }
143}
144
145func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
146 fmt.Fprintln(w, cmd.Summary)
147 fmt.Fprintln(w)
148 c.heading(w, "USAGE")
149 shape := cmd.Usage
150 if i := strings.Index(shape, " [--"); i >= 0 {
151 shape = shape[:i]
152 } else if i := strings.Index(shape, " --"); i >= 0 {
153 shape = shape[:i]
154 }
155 if c.Term.Cols > 0 {
156 shape = strings.Replace(shape, "<owner/name>", "[<owner/name>]", 1)
157 }
158 if len(cmd.Flags) > 0 {
159 shape += " [flags]"
160 }
161 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
162 fmt.Fprintln(w)
163 c.heading(w, "FLAGS")
164 rows := make([][2]string, 0, len(cmd.Flags)+1)
165 for _, f := range cmd.Flags {
166 name := f.Name
167 if f.Arg != "" {
168 name += " " + f.Arg
169 }
170 desc := f.Desc
171 if f.Default != "" {
172 desc += " (default " + f.Default + ")"
173 }
174 rows = append(rows, [2]string{name, desc})
175 }
176 rows = append(rows, [2]string{"--json", "machine-readable output"})
177 wide := 0
178 for _, r := range rows {
179 wide = max(wide, cells(r[0]))
180 }
181 for _, r := range rows {
182 c.wrapLine(w, " "+pad(r[0], wide)+" ", r[1])
183 }
184 if len(cmd.Examples) > 0 {
185 fmt.Fprintln(w)
186 c.heading(w, "EXAMPLES")
187 for _, ex := range cmd.Examples {
188 c.wrapLine(w, " "+c.program()+" ", ex)
189 }
190 }
191 if len(below) > 0 {
192 fmt.Fprintln(w)
193 c.heading(w, "SEE ALSO")
194 for _, b := range below {
195 fmt.Fprintf(w, " %s %s\n", c.program(), joinPath(b.Path))
196 }
197 }
198}
199
200func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command) {
201 head := nounSummaries[strings.Fields(prefix)[0]]
202 fmt.Fprintln(w, head)
203 fmt.Fprintln(w)
204 c.heading(w, "USAGE")
205 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), prefix)
206 wide := 0
207 for _, cmd := range cmds {
208 wide = max(wide, cells(strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")))
209 }
210 for _, section := range []struct {
211 title string
212 read bool
213 }{{"READ", true}, {"WRITE", false}} {
214 first := true
215 for _, cmd := range cmds {
216 if cmd.ReadOnly != section.read {
217 continue
218 }
219 if first {
220 fmt.Fprintln(w)
221 c.heading(w, section.title)
222 first = false
223 }
224 verb := strings.TrimPrefix(joinPath(cmd.Path), prefix+" ")
225 fmt.Fprintf(w, " %s %s\n", pad(verb, wide), cmd.Summary)
226 }
227 }
228 fmt.Fprintln(w)
229 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), prefix)
230}
internal/control/help_test.go +56
@@ -1,6 +1,7 @@
11package control
22
33import (
4 "bytes"
45 "regexp"
56 "slices"
67 "strings"
@@ -9,6 +10,61 @@ import (
910 "gitbay.org/gitbay/internal/protocol"
1011)
1112
13func helpOut(t *testing.T, term Term, prefix ...string) string {
14 t.Helper()
15 var out, errOut bytes.Buffer
16 c := &Ctx{Stdout: &out, Stderr: &errOut, Term: term, Scope: "full"}
17 c.Cfg.Server.SiteURL = "https://forge.test"
18 if code := Dispatch(c, append([]string{"help"}, prefix...)); code != protocol.ExitOK {
19 t.Fatalf("help %v: exit %d: %s", prefix, code, errOut.String())
20 }
21 return out.String()
22}
23
24func TestHelpVerb(t *testing.T) {
25 out := helpOut(t, Term{Cols: 100}, "issue", "list")
26 for _, want := range []string{
27 "list issues\n",
28 "USAGE\n gitbay issue list [<owner/name>] [flags]\n",
29 "FLAGS\n",
30 " --state open|closed|all",
31 "which issues (default open)\n",
32 " --json",
33 "EXAMPLES\n gitbay issue list krz/gitbay --label bug --state all\n",
34 } {
35 if !strings.Contains(out, want) {
36 t.Errorf("missing %q in:\n%s", want, out)
37 }
38 }
39 plain := helpOut(t, Term{}, "issue", "list")
40 if !strings.Contains(plain, " ssh git@forge.test issue list krz/gitbay --label bug --state all\n") {
41 t.Errorf("plain examples not ssh:\n%s", plain)
42 }
43 if !strings.Contains(plain, "USAGE\n ssh git@forge.test issue list <owner/name> [flags]\n") {
44 t.Errorf("plain usage:\n%s", plain)
45 }
46}
47
48func TestHelpNoun(t *testing.T) {
49 out := helpOut(t, Term{Cols: 100}, "issue")
50 for _, want := range []string{"issues\n", "READ\n", "WRITE\n", " list ", " create ", "gitbay issue <verb> --help for flags.\n"} {
51 if !strings.Contains(out, want) {
52 t.Errorf("missing %q in:\n%s", want, out)
53 }
54 }
55 if strings.Index(out, " list ") > strings.Index(out, "WRITE") {
56 t.Errorf("list is not under READ:\n%s", out)
57 }
58}
59
60func TestEveryNounHasASummary(t *testing.T) {
61 for _, cmd := range Commands() {
62 if nounSummaries[cmd.Path[0]] == "" {
63 t.Errorf("no noun summary for %q", cmd.Path[0])
64 }
65 }
66}
67
1268var usageFlag = regexp.MustCompile(`--[a-z][a-z0-9-]*`)
1369
1470// trailingRedirect strips a shell redirect an example ends with, the way a