docs/plans/2026-09-27-cli-ux.md

1ed9fb9399b21da8e6cf45be8389792aac82d5bc
gitbay/docs/plans/2026-09-27-cli-ux.md rendered · source · history · blame · raw

2544 lines · 94159 bytes

   1# CLI UX small fixes implementation plan
   2
   3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
   4
   5**Goal:** Close #265, #267 and #268: the dashboard's activity feed reads
   6as sentences instead of raw event payloads and stops repeating assigned
   7issues; CLI help and usage print the form the caller actually typed
   8(`gitbay ...` or `ssh git@host ...`), with the `auth` grouping, the
   9`--help` flag and command summaries fixed to match; and five smaller UX
  10findings (the unregistered-key message, `issue create` flags, `mr show`
  11plurals, a `repo readme` command, and a truncated mirror timestamp).
  12
  13**Architecture:** No schema changes and no new migrations. The dashboard
  14and web feed currently keep two copies of "turn a stored event into a
  15sentence" (`internal/httpd/feed.go`) and "the worst of a set of build
  16statuses" (`internal/httpd/builds.go`); both move into
  17`internal/control` so the CLI can call them directly (same package) and
  18`internal/httpd` calls the exported forms. Help and usage already carry
  19a `Ctx.Term` set from the CLI's `--term=<cols>[,color]`; `c.program()`
  20already picks `"gitbay"` or `"ssh git@<host>"` from it for the `--help`
  21path, but `c.usage()`/`c.usageWith()` (the wrong-argument path) do not
  22yet call it. Separately, eighteen CLI commands resolve to a server path
  23that differs from what cobra's tree spells (`cmd/gitbay/main.go`'s
  24`serverPath` annotations): everything under `auth` except a few whose
  25noun already matches the registry (`auth email ...` -> `email ...`,
  26`auth export` -> `account export`, `auth keys ...` -> `keys ...`, `auth
  27pgp ...` -> `pgp ...`, `auth token ...` -> `token ...`, `auth whoami` ->
  28`whoami`), and `repo topics list` -> `repo topics`. Printing the
  29registered path verbatim for one of these gives a command that does not
  30exist — `gitbay keys remove <fp>` is `unknown command "keys"`. #267
  31decided the CLI sends its own invoking path and the server prints that
  32instead of the registered one wherever they differ; the CLI's `auth`
  33grouping, still not a registry path at all, gets the same treatment for
  34the several registry prefixes it gathers.
  35
  36**Tech stack:** Go, `golang.org/x/crypto/ssh`, cobra.
  37
  38**Spec:** none — these are small, independently-scoped fixes; this plan
  39is its own spec.
  40
  41## Global constraints
  42
  43- Three MRs, each on its own branch off `main`: `cli-ux-activity`
  44  (#265), `cli-ux-help` (#267), `cli-ux-fixes` (#268). No MR depends on
  45  either of the others; land in any order.
  46- Commits are signed (the repository refuses unsigned ones); messages
  47  reference the issue they touch (`Ref #N`), and the last commit that
  48  finishes an issue says `Closes #N`. No attribution to any assistant,
  49  model or AI anywhere: commits, MR bodies, comments.
  50- MR: `gitbay mr create --source <branch> --target main --title "..."`;
  51  merge with `gitbay mr merge <n> --strategy ff` once CI is green
  52  (this repository requires signed commits, so `squash`/`merge` are
  53  refused), then delete the branch locally and on the remote. If the
  54  merge reports the branch is behind, rebase onto `main`, force-push,
  55  merge again.
  56- Locally: `go build ./...`, `go vet ./...`, and the unit tests of every
  57  touched package. Run at most the one e2e test being written per task
  58  (`go test ./e2e -run TestName -count=1`); CI on bay1 runs the full
  59  suite.
  60- No new migrations; none of #265/#267/#268 touch the schema. The plan
  61  numbers 0078–0079 pre-assigned to "plan 6" go unused.
  62- Registries that fail CI when a new thing lacks its row: a `ReadOnly`
  63  command needs an entry in `readArgs` in `e2e/readonly_test.go`; a new
  64  control command needs a `pass()` entry in `cmd/gitbay/main.go`
  65  (`cmd/gitbay/summaries_test.go`'s coverage and `summaries_gen.go`
  66  currency checks); a command reading stdin needs `ReadsStdin: true`.
  67- `--json` output: field shapes are unchanged throughout this plan.
  68  Where a task changes plain-text wording it says so; JSON error
  69  strings for usage refusals do change in Part 2 (Task 2.1), which is
  70  called out there specifically since no other task touches JSON text.
  71- The `.gitbay/wiki/Parity.org` page is updated in the same commit that
  72  changes the row it describes (Task 3.4).
  73- Writing style: plain, direct, no hype; code comments match the
  74  surrounding density; no before/after narration in comments or docs.
  75
  76## Order and dependencies
  77
  781. **`cli-ux-activity`** — closes #265. Independent.
  792. **`cli-ux-help`** — closes #267. Independent.
  803. **`cli-ux-fixes`** — closes #268. Independent.
  81
  82None of these three depend on any of the other five plans running in
  83parallel (credentials-and-sessions, ci-trust-and-build-reporting,
  84server-hardening, data-at-rest-and-backup, web-ux); nothing here touches
  85authentication, secrets, CI, backups or the pages those plans change.
  86
  87---
  88
  89# Part 1: dashboard activity, no duplicates, one empty-state wording (branch `cli-ux-activity`, closes #265)
  90
  91### Task 1.1: move the feed-line sentence renderer into `internal/control`
  92
  93The web renders "recent activity" as a sentence (`cmc opened issue #12`)
  94via `internal/httpd/feed.go`'s unexported `feedLine`/`feedLines`, which
  95the CLI cannot reach — `internal/httpd` imports `internal/control`, not
  96the other way around. Move the renderer into `internal/control` so both
  97sides call the same code; `internal/httpd` becomes a thin caller of the
  98exported form.
  99
 100**Files:**
 101- Create: `internal/control/feedline.go` (from `internal/httpd/feed.go`)
 102- Create: `internal/control/feedline_test.go` (from `internal/httpd/feed_test.go`)
 103- Modify: `internal/httpd/builds.go:150-183` (`worstStatus`, `runStatusPriority` move out; `combinedStatus` calls the moved form)
 104- Modify: `internal/httpd/web.go:219`, `:470`, `:493`, `:511` (`feedLine`/`feedLines` → `control.FeedLine`/`control.FeedLines`)
 105- Modify: `internal/httpd/ownerpage_test.go:58` (`feedLine{...}` → `control.FeedLine{...}`)
 106- Delete: `internal/httpd/feed.go`, `internal/httpd/feed_test.go`
 107
 108**Interfaces:**
 109- Produces: `type FeedLine struct{ Actor, Verb, Ref, Repo, URL string; When string; WhenT time.Time; State string; Jobs []string; sha string }` (exported type, one unexported field kept for the fold logic — same package as its only user); `func FeedLines(events []store.FeedEvent) []FeedLine`; `func WorstStatus(statuses []string) string`.
 110- Consumes (Task 1.2, 1.3): the same `FeedLines`/`FeedLine`.
 111
 112- [ ] **Step 1: Run the existing web feed tests to see the baseline pass**
 113
 114Run: `go test ./internal/httpd -run TestFeedLines -count=1`
 115Expected: PASS (nothing changed yet).
 116
 117- [ ] **Step 2: Move the renderer**
 118
 119`git mv internal/httpd/feed.go internal/control/feedline.go` and
 120`git mv internal/httpd/feed_test.go internal/control/feedline_test.go`.
 121In `internal/control/feedline.go`, change `package httpd` to
 122`package control`, capitalize the moved identifiers, and drop the now-
 123unused `"gitbay.org/gitbay/internal/store"` import path prefix
 124adjustments are unnecessary (the import path is the same from either
 125package). Concretely:
 126
 127```go
 128package control
 129
 130import (
 131	"encoding/json"
 132	"fmt"
 133	"slices"
 134	"strings"
 135	"time"
 136
 137	"gitbay.org/gitbay/internal/store"
 138)
 139
 140// FeedLine is one activity entry, already phrased and linked.
 141type FeedLine struct {
 142	Actor string
 143	Verb  string // "opened issue", "merged", "ran 2 jobs on"
 144	Ref   string // "#12", "!35", "v0.4.0", a short sha
 145	Repo  string
 146	URL   string
 147	When  string    // the stored timestamp, for anything still reading it raw
 148	WhenT time.Time // parsed from When, for ago/whenT rendering
 149	State string    // a build run's combined status; empty for anything else
 150	Jobs  []string  // job names folded into a build run
 151	sha   string    // the commit a build event fired on, for fold-matching
 152}
 153```
 154
 155Keep the rest of the function bodies (`FeedLines`, `issueVerb`, `mrVerb`,
 156`parseEventTime`) unchanged apart from `feedLines` → `FeedLines` and
 157`feedLine{` → `FeedLine{`; `issueVerb`/`mrVerb`/`parseEventTime` stay
 158unexported (nothing outside the package calls them directly). In
 159`internal/control/feedline_test.go`, change `package httpd` to
 160`package control` and `feedLines(` → `FeedLines(` throughout (ten call
 161sites, all named `feedLines(events)`).
 162
 163- [ ] **Step 3: Move `worstStatus`**
 164
 165In `internal/httpd/builds.go`, cut `runStatusPriority` and `worstStatus`
 166(the two declarations at lines 150–183) and paste them into
 167`internal/control/feedline.go`, renaming `worstStatus` to `WorstStatus`
 168and updating its one internal call site in `FeedLines`
 169(`out[i].State = worstStatus(statuses[i])` → `WorstStatus(...)`). In
 170`internal/httpd/builds.go`, `combinedStatus` becomes:
 171
 172```go
 173func combinedStatus(builds []control.BuildOut) string {
 174	statuses := make([]string, len(builds))
 175	for i, b := range builds {
 176		statuses[i] = b.Status
 177	}
 178	return control.WorstStatus(statuses)
 179}
 180```
 181
 182- [ ] **Step 4: Update `internal/httpd/web.go`'s call sites**
 183
 184Line 219 (`dashboard`'s anonymous struct): `Feed []control.FeedLine`.
 185Line 470: `func (s *Server) ownerFeed(tab, kind, name string) []control.FeedLine`,
 186its final `return feedLines(events)` becomes `return control.FeedLines(events)`.
 187Line 493 inside `dashboard`: `feedLines(events)` → `control.FeedLines(events)`.
 188Line 511 (`ownerPage.Log`): `Log []control.FeedLine`.
 189
 190- [ ] **Step 5: Update `internal/httpd/ownerpage_test.go:58`**
 191
 192```go
 193d.Log = []control.FeedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}}
 194```
 195
 196- [ ] **Step 6: Build and test both packages**
 197
 198Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/httpd -count=1`
 199Expected: PASS. A compile error naming `feedLine`/`feedLines`/`worstStatus`
 200means a call site in `internal/httpd` was missed — `grep -rn
 201"feedLine\|worstStatus" internal/httpd/*.go` should come back empty
 202except inside comments.
 203
 204- [ ] **Step 7: Commit**
 205
 206```bash
 207git add internal/control/feedline.go internal/control/feedline_test.go internal/httpd/feed.go internal/httpd/feed_test.go internal/httpd/builds.go internal/httpd/web.go internal/httpd/ownerpage_test.go
 208git commit -m "control: move the feed-line sentence renderer from httpd, so the CLI can share it" -m "Ref #265"
 209```
 210
 211### Task 1.2: a labelled event's sentence names the labels
 212
 213`issue.labeled`/`mr.labeled` events currently fall through `issueVerb`/
 214`mrVerb`'s default case (`"issue " + s"`, i.e. "issue labeled"), naming
 215neither what changed nor which labels — on the web today, not only in
 216the CLI this plan is fixing. Give both label events their own verb and
 217carry the label list alongside the ref.
 218
 219**Files:**
 220- Modify: `internal/control/feedline.go` (`FeedLine`, `FeedLines`, `issueVerb`, `mrVerb`)
 221- Modify: `internal/control/feedline_test.go`
 222- Modify: `internal/web/templates/dashboard.html:49`, `internal/web/templates/owner.html:39`
 223
 224**Interfaces:**
 225- Produces: `FeedLine.Extra string` — trailing detail rendered after the ref; empty for every event kind but a labelled one.
 226
 227- [ ] **Step 1: Write the failing test**
 228
 229```go
 230func TestFeedLinesNamesTheLabelsOnALabelledIssue(t *testing.T) {
 231	events := []store.FeedEvent{
 232		{RepoPath: "krz/gitbay", Actor: "cmc", Kind: "issue.labeled",
 233			Data: `{"number":262,"labels":["ops","security"]}`},
 234	}
 235	lines := FeedLines(events)
 236	if len(lines) != 1 {
 237		t.Fatalf("FeedLines returned %d lines, want 1", len(lines))
 238	}
 239	l := lines[0]
 240	if l.Verb != "labelled" || l.Ref != "#262" || l.Extra != "ops, security" {
 241		t.Errorf("got %+v", l)
 242	}
 243}
 244
 245func TestFeedLinesNamesTheLabelsOnALabelledMR(t *testing.T) {
 246	events := []store.FeedEvent{
 247		{RepoPath: "krz/gitbay", Actor: "cmc", Kind: "mr.labeled",
 248			Data: `{"number":471,"labels":["review"]}`},
 249	}
 250	lines := FeedLines(events)
 251	if len(lines) != 1 || lines[0].Verb != "labelled" || lines[0].Ref != "!471" || lines[0].Extra != "review" {
 252		t.Errorf("got %+v", lines)
 253	}
 254}
 255```
 256
 257- [ ] **Step 2: Run and see them fail**
 258
 259Run: `go test ./internal/control -run TestFeedLinesNamesTheLabels -count=1`
 260Expected: FAIL (`Verb = "issue labeled"`/`"merge request labeled"`, `Extra` unset).
 261
 262- [ ] **Step 3: Implement**
 263
 264Add a field to the local decode struct and the `FeedLine` type, then set
 265`Extra` for the two label kinds. In `FeedLines`, the local `d` struct
 266gains `Labels []string`:
 267
 268```go
 269var d struct {
 270	Number int64    `json:"number"`
 271	Job    string   `json:"job"`
 272	Tag    string   `json:"tag"`
 273	SHA    string   `json:"sha"`
 274	Labels []string `json:"labels"`
 275}
 276```
 277
 278`FeedLine` gains, after `Jobs`:
 279
 280```go
 281	// Extra is trailing detail shown after the ref: the label list on a
 282	// labelled event, empty for everything else.
 283	Extra string
 284```
 285
 286In the `case "issue":`/`case "mr":` arms, after setting `l.Verb, l.Ref`:
 287
 288```go
 289	case "issue":
 290		l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
 291		l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
 292		if rest == "labeled" {
 293			l.Extra = strings.Join(d.Labels, ", ")
 294		}
 295	case "mr":
 296		l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
 297		l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
 298		if rest == "labeled" {
 299			l.Extra = strings.Join(d.Labels, ", ")
 300		}
 301```
 302
 303`issueVerb` and `mrVerb` each gain a case:
 304
 305```go
 306	case "labeled":
 307		return "labelled"
 308```
 309
 310- [ ] **Step 4: Run**
 311
 312Run: `go test ./internal/control -count=1`
 313Expected: PASS.
 314
 315- [ ] **Step 5: Templates carry `Extra`**
 316
 317`internal/web/templates/dashboard.html:49` and
 318`internal/web/templates/owner.html:39` both gain `{{if .Extra}} {{.Extra}}{{end}}`
 319right after the closing `</a>` of the ref link, before the `<br>`:
 320
 321```
 322{{range .Feed}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a>{{if .Extra}} {{.Extra}}{{end}}<br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
 323```
 324
 325- [ ] **Step 6: Build**
 326
 327Run: `go build ./... && go test ./internal/control ./internal/httpd -count=1`
 328Expected: PASS.
 329
 330- [ ] **Step 7: Commit**
 331
 332```bash
 333git add internal/control/feedline.go internal/control/feedline_test.go internal/web/templates/dashboard.html internal/web/templates/owner.html
 334git commit -m "feed: a labelled event names the labels" -m "Ref #265"
 335```
 336
 337### Task 1.3: dashboard and feed render activity as sentences, not raw payloads
 338
 339`gitbay dashboard`'s "recent activity" table and `gitbay feed` both
 340print the event's kind and its raw JSON payload
 341(`2026-09-28T02:36:51Z cmc issue.labeled krz/gitbay
 342{"number":262,"labels":["ops","security"]}`). Render the same sentence
 343the web shows instead; the payload stays available under `--json`
 344(`DashboardOut.Activity`/`FeedOut.Data` are untouched).
 345
 346**Files:**
 347- Modify: `internal/control/dashboard.go` (`runDashboard`'s `activityRows`, `runFeed`'s plain formatter)
 348- Test: `internal/control/dashboard_test.go`
 349
 350**Interfaces:**
 351- Consumes: `FeedLines`, `FeedLine` (Task 1.1/1.2).
 352
 353- [ ] **Step 1: Write the failing test**
 354
 355```go
 356func TestDashboardActivityIsASentence(t *testing.T) {
 357	c := notifTestCtx(t, "cmc")
 358	repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public")
 359	if err != nil {
 360		t.Fatal(err)
 361	}
 362	repo, err := c.Store.RepoByID(repoID)
 363	if err != nil {
 364		t.Fatal(err)
 365	}
 366	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled", `{"number":262,"labels":["ops","security"]}`)
 367
 368	var out bytes.Buffer
 369	c.Stdout, c.Stderr = &out, &out
 370	if code := runDashboard(c, nil); code != 0 {
 371		t.Fatalf("exit %d: %s", code, out.String())
 372	}
 373	if strings.Contains(out.String(), `{"number"`) {
 374		t.Errorf("raw payload leaked into plain output:\n%s", out.String())
 375	}
 376	if !strings.Contains(out.String(), "cmc labelled krz/gitbay#262 ops, security") {
 377		t.Errorf("no sentence in output:\n%s", out.String())
 378	}
 379}
 380```
 381
 382- [ ] **Step 2: Run and see it fail**
 383
 384Run: `go test ./internal/control -run TestDashboardActivityIsASentence -count=1`
 385Expected: FAIL (output has `KIND`/`DATA` columns and the raw JSON).
 386
 387- [ ] **Step 3: Implement in `runDashboard`**
 388
 389Replace the `activityRows`/`section("recent activity:", ...)` block:
 390
 391```go
 392	lines := FeedLines(events)
 393	activityRows := make([][]cell, len(lines))
 394	for i, l := range lines {
 395		sentence := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, l.Ref)
 396		if l.Extra != "" {
 397			sentence += " " + l.Extra
 398		}
 399		activityRows[i] = []cell{cAge(l.When), cFlex(sentence)}
 400	}
 401	section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
 402```
 403
 404`events` is already in scope (it is what `d.Activity = feedOutputs(events)`
 405was built from, a few lines above); nothing else in `runDashboard` reads
 406it again, so no variable needs renaming.
 407
 408- [ ] **Step 4: Run**
 409
 410Run: `go test ./internal/control -run TestDashboardActivityIsASentence -count=1`
 411Expected: PASS.
 412
 413- [ ] **Step 5: Same fix in `runFeed`, its own failing test first**
 414
 415```go
 416func TestFeedIsASentence(t *testing.T) {
 417	c := notifTestCtx(t, "cmc")
 418	repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public")
 419	if err != nil {
 420		t.Fatal(err)
 421	}
 422	repo, err := c.Store.RepoByID(repoID)
 423	if err != nil {
 424		t.Fatal(err)
 425	}
 426	c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", `{"number":1}`)
 427
 428	var out bytes.Buffer
 429	c.Stdout, c.Stderr = &out, &out
 430	if code := runFeed(c, nil); code != 0 {
 431		t.Fatalf("exit %d: %s", code, out.String())
 432	}
 433	if !strings.Contains(out.String(), "cmc opened issue krz/gitbay#1") {
 434		t.Errorf("no sentence in output:\n%s", out.String())
 435	}
 436}
 437```
 438
 439Run: `go test ./internal/control -run TestFeedIsASentence -count=1`
 440Expected: FAIL.
 441
 442Implement: `runFeed`'s plain closure changes from the five-column
 443`WHEN`/`ACTOR`/`KIND`/`REPO`/`DATA` table to the same two-column shape,
 444built from `FeedLines(events)` (the same `events` slice `runFeed`
 445already queried, before `feedOutputs(events)` is called for `ds`):
 446
 447```go
 448	lines := FeedLines(events)
 449	return c.emitPage(p, ds, next, func(w io.Writer) {
 450		tb := c.table(w, "WHEN", "EVENT")
 451		for _, l := range lines {
 452			sentence := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, l.Ref)
 453			if l.Extra != "" {
 454				sentence += " " + l.Extra
 455			}
 456			tb.row(cAge(l.When), cFlex(sentence))
 457		}
 458		tb.flush()
 459	})
 460```
 461
 462`ds` (the `FeedOut` slice) stays exactly as it was: it is what `--json`
 463still emits, and `emitPage`'s cursor logic pages `ds`, not `lines` — the
 464two slices are always the same length and order since both come from
 465the same `events`.
 466
 467- [ ] **Step 6: Run**
 468
 469Run: `go test ./internal/control -count=1`
 470Expected: PASS. A failure elsewhere in the package on a "recent
 471activity"/"WHEN\tACTOR\tKIND" assertion means an existing test asserted
 472the old five-column shape; update its expectation to the new sentence
 473(the test name will say `TestDashboard...` or `TestFeed...`).
 474
 475- [ ] **Step 7: Commit**
 476
 477```bash
 478git add internal/control/dashboard.go internal/control/dashboard_test.go
 479git commit -m "dashboard, feed: render activity as the web's sentence, not the raw payload" -m "Ref #265"
 480```
 481
 482### Task 1.4: an issue assigned to you no longer repeats in "open issues"
 483
 484`dashboardIssuesQuery` (open issues you are involved in) and
 485`assignedIssuesQuery` (open issues assigned to you) overlap whenever an
 486assigned issue also sits in a repository you can otherwise reach — the
 487common case — so the same issue prints under both "assigned to you:"
 488and "open issues:" on the CLI, and under both lists on the web
 489dashboard, which calls the same two store methods
 490(`internal/httpd/web.go:206-209`). Exclude assigned issues from the
 491"open issues" query; the fix is in the store, so both surfaces get it
 492at once.
 493
 494**Files:**
 495- Modify: `internal/store/dashboard.go` (`dashboardIssuesQuery`)
 496- Test: `internal/store/dashboard_test.go` (create)
 497
 498**Interfaces:**
 499- Consumes: nothing new.
 500- Produces: nothing new (`DashboardIssues` keeps its signature).
 501
 502- [ ] **Step 1: Write the failing test**
 503
 504```go
 505package store
 506
 507import "testing"
 508
 509// An issue assigned to the user is not repeated under DashboardIssues:
 510// AssignedIssues already covers it, and a repository the user can
 511// otherwise reach (here, one they own) is the common case where the two
 512// queries used to overlap (#265).
 513func TestDashboardIssuesExcludesAssignedIssues(t *testing.T) {
 514	s := open(t)
 515	if err := s.MigrateUp(); err != nil {
 516		t.Fatal(err)
 517	}
 518	uid, err := s.CreateUser("cmc", false)
 519	if err != nil {
 520		t.Fatal(err)
 521	}
 522	repoID, err := s.CreateRepo("user", uid, "gitbay", "public")
 523	if err != nil {
 524		t.Fatal(err)
 525	}
 526	repo, err := s.RepoByID(repoID)
 527	if err != nil {
 528		t.Fatal(err)
 529	}
 530	assignedNum, err := s.CreateIssue(repo.ID, uid, "assigned to me", "", "markdown")
 531	if err != nil {
 532		t.Fatal(err)
 533	}
 534	if _, err := s.CreateIssue(repo.ID, uid, "not assigned", "", "markdown"); err != nil {
 535		t.Fatal(err)
 536	}
 537	assigned, err := s.IssueByNumber(repo.ID, assignedNum)
 538	if err != nil {
 539		t.Fatal(err)
 540	}
 541	if err := s.SetIssueAssignee(assigned.ID, uid, true); err != nil {
 542		t.Fatal(err)
 543	}
 544
 545	issues, err := s.DashboardIssues(uid)
 546	if err != nil {
 547		t.Fatal(err)
 548	}
 549	if len(issues) != 1 || issues[0].Title != "not assigned" {
 550		t.Fatalf("DashboardIssues = %+v, want only the unassigned issue", issues)
 551	}
 552	assignedList, err := s.AssignedIssues(uid)
 553	if err != nil {
 554		t.Fatal(err)
 555	}
 556	if len(assignedList) != 1 || assignedList[0].Title != "assigned to me" {
 557		t.Fatalf("AssignedIssues = %+v, want the assigned issue", assignedList)
 558	}
 559}
 560```
 561
 562- [ ] **Step 2: Run and see it fail**
 563
 564Run: `go test ./internal/store -run TestDashboardIssuesExcludesAssignedIssues -count=1`
 565Expected: FAIL (`DashboardIssues` returns both issues).
 566
 567- [ ] **Step 3: Implement**
 568
 569`dashboardIssuesQuery` in `internal/store/dashboard.go` gains one
 570`NOT EXISTS` clause:
 571
 572```go
 573const dashboardIssuesQuery = `
 574	SELECT COALESCE(u.username, o.name) || '/' || r.name,
 575	       x.number, x.title, au.username, x.state, x.updated_at
 576	FROM issues x
 577	JOIN repos r ON r.id = x.repo_id
 578	LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
 579	LEFT JOIN orgs o  ON r.owner_kind = 'org'  AND o.id = r.owner_id
 580	JOIN users au ON au.id = x.author_id
 581	WHERE x.state = 'open' AND ` + involvedCond + `
 582	  AND NOT EXISTS (SELECT 1 FROM issue_assignees ia
 583	                  WHERE ia.issue_id = x.id AND ia.user_id = ?1)
 584	ORDER BY x.updated_at DESC LIMIT 50`
 585```
 586
 587- [ ] **Step 4: Run the new test, then the package and the query-plan guard**
 588
 589Run: `go test ./internal/store -count=1`
 590Expected: PASS, `TestDashboardQueriesUseIndexes`'s `DashboardIssues` case
 591included — a correlated `NOT EXISTS` does not change which index drives
 592the `ORDER BY`, so the plan should still show `issues_recent` with no
 593`USE TEMP B-TREE FOR ORDER BY`. If it does regress, the `NOT EXISTS`
 594subquery needs `issue_assignees`'s existing `(issue_id, user_id)` index
 595(check `migrations/` for its name) rather than a new one — this task
 596does not add a migration.
 597
 598- [ ] **Step 5: Run the CLI package too**
 599
 600Run: `go test ./internal/control -count=1`
 601Expected: PASS. `TestDashboardEmptySectionsSayNone` and any other
 602dashboard test that seeded an assigned issue and expected it under
 603"open issues" needs its expectation updated to match the new,
 604non-overlapping behavior.
 605
 606- [ ] **Step 6: Commit**
 607
 608```bash
 609git add internal/store/dashboard.go internal/store/dashboard_test.go
 610git commit -m "dashboard: an assigned issue no longer repeats under open issues" -m "Ref #265"
 611```
 612
 613### Task 1.5: `notifications list`'s empty state names `--all`
 614
 615An inbox with only read notifications prints the generic `nothing to
 616list` on stderr when `notifications list` is run without `--all`,
 617without saying unread items are what it shows by default.
 618
 619**Files:**
 620- Modify: `internal/control/notifications.go` (`runNotificationsList`)
 621- Test: `internal/control/notifications_test.go`
 622
 623- [ ] **Step 1: Write the failing test**
 624
 625```go
 626func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) {
 627	c, repo, bob := testRepoWithWatcher(t)
 628	// Give bob one notice, then mark it read, so his inbox has rows but
 629	// no unread ones.
 630	c.User = store.User{ID: bob, Username: "bob"}
 631	notify(c, []int64{bob}, notice{repo: repo, kind: "issue", subject: "s", action: "a", path: "x"})
 632	if code := runNotificationsRead(c, []string{"--all"}); code != protocol.ExitOK {
 633		t.Fatalf("mark read: exit %d", code)
 634	}
 635	var out, errOut bytes.Buffer
 636	c.Stdout, c.Stderr = &out, &errOut
 637	if code := runNotificationsList(c, nil); code != protocol.ExitOK {
 638		t.Fatalf("exit %d: %s", code, errOut.String())
 639	}
 640	if got := errOut.String(); got != "no unread notifications (--all for read ones)\n" {
 641		t.Errorf("stderr = %q", got)
 642	}
 643	// --all sees it and stays the generic message when that too is empty.
 644	out.Reset()
 645	errOut.Reset()
 646	if code := runNotificationsList(c, []string{"--all"}); code != protocol.ExitOK {
 647		t.Fatalf("exit %d: %s", code, errOut.String())
 648	}
 649	if !strings.Contains(out.String(), "s") {
 650		t.Errorf("--all did not show the read notice: %q", out.String())
 651	}
 652}
 653```
 654
 655(This test needs `notify` and `notice` — the same helpers
 656`testRepoWithWatcher`'s package already exercises in
 657`notifications_test.go`'s other tests; if their exact names differ,
 658`grep -n "^func notify\b\|^type notice\b" internal/control/*.go` and use
 659what is actually there.)
 660
 661- [ ] **Step 2: Run and see it fail**
 662
 663Run: `go test ./internal/control -run TestNotificationsListEmptyUnreadSaysHowToSeeRead -count=1`
 664Expected: FAIL (stderr is `nothing to list`).
 665
 666- [ ] **Step 3: Implement**
 667
 668In `runNotificationsList`, after `ds` is built and before the `return
 669c.emitPage(...)`:
 670
 671```go
 672	if !c.JSON && !p.active && len(ds) == 0 {
 673		msg := "nothing to list"
 674		if !all {
 675			msg = "no unread notifications (--all for read ones)"
 676		}
 677		fmt.Fprintln(c.Stderr, msg)
 678		return protocol.ExitOK
 679	}
 680	return c.emitPage(p, ds, next, func(w io.Writer) {
 681```
 682
 683This runs before pagination wraps the result (`p.active`, from
 684`--limit`/`--cursor`) and before JSON, both of which already have their
 685own well-defined empty shape (`{"items":[],...}` or a bare `[]`) that
 686this task leaves alone.
 687
 688- [ ] **Step 4: Run**
 689
 690Run: `go test ./internal/control -run TestNotifications -count=1`
 691Expected: PASS.
 692
 693- [ ] **Step 5: Run the package, commit, open the MR**
 694
 695Run: `go test ./internal/control ./internal/store ./internal/httpd -count=1`
 696Expected: PASS.
 697
 698```bash
 699git add internal/control/notifications.go internal/control/notifications_test.go
 700git commit -m "notifications list: name --all when the empty inbox is just read items" -m "Closes #265"
 701git push -u origin cli-ux-activity
 702gitbay mr create --source cli-ux-activity --target main --title "dashboard activity as sentences, no duplicate issues"
 703```
 704
 705Wait for CI, merge with `--strategy ff`, delete the branch both places.
 706
 707---
 708
 709# Part 2: help and usage print the form the caller typed (branch `cli-ux-help`, closes #267)
 710
 711### Task 2.1: the CLI sends the path it typed; usage and help print it
 712
 713`c.usage()` prints the bare *registered* usage (`usage: keys remove
 714<fingerprint>`), with neither the `gitbay` nor the `ssh git@host` prefix
 715`c.program()`/`helpVerb` already use for `--help` — and for eighteen
 716commands (the ones listed in Architecture above) the registered path is
 717not even something a caller can type: `gitbay keys remove <fp>` is
 718`unknown command "keys"`, because the real command is `gitbay auth keys
 719remove <fp>`. Fix both: give every usage/help line the program prefix,
 720mark a leading `<owner/name>` optional at a terminal (the CLI fills it
 721in from the clone's origin remote, `cmd/gitbay/ssh.go`'s `withRepo`;
 722stock ssh never does), and have the CLI tell the server what it was
 723actually typed as, so the server can print that instead of the
 724registered path wherever the two differ.
 725
 726The CLI already tells the server one thing about the calling session
 727this way: `--term=<cols>[,color]`, prepended to the command line by
 728`runSSHPaged` and stripped off `argv[0]` by `Dispatch` before `Lookup`
 729(`internal/control/control.go`). A second, sibling prefix, `--path=<cli
 730path>`, carries what cobra resolved the call to
 731(`cobra.Command.CommandPath()`, minus the leading `gitbay `). It travels
 732as its own `--path=` argument rather than a new field packed into the
 733`--term=` value: that value's `cols[,color]` grammar has no room for a
 734string containing spaces (a CLI path always does), and a second prefix
 735is one more `strings.CutPrefix` in the same loop, not a new mini-parser.
 736Only the gitbay CLI ever sends it — stock ssh has no notion of a "path
 737it resolved to" that differs from what was typed, because what was typed
 738*is* the dispatch path — so `Dispatch` never invents one, and the field
 739stays empty for the web and the API exactly like `Term` does.
 740
 741**Files:**
 742- Modify: `internal/control/control.go` (`Ctx.CLIPath`, `Dispatch`, `usage`, `usageWith`)
 743- Modify: `internal/control/help.go` (`cliUsage`, `shownAs`, `cmdUsage`, `helpVerb`, `helpNoun`)
 744- Modify: `internal/control/control_test.go` (`TestArgumentRefusalsNameTheUsage`, new `TestPathArgument`)
 745- Test: `internal/control/help_test.go`
 746- Modify: `cmd/gitbay/ssh.go` (`cliPathOf`, `withCLIPath`, new)
 747- Modify: `cmd/gitbay/main.go` (`pass`, `runServerHelp`, `runPass`, `group`, `serverHelp`)
 748- Test: `cmd/gitbay/term_test.go` (the two new pure helpers)
 749- Test: `cmd/gitbay/serverpath_test.go` (create)
 750- Test: `e2e/cliusage_test.go` (create)
 751
 752**Interfaces:**
 753- Produces: `Ctx.CLIPath string`; `func cliUsage(usage string) string`;
 754  `func (c *Ctx) shownAs(registered, full string) string`; `func (c
 755  *Ctx) cmdUsage() string`; `func cliPathOf(cmd *cobra.Command) string`;
 756  `func withCLIPath(cliPath string, argv []string) []string`.
 757- Consumes (Task 2.2, 2.3): `Ctx.CLIPath`, `shownAs`, `withCLIPath`, `cliPathOf`.
 758
 759- [ ] **Step 1: Write the failing test for the transport**
 760
 761```go
 762// TestPathArgument: --path= is read only as a leading argument (in
 763// either order with --term=), and never over HTTP — mirrors
 764// TestTermArgument, the mechanism it rides alongside.
 765func TestPathArgument(t *testing.T) {
 766	cases := []struct {
 767		name     string
 768		viaAPI   bool
 769		argv     []string
 770		want     string
 771		wantArgv []string
 772	}{
 773		{"leading", false, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
 774		{"after term", false, []string{"--term=80", "--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
 775		{"before term", false, []string{"--path=auth keys remove", "--term=80", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
 776		{"over HTTP", true, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "", []string{"abc"}},
 777	}
 778	for _, tc := range cases {
 779		c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard}
 780		Dispatch(c, tc.argv)
 781		if c.CLIPath != tc.want {
 782			t.Errorf("%s: CLIPath %q, want %q", tc.name, c.CLIPath, tc.want)
 783		}
 784		if !slices.Equal(c.Argv, tc.wantArgv) {
 785			t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv)
 786		}
 787	}
 788}
 789```
 790
 791- [ ] **Step 2: Run and see it fail**
 792
 793Run: `go test ./internal/control -run TestPathArgument -count=1`
 794Expected: FAIL to compile (`c.CLIPath undefined`).
 795
 796- [ ] **Step 3: Add the field and route it through `Dispatch`**
 797
 798In `internal/control/control.go`, `Ctx` gains a field next to `Term`:
 799
 800```go
 801	// CLIPath is the path the gitbay CLI actually resolved this call to
 802	// (cobra.Command.CommandPath(), from a leading --path=), when it
 803	// differs from the registered path being dispatched (#267) — auth's
 804	// several groupings and repo topics list, today. Empty for stock
 805	// ssh, the web and the API: nothing but the gitbay CLI sends one.
 806	CLIPath string
 807```
 808
 809`Dispatch` strips both leading pseudo-flags in a loop, in whichever
 810order the caller sent them, replacing the single `--term=` check:
 811
 812```go
 813	// A leading --term=<v> selects terminal output for this session, the
 814	// same as GITBAY_TERM; a leading --path=<v> carries the CLI's own
 815	// invoking path when it differs from the one being dispatched
 816	// (#267). Both come off before Lookup, in whichever order the
 817	// caller sent them: Lookup matches argv against a command's Path,
 818	// and either prefix in front would never match one. Over HTTP both
 819	// are dropped unread: the web and the API render no terminal and
 820	// have no CLI path of their own.
 821	for len(argv) > 0 {
 822		if v, ok := strings.CutPrefix(argv[0], "--term="); ok {
 823			if !c.ViaAPI {
 824				c.Term = ParseTerm(v)
 825			}
 826			argv = argv[1:]
 827			continue
 828		}
 829		if v, ok := strings.CutPrefix(argv[0], "--path="); ok {
 830			if !c.ViaAPI {
 831				c.CLIPath = v
 832			}
 833			argv = argv[1:]
 834			continue
 835		}
 836		break
 837	}
 838	if len(argv) == 0 {
 839		return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
 840	}
 841```
 842
 843- [ ] **Step 4: Run**
 844
 845Run: `go test ./internal/control -run "TestPathArgument|TestTermArgument" -count=1`
 846Expected: PASS.
 847
 848- [ ] **Step 5: Write the failing test for rendering**
 849
 850```go
 851func TestCmdUsagePrefixesTheProgram(t *testing.T) {
 852	c := &Ctx{Cmd: Command{Path: []string{"keys", "remove"}, Usage: "keys remove <fingerprint>"}, Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
 853	if got := c.cmdUsage(); got != "ssh git@forge.test keys remove <fingerprint>" {
 854		t.Errorf("ssh form: %q", got)
 855	}
 856	c.Term = Term{Cols: 100}
 857	if got := c.cmdUsage(); got != "gitbay keys remove <fingerprint>" {
 858		t.Errorf("cli form, no CLIPath sent: %q", got)
 859	}
 860	c.CLIPath = "auth keys remove"
 861	if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" {
 862		t.Errorf("cli form, mismatched registered path: %q", got)
 863	}
 864
 865	c2 := &Ctx{Cmd: Command{Path: []string{"repo", "tree"}, Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}}
 866	if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
 867		t.Errorf("optional owner/name: %q", got)
 868	}
 869	c2.CLIPath = "repo tree" // matches the registered path: a no-op
 870	if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
 871		t.Errorf("matching CLIPath changes nothing: %q", got)
 872	}
 873}
 874```
 875
 876- [ ] **Step 6: Run and see it fail**
 877
 878Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1`
 879Expected: FAIL to compile (`c.cmdUsage undefined`).
 880
 881- [ ] **Step 7: Implement `cliUsage`, `shownAs` and `cmdUsage` in `internal/control/help.go`**
 882
 883```go
 884// cliUsage marks a leading <owner/name> optional in a CLI-rendered usage
 885// line: the CLI infers it inside a clone (cmd/gitbay/ssh.go's withRepo),
 886// stock ssh never does. Only the first occurrence is marked — a usage
 887// line never repeats the placeholder.
 888func cliUsage(usage string) string {
 889	return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1)
 890}
 891
 892// shownAs returns how a registered path should print to this caller:
 893// the CLI path it sent (Ctx.CLIPath) standing in for the leading
 894// portion that corresponds to registered, with full's remainder kept
 895// as-is; or full unchanged for stock ssh, the API, or a caller whose
 896// CLI path already agrees with the registered one. registered must be
 897// a genuine leading substring of full (a command's own registered path
 898// always is, against its own Usage or a sibling's full path).
 899func (c *Ctx) shownAs(registered, full string) string {
 900	if c.CLIPath == "" || c.CLIPath == registered {
 901		return full
 902	}
 903	return c.CLIPath + strings.TrimPrefix(full, registered)
 904}
 905
 906// cmdUsage is the registered usage as this call should see it: the CLI
 907// path this session actually typed when it differs from the registered
 908// one (#267), the gitbay form otherwise, the ssh form when there is no
 909// terminal — with a leading <owner/name> marked optional at a terminal.
 910// Every usage message — the --help path and a wrong-argument refusal
 911// alike — goes through this, so a caller never sees a command it
 912// cannot actually run.
 913func (c *Ctx) cmdUsage() string {
 914	registered := joinPath(c.Cmd.Path)
 915	shape := c.shownAs(registered, c.Cmd.Usage)
 916	if c.Term.Cols > 0 {
 917		shape = cliUsage(shape)
 918	}
 919	return c.program() + " " + shape
 920}
 921```
 922
 923- [ ] **Step 8: Run**
 924
 925Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1`
 926Expected: PASS.
 927
 928- [ ] **Step 9: Route `usage`/`usageWith` through it**
 929
 930In `internal/control/control.go`:
 931
 932```go
 933// usage reports a bad invocation with the command's registered usage,
 934// the one source of it.
 935func (c *Ctx) usage() int {
 936	return c.fail(protocol.ExitUsage, "usage: %s", c.cmdUsage())
 937}
 938
 939// usageWith reports a specific problem with the arguments, then the
 940// registered usage, so a person always sees the shape that was expected.
 941func (c *Ctx) usageWith(msg string) int {
 942	return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.cmdUsage())
 943}
 944```
 945
 946- [ ] **Step 10: `helpVerb` gets the same treatment**
 947
 948`helpVerb` (`internal/control/help.go`) recomputes a "cut at ` [--`"
 949shape independently, and lists sibling commands under SEE ALSO by their
 950full registered path. Both go through `shownAs` now:
 951
 952```go
 953func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
 954	fmt.Fprintln(w, cmd.Summary)
 955	fmt.Fprintln(w)
 956	c.heading(w, "USAGE")
 957	// Cutting at the first optional flag drops the rest of the usage
 958	// syntax behind "[flags]" — safe only for what is actually optional.
 959	// A required flag (repo delete --yes) or an alternative
 960	// (notifications read <id>... | --all) has no " [--" to cut at, so
 961	// the usage prints whole.
 962	registered := joinPath(cmd.Path)
 963	shape := c.shownAs(registered, cmd.Usage)
 964	if c.Term.Cols > 0 {
 965		shape = cliUsage(shape)
 966	}
 967	if i := strings.Index(shape, " [--"); i >= 0 {
 968		shape = shape[:i] + " [flags]"
 969	}
 970	fmt.Fprintf(w, "  %s %s\n", c.program(), shape)
 971	fmt.Fprintln(w)
 972	c.heading(w, "FLAGS")
 973	rows := make([][2]string, 0, len(cmd.Flags)+1)
 974	for _, f := range cmd.Flags {
 975		name := f.Name
 976		if f.Arg != "" {
 977			name += " " + f.Arg
 978		}
 979		desc := f.Desc
 980		if f.Default != "" {
 981			desc += " (default " + f.Default + ")"
 982		}
 983		rows = append(rows, [2]string{name, desc})
 984	}
 985	rows = append(rows, [2]string{"--json", "machine-readable output"})
 986	wide := 0
 987	for _, r := range rows {
 988		wide = max(wide, cells(r[0]))
 989	}
 990	for _, r := range rows {
 991		c.wrapLine(w, "  "+pad(r[0], wide)+"  ", r[1])
 992	}
 993	if len(cmd.Examples) > 0 {
 994		fmt.Fprintln(w)
 995		c.heading(w, "EXAMPLES")
 996		for _, ex := range cmd.Examples {
 997			c.wrapLine(w, "  "+c.program()+" ", ex)
 998		}
 999	}
1000	if len(below) > 0 {
1001		fmt.Fprintln(w)
1002		c.heading(w, "SEE ALSO")
1003		for _, b := range below {
1004			fmt.Fprintf(w, "  %s %s\n", c.program(), c.shownAs(registered, joinPath(b.Path)))
1005		}
1006	}
1007}
1008```
1009
1010(Only the `USAGE` and `SEE ALSO` lines change; `FLAGS`/`EXAMPLES` are
1011reproduced above unchanged, for the diff to apply against the current
1012file — do not re-type them from scratch.)
1013
1014- [ ] **Step 11: `helpNoun` gets the same treatment**
1015
1016`helpNoun` prints `{program} {prefix} <verb> ...` and, per command, the
1017verb relative to `prefix`; the prefix itself needs the same swap (Task
10182.3 also gives it an `override` map, threaded through here empty for
1019every noun but the CLI-only `auth` alias):
1020
1021```go
1022func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command, override map[string]string) {
1023	head := nounSummaries[strings.Fields(prefix)[0]]
1024	fmt.Fprintln(w, head)
1025	fmt.Fprintln(w)
1026	c.heading(w, "USAGE")
1027	display := c.shownAs(prefix, prefix)
1028	fmt.Fprintf(w, "  %s %s <verb> ...\n", c.program(), display)
1029	rowText := func(cmd Command) string {
1030		full := joinPath(cmd.Path)
1031		if d, ok := override[full]; ok {
1032			return d
1033		}
1034		return strings.TrimPrefix(full, prefix+" ")
1035	}
1036	wide := 0
1037	for _, cmd := range cmds {
1038		wide = max(wide, cells(rowText(cmd)))
1039	}
1040	for _, section := range []struct {
1041		title string
1042		read  bool
1043	}{{"READ", true}, {"WRITE", false}} {
1044		first := true
1045		for _, cmd := range cmds {
1046			if cmd.ReadOnly != section.read {
1047				continue
1048			}
1049			if first {
1050				fmt.Fprintln(w)
1051				c.heading(w, section.title)
1052				first = false
1053			}
1054			fmt.Fprintf(w, "  %s  %s\n", pad(rowText(cmd), wide), cmd.Summary)
1055		}
1056	}
1057	fmt.Fprintln(w)
1058	fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), display)
1059}
1060```
1061
1062`runHelp`'s one call site becomes `c.helpNoun(w, prefix, matched, nil)`
1063for now; Task 2.3 gives it a real map for the `auth` alias. A `nil`
1064map's zero value behaves like an empty one — `override[full]` on a
1065`nil` map is always `"", false` — so every other noun is unaffected.
1066
1067- [ ] **Step 12: Update the test `usage`/`usageWith` changes**
1068
1069`TestArgumentRefusalsNameTheUsage` in `internal/control/control_test.go`
1070asserts `errOut.String()` contains the bare `"usage: " +
1071strings.Join(argv, " ")`; with no `Cfg.Server.SiteURL` and no `Term` set
1072on its `Ctx`, the message now reads `usage: ssh git@ build show` (an
1073empty host — `hostOf("")` returns `""`). None of these four commands is
1074one of the eighteen with a mismatched CLI path, and the test sends no
1075`--path=`, so `c.CLIPath` stays empty throughout — set a `SiteURL` and
1076assert the plain ssh-prefixed registered form:
1077
1078```go
1079func TestArgumentRefusalsNameTheUsage(t *testing.T) {
1080	for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} {
1081		var out, errOut bytes.Buffer
1082		c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut,
1083			Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
1084		if code := Dispatch(c, argv); code != protocol.ExitUsage {
1085			t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String())
1086			continue
1087		}
1088		want := "usage: ssh git@forge.test " + strings.Join(argv, " ")
1089		if !strings.Contains(errOut.String(), want) {
1090			t.Errorf("%v: no usage line: got %q, want to contain %q", argv, errOut.String(), want)
1091		}
1092	}
1093}
1094```
1095
1096(Add `"gitbay.org/gitbay/internal/config"` to the file's imports if it
1097is not already there.)
1098
1099- [ ] **Step 13: Run the package**
1100
1101Run: `go test ./internal/control -count=1`
1102Expected: PASS. Any other test asserting a bare `"usage: <path>..."` with
1103no program prefix needs the same treatment — `grep -rn '"usage: '
1104internal/control/*_test.go` finds them all; a `help`-rendering test
1105asserting a bare `"gitbay keys ..."` line for one of the eighteen
1106commands needs the CLI-prefixed form instead.
1107
1108- [ ] **Step 14: The CLI side — write the failing tests for the two pure helpers**
1109
1110`cmd/gitbay/ssh.go` needs a way to read a cobra command's own path, and
1111a way to fold it onto a server command line, both pure and cheap to
1112unit test the way `termValue`/`pagerArgv`/`pages` already are (`cmd/gitbay/term_test.go`):
1113
1114```go
1115func TestCLIPathOf(t *testing.T) {
1116	root := &cobra.Command{Use: "gitbay"}
1117	auth := &cobra.Command{Use: "auth"}
1118	keys := &cobra.Command{Use: "keys"}
1119	remove := &cobra.Command{Use: "remove"}
1120	keys.AddCommand(remove)
1121	auth.AddCommand(keys)
1122	root.AddCommand(auth)
1123	if got := cliPathOf(remove); got != "auth keys remove" {
1124		t.Errorf("cliPathOf = %q", got)
1125	}
1126}
1127
1128func TestWithCLIPath(t *testing.T) {
1129	if got := withCLIPath("", []string{"keys", "remove", "abc"}); !slices.Equal(got, []string{"keys", "remove", "abc"}) {
1130		t.Errorf("empty cliPath: %v", got)
1131	}
1132	got := withCLIPath("auth keys remove", []string{"keys", "remove", "abc"})
1133	want := []string{"--path=auth keys remove", "keys", "remove", "abc"}
1134	if !slices.Equal(got, want) {
1135		t.Errorf("got %v, want %v", got, want)
1136	}
1137}
1138```
1139
1140Add these to `cmd/gitbay/term_test.go`, alongside `TestTermValue` and
1141`TestPagerArgv`; add `"slices"` and `"github.com/spf13/cobra"` to its
1142imports if not already there.
1143
1144- [ ] **Step 15: Run and see them fail**
1145
1146Run: `go test ./cmd/gitbay -run "TestCLIPathOf|TestWithCLIPath" -count=1`
1147Expected: FAIL to compile (`cliPathOf`/`withCLIPath` undefined).
1148
1149- [ ] **Step 16: Implement the two helpers in `cmd/gitbay/ssh.go`**
1150
1151Add `"github.com/spf13/cobra"` to the file's imports, then:
1152
1153```go
1154// cliPathOf is the path this cobra command was actually reached by,
1155// stripped of the root's own name: "auth keys remove" for a command
1156// nested under auth > keys > remove. It is sent to the server as
1157// --path=, so usage and help can print what the caller can actually
1158// run even where that differs from the registered path being
1159// dispatched (cmd.Annotations[serverPath]) — #267.
1160func cliPathOf(cmd *cobra.Command) string {
1161	return strings.TrimPrefix(cmd.CommandPath(), "gitbay ")
1162}
1163
1164// withCLIPath prepends --path=<cliPath> to a server command line, the
1165// same way runSSHPaged prepends --term=: a leading pseudo-flag Dispatch
1166// strips before Lookup, never confused for a real argument. Empty
1167// cliPath is a no-op — nothing to add for a caller with no cobra tree
1168// of its own to have resolved.
1169func withCLIPath(cliPath string, argv []string) []string {
1170	if cliPath == "" {
1171		return argv
1172	}
1173	return append([]string{"--path=" + cliPath}, argv...)
1174}
1175```
1176
1177- [ ] **Step 17: Run**
1178
1179Run: `go test ./cmd/gitbay -run "TestCLIPathOf|TestWithCLIPath" -count=1`
1180Expected: PASS.
1181
1182- [ ] **Step 18: Thread `cliPath` through `pass`, `runServerHelp`, `runPass`**
1183
1184In `cmd/gitbay/main.go`:
1185
1186```go
1187func pass(use string, o passOpts) *cobra.Command {
1188	return &cobra.Command{
1189		Use:   use,
1190		Short: summaries[strings.Join(o.server, " ")],
1191		Annotations: map[string]string{
1192			serverPath: strings.Join(o.server, " "),
1193			stdinMode:  o.stdinModeName(),
1194			stdinWhat:  o.stdinWhat,
1195		},
1196		DisableFlagParsing: true,
1197		RunE: func(cmd *cobra.Command, args []string) error {
1198			// The registry is the only place flags are written down, so
1199			// --help asks the server rather than reprinting the one-line
1200			// summary cobra holds.
1201			cliPath := cliPathOf(cmd)
1202			for _, a := range args {
1203				if a == "--help" || a == "-h" {
1204					os.Exit(runServerHelp(o, cliPath))
1205				}
1206			}
1207			os.Exit(runPass(o, cliPath, args))
1208			return nil
1209		},
1210	}
1211}
1212```
1213
1214```go
1215// runServerHelp prints the registry's usage for one command.
1216func runServerHelp(o passOpts, cliPath string) int {
1217	t, err := resolveTarget()
1218	if err != nil {
1219		fmt.Fprintln(os.Stderr, "gitbay:", err)
1220		return protocol.ExitFailure
1221	}
1222	return runSSH(t, withCLIPath(cliPath, append([]string{"help"}, o.server...)), strings.NewReader(""))
1223}
1224
1225func runPass(o passOpts, cliPath string, args []string) int {
1226```
1227
1228(`runPass`'s body is otherwise unchanged; only its signature gains
1229`cliPath string` as the second parameter, and its final line becomes:)
1230
1231```go
1232	return runSSHPaged(t, withCLIPath(cliPath, append(o.server, args...)), stdin, pages(o.server, args))
1233```
1234
1235- [ ] **Step 19: Thread `cliPath` through `group`/`serverHelp`**
1236
1237```go
1238func group(use, short string, subs ...*cobra.Command) *cobra.Command {
1239	c := &cobra.Command{Use: use, Short: short}
1240	c.AddCommand(subs...)
1241	// A noun's help is the server's, like a command's: the registry is
1242	// the only place flags are written down, and cobra's subcommand list
1243	// carried none (#130). Offline, or for a noun the server does not
1244	// know by that name, cobra's own tree still prints.
1245	local := c.HelpFunc()
1246	c.SetHelpFunc(func(cmd *cobra.Command, args []string) {
1247		if !serverHelp(use, cliPathOf(cmd)) {
1248			local(cmd, args)
1249		}
1250	})
1251	return c
1252}
1253
1254// serverHelp prints the registry's usage for a prefix and reports whether
1255// it did. cliPath is this invocation's own resolved cobra path (empty for
1256// a noun whose CLI path already matches its registered prefix). At a
1257// terminal it goes through the terminal-aware path, so it gets the same
1258// --term=<cols>[,color] treatment (and layout) as any other command;
1259// piped, it stays a quiet capture, so a network or lookup failure falls
1260// back to cobra's local help without noise.
1261func serverHelp(prefix, cliPath string) bool {
1262	t, err := resolveTarget()
1263	if err != nil {
1264		return false
1265	}
1266	argv := withCLIPath(cliPath, []string{"help", prefix})
1267	if term.IsTerminal(int(os.Stdout.Fd())) {
1268		return runSSH(t, argv, strings.NewReader("")) == 0
1269	}
1270	out, code := sshCapture(t, argv)
1271	if code != 0 || out == "" {
1272		return false
1273	}
1274	fmt.Print(out)
1275	return true
1276}
1277```
1278
1279- [ ] **Step 20: Build**
1280
1281Run: `go build ./... && go vet ./...`
1282Expected: builds clean. `keysAdd.RunE`/`pgpAdd.RunE` in `authCmd()`
1283(`cmd/gitbay/main.go`) are hand-built, not `pass()`-generated, so they
1284do not pick up `cliPath` from this step — Task 2.2 gives them the same
1285treatment where it already rewrites their bodies.
1286
1287- [ ] **Step 21: The cobra tree is the one place the eighteen mismatches
1288      are allowed to be listed — a coverage test, not a hand check**
1289
1290A future command wired with a `serverPath` that does not match its own
1291`CommandPath()` is exactly this defect happening again; nothing should
1292have to remember to re-check it by hand. `TestEveryCommandIsReachable`
1293(`cmd/gitbay/coverage_test.go`) already walks `newRoot()` comparing
1294`Annotations[serverPath]` against the registry — this test walks the
1295same tree comparing it against `cliPathOf`, and pins today's known set
1296so any change to it (a new mismatch, or one of these being fixed to
1297match) shows up as a diff a reviewer has to look at.
1298
1299```go
1300package main
1301
1302import (
1303	"slices"
1304	"strings"
1305	"testing"
1306
1307	"github.com/spf13/cobra"
1308)
1309
1310// TestServerPathMismatches pins the commands whose CLI path differs from
1311// the server path they dispatch (#267) — cmdUsage/help print the CLI
1312// path for exactly these, from cliPathOf, not the registered one. A new
1313// mismatch changes this list; update it deliberately, alongside the
1314// wiki's Parity page if it changes what a stock-ssh caller must type.
1315func TestServerPathMismatches(t *testing.T) {
1316	want := []string{
1317		"auth email add", "auth email list", "auth email primary",
1318		"auth email remove", "auth email verify",
1319		"auth export",
1320		"auth keys add", "auth keys label", "auth keys list", "auth keys remove",
1321		"auth pgp add", "auth pgp list", "auth pgp remove",
1322		"auth token create", "auth token list", "auth token revoke",
1323		"auth whoami",
1324		"repo topics list",
1325	}
1326
1327	var got []string
1328	var walk func(*cobra.Command)
1329	walk = func(c *cobra.Command) {
1330		if p := c.Annotations[serverPath]; p != "" {
1331			if cli := cliPathOf(c); cli != p {
1332				got = append(got, cli)
1333			}
1334		}
1335		for _, sub := range c.Commands() {
1336			walk(sub)
1337		}
1338	}
1339	root := newRoot()
1340	root.InitDefaultHelpCmd()
1341	walk(root)
1342	slices.Sort(got)
1343
1344	if !slices.Equal(got, want) {
1345		t.Errorf("mismatched CLI paths = %v\nwant %v", got, want)
1346	}
1347}
1348```
1349
1350- [ ] **Step 22: Run**
1351
1352Run: `go test ./cmd/gitbay -run TestServerPathMismatches -count=1`
1353Expected: PASS (the eighteen are already there today; this step only
1354adds the guard, it changes no behavior).
1355
1356- [ ] **Step 23: One end-to-end proof, real ssh and the real binary**
1357
1358Every other test here is a unit test against `Ctx`/cobra values built
1359by hand; this is the one e2e test for this task (Global Constraints
1360caps it at one), proving the wiring — `runSSHPaged`'s `--path=` prepend,
1361the server's `--path=` parse, `cmdUsage`'s substitution — actually
1362reaches an instance over real ssh, for the CLI and for stock ssh alike.
1363Model it on `e2e/term_test.go`'s `TestTermEnvSelectsTerminalOutput`.
1364
1365```go
1366package e2e
1367
1368import (
1369	"strings"
1370	"testing"
1371)
1372
1373// The CLI sends its own invoking path so usage and help print a command
1374// that exists — gitbay auth keys remove, never the unregistered gitbay
1375// keys remove (#267). Stock ssh, which never sends one, keeps seeing
1376// the registered path: it is the only one it could ever type.
1377func TestCLIUsagePrintsTheInvokingPath(t *testing.T) {
1378	t.Parallel()
1379	inst := startInstance(t)
1380	key := inst.newKey(t, "alice")
1381	inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
1382		"--email", "alice@example.test", "--verified")
1383
1384	c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key}
1385	c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
1386		"--port", instPort(inst),
1387		"--ssh-option", "-i", "--ssh-option", key,
1388		"--ssh-option", "-oIdentitiesOnly=yes",
1389		"--ssh-option", "-oStrictHostKeyChecking=no",
1390		"--ssh-option", "-oUserKnownHostsFile="+inst.sshDir+"/kh",
1391		"--ssh-option", "-oBatchMode=yes",
1392		"--default")
1393
1394	// A mismatched command: the CLI path (auth keys remove) differs from
1395	// the registered one (keys remove). No fingerprint given, an actual
1396	// wrong-argument refusal.
1397	_, errOut, code := c.run(t, "", "", "auth", "keys", "remove")
1398	if code == 0 || !strings.Contains(errOut, "usage: gitbay auth keys remove") {
1399		t.Errorf("mismatched command: exit %d, stderr %q", code, errOut)
1400	}
1401	if strings.Contains(errOut, "usage: gitbay keys remove") {
1402		t.Errorf("mismatched command leaked the registered path: %q", errOut)
1403	}
1404
1405	// A matching command: no CLI/registered difference, still the gitbay
1406	// form (it is a terminal-adjacent test binary run, isTTY is false
1407	// here, so this exercises the non-terminal ssh form instead —
1408	// assert on the registered path itself, which is all cmdUsage can
1409	// tell apart in that mode).
1410	_, errOut2, code2 := c.run(t, "", "", "repo", "show")
1411	if code2 == 0 || !strings.Contains(errOut2, "usage: ") || !strings.Contains(errOut2, "repo show") {
1412		t.Errorf("matching command: exit %d, stderr %q", code2, errOut2)
1413	}
1414
1415	// Stock ssh, no CLI involved: the registered path, because it is the
1416	// only one this caller could have typed.
1417	_, errOut3, code3 := inst.ssh(t, key, "", "keys", "remove")
1418	if code3 == 0 || !strings.Contains(errOut3, "usage: ssh git@") || !strings.Contains(errOut3, "keys remove") {
1419		t.Errorf("stock ssh: exit %d, stderr %q", code3, errOut3)
1420	}
1421	if strings.Contains(errOut3, "auth keys remove") {
1422		t.Errorf("stock ssh should never see the CLI-only auth prefix: %q", errOut3)
1423	}
1424}
1425```
1426
1427(`instPort`/`inst.sshDir`/`c.run`/`inst.ssh` are whatever `e2e/cli_test.go`
1428and `e2e/term_test.go` already expose — read both before writing this
1429file and use their actual helper names and signatures rather than the
1430ones guessed here; `TestCLI` in `e2e/cli_test.go` is the fullest existing
1431example of standing up a `cli` value against a live `instance`.)
1432
1433- [ ] **Step 24: Run the one e2e test**
1434
1435Run: `go test ./e2e -run TestCLIUsagePrintsTheInvokingPath -count=1`
1436Expected: PASS.
1437
1438- [ ] **Step 25: Run everything this task touched, commit**
1439
1440Run: `go build ./... && go vet ./... && go test ./internal/control ./cmd/gitbay -count=1`
1441Expected: PASS.
1442
1443```bash
1444git add internal/control/help.go internal/control/control.go internal/control/control_test.go internal/control/help_test.go cmd/gitbay/ssh.go cmd/gitbay/main.go cmd/gitbay/term_test.go cmd/gitbay/serverpath_test.go e2e/cliusage_test.go
1445git commit -m "usage, help: print the CLI's own invoking path where it differs from the registered one, the ssh form otherwise" -m "Ref #267"
1446```
1447
1448### Task 2.2: `--help` check in `keys add` and `pgp add`
1449
1450Every other passthrough command checks for `--help`/`-h` in `pass()`
1451before reading stdin; `keysAdd.RunE` and `pgpAdd.RunE` in
1452`cmd/gitbay/main.go`'s `authCmd()` were given their own `RunE` (to wire
1453stdin directly) and lost that check, so `gitbay auth keys add --help`
1454tries to read a public key from stdin instead of showing help, and
1455blocks or fails depending on what stdin happens to be.
1456
1457**Files:**
1458- Modify: `cmd/gitbay/main.go` (`authCmd`'s `keysAdd.RunE`, `pgpAdd.RunE`)
1459- Test: `cmd/gitbay/main_test.go`
1460
1461- [ ] **Step 1: Write the failing test**
1462
1463```go
1464func TestKeysAddAndPGPAddCheckHelpBeforeStdin(t *testing.T) {
1465	for _, args := range [][]string{{"auth", "keys", "add", "--help"}, {"auth", "pgp", "add", "--help"}} {
1466		root := newRoot()
1467		root.SetArgs(args)
1468		root.SetIn(strings.NewReader("")) // would block/fail if read as the key body
1469		if err := root.Execute(); err != nil {
1470			t.Errorf("%v: %v", args, err)
1471		}
1472	}
1473}
1474```
1475
1476(`cmd/gitbay` runs its `RunE` through `os.Exit`, so this test only
1477proves the command does not attempt to read stdin as a key before
1478exiting — check with `go test ./cmd/gitbay -run
1479TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -v` that it does not
1480hang; if the harness needs the process not to call `os.Exit` at all,
1481grep `main_test.go` for how existing `--help` tests in this package
1482already handle that and follow the same pattern rather than inventing a
1483new one.)
1484
1485- [ ] **Step 2: Run and see it fail (or hang)**
1486
1487Run: `go test ./cmd/gitbay -run TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -timeout 5s`
1488Expected: FAIL or timeout (stdin read attempted).
1489
1490- [ ] **Step 3: Implement**
1491
1492`keysAdd.RunE` and `pgpAdd.RunE` in `cmd/gitbay/main.go` each gain the
1493same loop `pass()` already has, before resolving the target. They also
1494pick up `cliPath` here (Task 2.1 gave `runServerHelp` a second
1495parameter but could not touch these two hand-built `RunE`s, since this
1496task is what rewrites their bodies): `auth keys add` and `auth pgp add`
1497are two of the eighteen commands whose CLI path differs from the
1498registered one, so their own usage refusals need it exactly like every
1499`pass()`-generated command's do.
1500
1501```go
1502	keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
1503		cliPath := cliPathOf(cmd)
1504		for _, a := range args {
1505			if a == "--help" || a == "-h" {
1506				os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}}, cliPath))
1507			}
1508		}
1509		t, err := resolveTarget()
1510		if err != nil {
1511			return err
1512		}
1513		in, err := stdinPayload(os.Stdin, "an SSH public key", false)
1514		if err != nil {
1515			return err
1516		}
1517		os.Exit(runSSH(t, withCLIPath(cliPath, append([]string{"keys", "add"}, args...)), in))
1518		return nil
1519	}
1520```
1521
1522and, for `pgpAdd`:
1523
1524```go
1525		RunE: func(cmd *cobra.Command, args []string) error {
1526			cliPath := cliPathOf(cmd)
1527			for _, a := range args {
1528				if a == "--help" || a == "-h" {
1529					os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}}, cliPath))
1530				}
1531			}
1532			t, err := resolveTarget()
1533			if err != nil {
1534				return err
1535			}
1536			in, err := stdinPayload(os.Stdin, "an armored OpenPGP public key", false)
1537			if err != nil {
1538				return err
1539			}
1540			os.Exit(runSSH(t, withCLIPath(cliPath, append([]string{"pgp", "add"}, args...)), in))
1541			return nil
1542		},
1543```
1544
1545- [ ] **Step 4: Run**
1546
1547Run: `go test ./cmd/gitbay -run TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -timeout 5s`
1548Expected: PASS.
1549
1550- [ ] **Step 5: Build and run the package**
1551
1552Run: `go build ./... && go test ./cmd/gitbay -count=1`
1553Expected: PASS.
1554
1555- [ ] **Step 6: Commit**
1556
1557```bash
1558git add cmd/gitbay/main.go cmd/gitbay/main_test.go
1559git commit -m "auth keys add, pgp add: check --help before reading stdin" -m "Ref #267"
1560```
1561
1562### Task 2.3: `auth --help` renders with the registry layout, in the CLI's own paths
1563
1564`auth` is a CLI-only grouping — no registry command's path starts with
1565`auth`, so `gitbay auth --help` asks the server for help on prefix
1566`"auth"`, gets `ExitNotFound`, and `cmd/gitbay/main.go`'s `group()`
1567falls back to cobra's own subcommand listing, which carries no flags or
1568examples (the reason `group()` exists at all, per its own comment).
1569Give the registry an alias table for CLI-only groupings so `auth`
1570renders the same READ/WRITE, aligned-summary layout every real noun
1571gets — and, since none of the rows it gathers (`keys add`, `account
1572export`, ...) are commands a caller can actually type, each row prints
1573the CLI path it really takes (`auth keys add`, `auth export`), the same
1574substitution Task 2.1 gave a single command's own usage line. Unlike
1575Task 2.1's mismatches, which are one registered path to one CLI path,
1576`auth` gathers several unrelated registered prefixes into one grouping,
1577and one of them (`account export` -> `auth export`) does not even keep
1578the same word count — the alias table has to carry the CLI form
1579alongside each registered prefix explicitly; it cannot be derived by
1580pattern-matching the prefix the way Task 2.1's single-command swap is.
1581
1582**Files:**
1583- Modify: `internal/control/help.go` (`runHelp`, `nounAliases`, `nounSummaries`)
1584- Modify: `cmd/gitbay/main.go` (`authCmd`'s `group("auth", ...)` description)
1585- Test: `internal/control/help_test.go`
1586
1587**Interfaces:**
1588- Produces: `type nounAlias struct { Registered, CLI string }`; `var
1589  nounAliases map[string][]nounAlias` — a CLI-only noun name to the
1590  registered prefixes it gathers, each paired with the CLI path that
1591  reaches it.
1592
1593- [ ] **Step 1: Write the failing tests**
1594
1595Two: the CLI form (a caller that sent `--path=auth`, as `gitbay auth
1596--help` now does per Task 2.1's `group`/`serverHelp` change), and the
1597ssh form (a caller that sent nothing, which cannot run an `auth
1598whatever` command and must not be told to).
1599
1600```go
1601func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) {
1602	var out bytes.Buffer
1603	c := &Ctx{Stdout: &out, Term: Term{Cols: 100}, CLIPath: "auth"}
1604	if code := runHelp(c, []string{"auth"}); code != protocol.ExitOK {
1605		t.Fatalf("exit %d", code)
1606	}
1607	got := out.String()
1608	for _, want := range []string{"auth whoami", "auth keys list", "auth pgp add", "auth token create", "auth export"} {
1609		if !strings.Contains(got, want) {
1610			t.Errorf("missing %q in:\n%s", want, got)
1611		}
1612	}
1613	if strings.Contains(got, "no command matches") {
1614		t.Errorf("auth did not resolve: %s", got)
1615	}
1616}
1617
1618func TestHelpRendersAnAliasedNounInRegisteredFormOverSSH(t *testing.T) {
1619	var out bytes.Buffer
1620	c := &Ctx{Stdout: &out} // no Term, no CLIPath: exactly stock ssh
1621	if code := runHelp(c, []string{"auth"}); code != protocol.ExitOK {
1622		t.Fatalf("exit %d", code)
1623	}
1624	got := out.String()
1625	for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} {
1626		if !strings.Contains(got, want) {
1627			t.Errorf("missing %q in:\n%s", want, got)
1628		}
1629	}
1630	if strings.Contains(got, "auth keys list") {
1631		t.Errorf("stock ssh should not see the CLI-only auth prefix: %s", got)
1632	}
1633}
1634```
1635
1636- [ ] **Step 2: Run and see them fail**
1637
1638Run: `go test ./internal/control -run TestHelpRendersAnAliasedNoun -count=1`
1639Expected: FAIL (`no command matches "auth"`).
1640
1641- [ ] **Step 3: Implement the alias table and the lookup change**
1642
1643In `internal/control/help.go`, near `nounSummaries`:
1644
1645```go
1646// nounAlias is one bucket of registered commands, reachable under a
1647// CLI-only noun that is not itself a registry path (auth, gathering
1648// several unrelated registry prefixes): Registered is what runHelp
1649// matches against the registry, CLI is the path a gitbay caller
1650// actually types to reach it — not always Registered with the alias's
1651// own name stitched on (account export -> auth export drops a word),
1652// so the two are paired explicitly rather than derived.
1653type nounAlias struct {
1654	Registered string
1655	CLI        string
1656}
1657
1658// nounAliases groups a CLI-only noun into the real prefixes it gathers,
1659// so `help auth` renders with the same layout a real noun gets instead
1660// of falling back to whatever a caller does when help fails. A stock
1661// ssh caller — the only one who could ever ask for a bare "auth" and
1662// get nothing back from the registry — sees the Registered forms
1663// unchanged; the CLI, having sent its own path, sees CLI.
1664var nounAliases = map[string][]nounAlias{
1665	"auth": {
1666		{"account export", "auth export"},
1667		{"whoami", "auth whoami"},
1668		{"keys", "auth keys"},
1669		{"email", "auth email"},
1670		{"pgp", "auth pgp"},
1671		{"token", "auth token"},
1672	},
1673}
1674```
1675
1676and add, to `nounSummaries`:
1677
1678```go
1679	"auth":          "whoami, SSH and PGP keys, email, API tokens",
1680```
1681
1682In `runHelp`, widen the match to every aliased prefix, and — only for a
1683caller that sent its own `CLIPath` — build the per-row override
1684`helpNoun` (Task 2.1) now accepts:
1685
1686```go
1687func runHelp(c *Ctx, args []string) int {
1688	prefix := joinPath(args)
1689	prefixes := []string{prefix}
1690	override := map[string]string{}
1691	if aliased, ok := nounAliases[prefix]; ok {
1692		prefixes = nil
1693		for _, a := range aliased {
1694			prefixes = append(prefixes, a.Registered)
1695		}
1696		if c.CLIPath != "" {
1697			for _, cmd := range registry {
1698				p := joinPath(cmd.Path)
1699				for _, a := range aliased {
1700					if p == a.Registered || strings.HasPrefix(p, a.Registered+" ") {
1701						override[p] = a.CLI + strings.TrimPrefix(p, a.Registered)
1702						break
1703					}
1704				}
1705			}
1706		}
1707	}
1708	var matched []Command
1709	for _, cmd := range registry {
1710		p := joinPath(cmd.Path)
1711		for _, pfx := range prefixes {
1712			if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") {
1713				matched = append(matched, cmd)
1714				break
1715			}
1716		}
1717	}
1718	if len(matched) == 0 {
1719		return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
1720	}
1721	slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
1722	entries := make([]helpEntry, len(matched))
1723	for i, cmd := range matched {
1724		entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
1725	}
1726	return c.emit(entries, func(w io.Writer) {
1727		switch {
1728		case prefix == "":
1729			for _, e := range entries {
1730				summary := e.Summary
1731				if c.Term.Cols > 0 {
1732					if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
1733						summary = clip(summary, avail)
1734					}
1735				}
1736				fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
1737			}
1738		case joinPath(matched[0].Path) == prefix:
1739			c.helpVerb(w, matched[0], matched[1:])
1740		default:
1741			c.helpNoun(w, prefix, matched, override)
1742		}
1743	})
1744}
1745```
1746
1747`matched[0].Path` never equals `"auth"` literally (nothing in the
1748registry is named that), so an aliased noun always takes the
1749`helpNoun` branch. `override` stays an empty (non-nil) map for every
1750ordinary noun — `override[full]` misses for every row, and `helpNoun`
1751falls back to its plain `strings.TrimPrefix` — so this changes nothing
1752for `help repo` or any other real prefix.
1753
1754- [ ] **Step 4: Sync the CLI's own description**
1755
1756`cmd/gitbay/main.go`'s `authCmd()`:
1757
1758```go
1759	return group("auth", "whoami, SSH and PGP keys, email, API tokens",
1760```
1761
1762(`TestGroupsSayWhatTheServerSays` checks this against
1763`nounSummaries["auth"]`, added above.)
1764
1765- [ ] **Step 5: Run**
1766
1767Run: `go test ./internal/control -run TestHelpRendersAnAliasedNoun -count=1`
1768Expected: PASS.
1769
1770- [ ] **Step 6: Run both packages**
1771
1772Run: `go test ./internal/control ./cmd/gitbay -count=1`
1773Expected: PASS.
1774
1775- [ ] **Step 7: Commit**
1776
1777```bash
1778git add internal/control/help.go internal/control/help_test.go cmd/gitbay/main.go
1779git commit -m "help: auth (and any future CLI-only grouping) renders with the registry layout, in the CLI's own paths" -m "Ref #267"
1780```
1781
1782### Task 2.4: verb-phrase summaries
1783
1784Six commands' one-line summaries are bare nouns rather than a phrase
1785saying what the command does: `issue comment`/`mr comment` ("comment"),
1786`issue label`/`mr label` ("labels"), `issue assign` ("assignees"), `mr
1787review` ("review").
1788
1789**Files:**
1790- Modify: `internal/control/issue.go:70`, `:93`, `:102`
1791- Modify: `internal/control/mr.go:146`, `:156`, `:176`
1792- Modify: `cmd/gitbay/summaries_gen.go` (regenerated, not hand-edited)
1793- Test: `cmd/gitbay/summaries_test.go` (existing `TestSummariesAreCurrent` enforces this)
1794
1795- [ ] **Step 1: Change the six `Summary` strings**
1796
1797`internal/control/issue.go:70`: `Summary: "add a comment",`
1798`internal/control/issue.go:93`: `Summary: "add or remove labels",`
1799`internal/control/issue.go:102`: `Summary: "add or remove assignees",`
1800`internal/control/mr.go:146`: `Summary: "add a comment",`
1801`internal/control/mr.go:156`: `Summary: "record a review verdict",`
1802`internal/control/mr.go:176`: `Summary: "add or remove labels",`
1803
1804- [ ] **Step 2: Regenerate `summaries_gen.go`**
1805
1806Run: `go test ./cmd/gitbay -run TestSummariesAreCurrent -update`
1807This rewrites `cmd/gitbay/summaries_gen.go`'s six affected map entries
1808(`"issue comment"`, `"issue label"`, `"issue assign"`, `"mr comment"`,
1809`"mr review"`, `"mr label"`) to the new strings; nothing else in the
1810generated file changes.
1811
1812- [ ] **Step 3: Run**
1813
1814Run: `go test ./internal/control ./cmd/gitbay -count=1`
1815Expected: PASS.
1816
1817- [ ] **Step 4: Commit and open the MR**
1818
1819```bash
1820git add internal/control/issue.go internal/control/mr.go cmd/gitbay/summaries_gen.go
1821git commit -m "summaries: verb phrases instead of bare nouns" -m "Closes #267"
1822git push -u origin cli-ux-help
1823gitbay mr create --source cli-ux-help --target main --title "CLI help and usage print the form the caller typed"
1824```
1825
1826Wait for CI, merge with `--strategy ff`, delete the branch both places.
1827
1828---
1829
1830# Part 3: unregistered key, issue create flags, mr show plurals, repo readme, mirror time (branch `cli-ux-fixes`, closes #268)
1831
1832### Task 3.1: the unregistered-key message names the fingerprint and the real host
1833
1834`runAnonymous` in `internal/sshd/sshd.go:332` tells a connecting
1835stranger to register with a literal `<host>` placeholder and no
1836fingerprint, whether they are truly unknown or someone on a new laptop
1837whose existing account has a different key. Print the fingerprint and
1838the real host, and offer both the web and the ssh path.
1839
1840**Files:**
1841- Modify: `internal/sshd/sshd.go` (`runAnonymous`)
1842- Test: `internal/sshd/sshd_test.go`
1843
1844- [ ] **Step 1: Write the failing test**
1845
1846```go
1847func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) {
1848	st, cleanup := newTestStore(t) // reuse whatever helper sshd_test.go's other tests use to open a migrated store
1849	defer cleanup()
1850	srv := &Server{st: st, cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
1851	pub, _, err := ed25519.GenerateKey(rand.Reader)
1852	if err != nil {
1853		t.Fatal(err)
1854	}
1855	sshPub, err := ssh.NewPublicKey(pub)
1856	if err != nil {
1857		t.Fatal(err)
1858	}
1859	var out bytes.Buffer
1860	ch := &fakeChannel{stderr: &out} // sshd_test.go's existing fake ssh.Channel, if it has one
1861	code := srv.runAnonymous(ch, base64.StdEncoding.EncodeToString(sshPub.Marshal()), "whoami")
1862	if code != protocol.ExitDenied {
1863		t.Fatalf("exit %d", code)
1864	}
1865	fp := ssh.FingerprintSHA256(sshPub)
1866	for _, want := range []string{fp, "forge.test", "https://forge.test/settings#keys", "ssh git@forge.test register"} {
1867		if !strings.Contains(out.String(), want) {
1868			t.Errorf("message missing %q:\n%s", want, out.String())
1869		}
1870	}
1871}
1872```
1873
1874`newTestStore`/`fakeChannel` are placeholders for whatever
1875`internal/sshd/sshd_test.go` already provides for its other
1876`runAnonymous`-adjacent tests — read the top of that file (`grep -n
1877"^func " internal/sshd/sshd_test.go`) and use its actual helpers rather
1878than the names guessed here.
1879
1880- [ ] **Step 2: Run and see it fail**
1881
1882Run: `go test ./internal/sshd -run TestUnregisteredKeyMessageNamesFingerprintAndHost -count=1`
1883Expected: FAIL (message contains the literal string `<host>`, no fingerprint).
1884
1885- [ ] **Step 3: Implement**
1886
1887```go
1888	if len(argv) == 0 || argv[0] != "register" {
1889		host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/")
1890		fp := ssh.FingerprintSHA256(pub)
1891		flag := map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]
1892		fmt.Fprintf(ch.Stderr(),
1893			"this key (%s) is not registered on %s.\n"+
1894				"already have an account? add it at https://%s/settings#keys\n"+
1895				"new here? ssh git@%s register --username <name> %s\n",
1896			fp, host, host, host, flag)
1897		return protocol.ExitDenied
1898	}
1899```
1900
1901- [ ] **Step 4: Run**
1902
1903Run: `go test ./internal/sshd -run TestUnregisteredKeyMessageNamesFingerprintAndHost -count=1`
1904Expected: PASS.
1905
1906- [ ] **Step 5: Run the package**
1907
1908Run: `go test ./internal/sshd -count=1`
1909Expected: PASS. A failing e2e-adjacent unit test asserting the old `this
1910key is not registered here` text needs its expectation updated the same
1911way.
1912
1913- [ ] **Step 6: Commit**
1914
1915```bash
1916git add internal/sshd/sshd.go internal/sshd/sshd_test.go
1917git commit -m "sshd: unregistered-key message names the fingerprint and the real host" -m "Ref #268"
1918```
1919
1920### Task 3.2: `issue create` takes `--label`, `--milestone`, `--assignee`
1921
1922`issue create` only sets title, body and format; labels, milestone and
1923assignees each need a separate call afterward, unlike the web form. Add
1924the three flags (label repeatable) and document that `$EDITOR` already
1925opens when neither `--body` nor `--file` is given (`cmd/gitbay/ssh.go`'s
1926`withRepo`/`maybeEditor` machinery already does this via `issueCmd()`'s
1927`editor: "issue"` — this task only adds the missing flags and says so
1928in the registered help).
1929
1930**Files:**
1931- Modify: `internal/control/issue.go` (`init`'s `issue create` registration, `runIssueCreate`)
1932- Test: `internal/control/issue_test.go`
1933
1934**Interfaces:**
1935- Consumes: `parseFlags`/`flagSpec.Multi` (existing), `c.Store.SetIssueLabel`, `c.Store.MilestoneByTitle`, `c.Store.SetIssueMilestone`, `c.Store.UserByUsername`, `c.Store.SetIssueAssignee` (all existing store methods).
1936
1937- [ ] **Step 1: Write the failing test**
1938
1939```go
1940func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) {
1941	c := notifTestCtx(t, "alice")
1942	repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
1943	if err != nil {
1944		t.Fatal(err)
1945	}
1946	repo, err := c.Store.RepoByID(repoID)
1947	if err != nil {
1948		t.Fatal(err)
1949	}
1950	if err := c.Store.SetLabel(repo, "bug", "ff0000"); err != nil {
1951		t.Fatal(err)
1952	}
1953	if _, err := c.Store.CreateMilestone(repo.ID, "m1", ""); err != nil {
1954		t.Fatal(err)
1955	}
1956	if _, err := c.Store.CreateUser("bob", false); err != nil {
1957		t.Fatal(err)
1958	}
1959
1960	if code := runIssueCreate(c, []string{repo.Path(), "--title", "t",
1961		"--label", "bug", "--milestone", "m1", "--assignee", "bob"}); code != 0 {
1962		t.Fatalf("exit %d: %s", code, c.Stderr.(*bytes.Buffer).String())
1963	}
1964	issue, err := c.Store.IssueByNumber(repo.ID, 1)
1965	if err != nil {
1966		t.Fatal(err)
1967	}
1968	if len(issue.Labels) != 1 || issue.Labels[0] != "bug" {
1969		t.Errorf("labels = %v", issue.Labels)
1970	}
1971	if issue.Milestone != "m1" {
1972		t.Errorf("milestone = %q", issue.Milestone)
1973	}
1974	if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" {
1975		t.Errorf("assignees = %v", issue.Assignees)
1976	}
1977}
1978```
1979
1980(`c.Store.SetLabel`/`CreateMilestone` are placeholders for the real
1981label/milestone creation helpers — `grep -n "func (s \*Store)
1982SetLabel\|func (s \*Store) CreateMilestone" internal/store/*.go` for
1983their actual names and signatures and use those; `issue.Milestone`
1984similarly needs to match whatever field `store.Issue` actually carries
1985for its milestone title, e.g. via `grep -n "Milestone" internal/store/issues.go`.)
1986
1987- [ ] **Step 2: Run and see it fail**
1988
1989Run: `go test ./internal/control -run TestIssueCreateSetsLabelsMilestoneAndAssignee -count=1`
1990Expected: FAIL, exit 2 (`--label` not accepted).
1991
1992- [ ] **Step 3: Register the new flags**
1993
1994```go
1995	register(Command{Path: []string{"issue", "create"},
1996		Summary: "open an issue",
1997		Usage:   "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
1998		Flags: []Flag{
1999			{"--title", "<t>", "the issue's title", ""},
2000			{"--body", "<b>", "the issue's body", ""},
2001			{"--file", "-", "read the body from stdin", ""},
2002			{"--format", "md|org", "the body's markup", "md"},
2003			{"--label", "<l>", "label to add, may repeat", ""},
2004			{"--milestone", "<title>", "milestone to set", ""},
2005			{"--assignee", "<user>", "user to assign, may repeat", ""},
2006		},
2007		Examples: []string{
2008			`issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`,
2009			"issue create krz/gitbay --title notes --file - < notes.md",
2010			"issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc",
2011		},
2012		ReadsStdin: true, Run: runIssueCreate})
2013```
2014
2015Note in a doc comment above `runIssueCreate`, since the flags list
2016above has no room for prose: `$EDITOR` opens for the body when the CLI
2017is asked for neither `--body` nor `--file` — that behavior is entirely
2018client-side (`cmd/gitbay/main.go`'s `issueCmd()` already sets
2019`editor: "issue"`), this registration only documents it:
2020
2021```go
2022// runIssueCreate opens an issue. The CLI opens $EDITOR for the body
2023// when neither --body nor --file is given (cmd/gitbay's issueCmd,
2024// editor: "issue"); over stock ssh the body must be one of the two.
2025func runIssueCreate(c *Ctx, args []string) int {
2026	f, err := parseFlags(args, flagSpec{
2027		Values: []string{"--format", "--title", "--body", "--file", "--milestone"},
2028		Multi:  []string{"--label", "--assignee"},
2029		MaxPos: 1,
2030		Usage:  "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
2031	})
2032	if err != nil {
2033		return c.fail(protocol.ExitUsage, "%v", err)
2034	}
2035```
2036
2037- [ ] **Step 4: Set labels, milestone and assignees after creation**
2038
2039After the existing `n, err := c.Store.CreateIssue(...)` block and its
2040`RecordEvent`/notify calls, before the final `return c.emit(...)`:
2041
2042```go
2043	for _, l := range f.List("--label") {
2044		if err := c.Store.SetIssueLabel(repo, n, l, true); err != nil {
2045			return c.failErr(err)
2046		}
2047	}
2048	if m := f.Value("--milestone"); m != "" {
2049		ms, err := c.Store.MilestoneByTitle(repo, m)
2050		if err != nil {
2051			return milestoneErr(c, repo, m, err)
2052		}
2053		if err := c.Store.SetIssueMilestone(n, ms.ID); err != nil {
2054			return c.fail(protocol.ExitFailure, "%v", err)
2055		}
2056	}
2057	for _, name := range f.List("--assignee") {
2058		u, err := c.Store.UserByUsername(name)
2059		if errors.Is(err, store.ErrNotFound) {
2060			return c.fail(protocol.ExitNotFound, "no such user %q", name)
2061		}
2062		if err != nil {
2063			return c.fail(protocol.ExitFailure, "%v", err)
2064		}
2065		if err := c.Store.SetIssueAssignee(n, u.ID, true); err != nil {
2066			return c.fail(protocol.ExitFailure, "%v", err)
2067		}
2068	}
2069```
2070
2071`SetIssueLabel`'s second parameter in `runIssueLabel` is `issue.ID`, not
2072the issue number — `CreateIssue` returns the number `n`, so fetch the
2073row first if `SetIssueLabel`/`SetIssueMilestone`/`SetIssueAssignee` all
2074key on the database id rather than the number (check each store
2075method's actual first parameter — `grep -n "func (s \*Store)
2076SetIssueLabel\|SetIssueMilestone\|SetIssueAssignee" internal/store/*.go`
2077and adjust to fetch `issue, err := c.Store.IssueByNumber(repo.ID, n)`
2078first if any of them needs `issue.ID` rather than `n`). Add
2079`"errors"` to the file's imports if not already present.
2080
2081- [ ] **Step 5: Run**
2082
2083Run: `go test ./internal/control -run TestIssueCreateSetsLabelsMilestoneAndAssignee -count=1`
2084Expected: PASS.
2085
2086- [ ] **Step 6: Run the package, regenerate the CLI summary if `Usage` changed its flag list**
2087
2088Run: `go test ./internal/control -count=1`
2089Expected: PASS (the `Summary` string is unchanged, so
2090`summaries_gen.go` does not need regenerating — only `Usage`/`Flags`
2091changed, which is not part of that generated file).
2092
2093- [ ] **Step 7: Commit**
2094
2095```bash
2096git add internal/control/issue.go internal/control/issue_test.go
2097git commit -m "issue create: --label, --milestone, --assignee" -m "Ref #268"
2098```
2099
2100### Task 3.3: `mr show` pluralizes its multi-row section headings
2101
2102`mr show`'s commit/check/review sub-tables print a singular label
2103(`commit:`, `check:`) even when they hold several rows.
2104
2105**Files:**
2106- Modify: `internal/control/mr.go` (the three `v.section(...)` calls around lines 793, 802, 811)
2107- Test: `internal/control/mr_test.go`
2108
2109- [ ] **Step 1: Write the failing test**
2110
2111Find `mr show`'s existing plain-output test (`grep -n "func Test.*MRShow"
2112internal/control/mr_test.go`) and add a case with more than one commit,
2113check and review, asserting the plural, counted heading:
2114
2115```go
2116func TestMRShowPluralizesMultiRowSections(t *testing.T) {
2117	// build on whatever fixture the existing MR-show tests in this file
2118	// use to get a repo with an open MR; push two commits onto its
2119	// source branch, set two statuses, and record two reviews before
2120	// calling runMRShow, following that fixture's own setup exactly.
2121	...
2122	out := ... // runMRShow's plain stdout
2123	for _, want := range []string{"commits (2):", "checks (2):", "reviews (2):"} {
2124		if !strings.Contains(out, want) {
2125			t.Errorf("missing %q in:\n%s", want, out)
2126		}
2127	}
2128}
2129```
2130
2131- [ ] **Step 2: Run and see it fail**
2132
2133Run: `go test ./internal/control -run TestMRShowPluralizesMultiRowSections -count=1`
2134Expected: FAIL (headings read `commit:`, `check:`, `review:`).
2135
2136- [ ] **Step 3: Implement**
2137
2138```go
2139		if len(commits) > 1 {
2140			v.section(fmt.Sprintf("commits (%d)", len(commits)))
2141			tb := c.table(w, "SHA", "SUBJECT")
2142			...
2143		}
2144
2145		if len(checks) > 1 {
2146			v.section(fmt.Sprintf("checks (%d)", len(checks)))
2147			tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
2148			...
2149		}
2150
2151		if len(rs) > 1 {
2152			v.section(fmt.Sprintf("reviews (%d)", len(rs)))
2153			tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
2154			...
2155		}
2156```
2157
2158(`v.section` prints `label + ":"` in plain mode already — do not add a
2159trailing colon inside the `fmt.Sprintf` string.)
2160
2161- [ ] **Step 4: Run**
2162
2163Run: `go test ./internal/control -run TestMRShow -count=1`
2164Expected: PASS.
2165
2166- [ ] **Step 5: Run the package**
2167
2168Run: `go test ./internal/control -count=1`
2169Expected: PASS.
2170
2171- [ ] **Step 6: Commit**
2172
2173```bash
2174git add internal/control/mr.go internal/control/mr_test.go
2175git commit -m "mr show: pluralize commits/checks/reviews section headings" -m "Ref #268"
2176```
2177
2178### Task 3.4: `repo readme` prints a repository's README
2179
2180No command prints a repository's README; the web page's own
2181README-picking logic (`pickReadme` in `internal/httpd/web.go`) is not
2182reachable from `internal/control`. Move it into `internal/control`,
2183exported, and add `repo readme <owner/name> [--ref <ref>]` following
2184`repo cat`'s shape.
2185
2186**Files:**
2187- Modify: `internal/control/read.go` (new `repo readme` registration and `runRepoReadme`, model on `runRepoCat`/`runRepoTree`)
2188- Modify: `internal/httpd/web.go` (move `readmeRank`/`pickReadme` out, call site at line 660 updated)
2189- Modify: `cmd/gitbay/main.go` (`repoCmd`, new `pass("readme", ...)`)
2190- Modify: `e2e/readonly_test.go` (`readArgs["repo readme"]`)
2191- Modify: `.gitbay/wiki/Parity.org` (Repositories table)
2192- Test: `internal/control/read_test.go`
2193
2194**Interfaces:**
2195- Produces: `func PickReadme(entries []gitutil.TreeEntry) string` (moved from `internal/httpd`, exported).
2196
2197- [ ] **Step 1: Move `readmeRank`/`pickReadme`**
2198
2199Cut both from `internal/httpd/web.go` (around lines 1185–1210) and paste
2200into `internal/control/read.go`, renaming `pickReadme` to `PickReadme`:
2201
2202```go
2203// readmeRank orders competing README files: richer renderers win.
2204var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
2205
2206// PickReadme returns the best README-ish blob in a tree listing: any
2207// file named "readme" or "readme.<ext>" (case-insensitive), preferring
2208// formats we can render richly.
2209func PickReadme(entries []gitutil.TreeEntry) string {
2210	best, bestRank := "", 1<<30
2211	for _, e := range entries {
2212		if e.Type != "blob" {
2213			continue
2214		}
2215		lower := strings.ToLower(e.Name)
2216		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
2217			continue
2218		}
2219		rank, ok := readmeRank[path.Ext(lower)]
2220		if !ok {
2221			rank = 10 // plaintext fallback
2222		}
2223		if rank < bestRank {
2224			best, bestRank = e.Name, rank
2225		}
2226	}
2227	return best
2228}
2229```
2230
2231In `internal/httpd/web.go`, the call site at line 660 becomes
2232`readmeName := control.PickReadme(entries)`. Remove the unused `"path"`
2233import from `web.go` only if nothing else in the file still uses it
2234(`grep -n '"path"' internal/httpd/web.go` and `grep -n "path\."
2235internal/httpd/web.go` — this file is large and almost certainly uses
2236`path` elsewhere, so this removal is likely a no-op check, not an edit).
2237
2238- [ ] **Step 2: Build to confirm the move alone is clean**
2239
2240Run: `go build ./... && go vet ./...`
2241Expected: no errors.
2242
2243- [ ] **Step 3: Write the failing test for the new command**
2244
2245```go
2246func TestRepoReadmePicksTheRichestFormat(t *testing.T) {
2247	st, repo, uid := newQueueTestRepo(t)
2248	dir := RepoDir(config.Config{}.Server.Root, repo.OwnerName, repo.Name) // adjust to however read_test.go's existing repo-cat tests get a working tree with committed files — reuse that helper rather than re-deriving RepoDir's root
2249	git := gitRunner(t)
2250	git(dir, "init", "--bare") // only if newQueueTestRepo does not already leave a real git repo on disk; check runRepoCat's own test setup and mirror it exactly
2251	...
2252	c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
2253	if code := Dispatch(c, []string{"repo", "readme", repo.Path()}); code != protocol.ExitOK {
2254		t.Fatalf("exit %d: %s", code, errOut)
2255	}
2256	if got := c.Stdout.(*bytes.Buffer).String(); got != "# app\n\nhello\n" {
2257		t.Errorf("readme = %q", got)
2258	}
2259}
2260```
2261
2262`runRepoCat`'s own test in `internal/control/read_test.go` already sets
2263up a real on-disk repository with a committed file — copy that setup
2264exactly (bare repo, a work tree pushed into it, matching
2265`gitTestEnv()`/`gitRunner(t)` from `build_test.go`) rather than
2266reinventing it; commit a `README.md` instead of whatever file that test
2267uses.
2268
2269- [ ] **Step 4: Run and see it fail**
2270
2271Run: `go test ./internal/control -run TestRepoReadmePicksTheRichestFormat -count=1`
2272Expected: FAIL (`unknown command "readme"`).
2273
2274- [ ] **Step 5: Register the command and implement it**
2275
2276In `internal/control/read.go`'s `init()`, after the `repo cat`
2277registration:
2278
2279```go
2280	register(Command{
2281		Path:    []string{"repo", "readme"},
2282		Summary: "print a repository's README",
2283		Usage:   "repo readme <owner/name> [--ref <ref>]",
2284		Flags: []Flag{
2285			{"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
2286		},
2287		Examples: []string{"repo readme krz/gitbay"},
2288		ReadOnly: true,
2289		Run:      runRepoReadme,
2290	})
2291```
2292
2293```go
2294func runRepoReadme(c *Ctx, args []string) int {
2295	pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1)
2296	if code >= 0 {
2297		return code
2298	}
2299	if len(pos) != 1 {
2300		return c.usage()
2301	}
2302	repo, code := resolveRepo(c, pos[0], policy.CanRead)
2303	if code >= 0 {
2304		return code
2305	}
2306	if ref == "" {
2307		ref = repo.DefaultBranch
2308	}
2309	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
2310	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
2311		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
2312	}
2313	entries, err := gitutil.ListTree(dir, ref, "")
2314	if err != nil {
2315		return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref)
2316	}
2317	name := PickReadme(entries)
2318	if name == "" {
2319		return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref)
2320	}
2321	limit := c.Cfg.Limits.MaxBlobBytes
2322	data, err := gitutil.ReadBlob(dir, ref, name, limit+1)
2323	if err != nil {
2324		return c.fail(protocol.ExitFailure, "%v", err)
2325	}
2326	truncated := int64(len(data)) > limit
2327	if truncated {
2328		data = data[:limit]
2329	}
2330	binary := gitutil.IsBinary(data)
2331	type out struct {
2332		Path      string `json:"path"`
2333		Ref       string `json:"ref"`
2334		File      string `json:"file"`
2335		Size      int    `json:"size"`
2336		Truncated bool   `json:"truncated,omitempty"`
2337		Binary    bool   `json:"binary,omitempty"`
2338		Content   string `json:"content,omitempty"`
2339		Base64    string `json:"base64,omitempty"`
2340	}
2341	d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data), Truncated: truncated, Binary: binary}
2342	if binary {
2343		d.Base64 = base64.StdEncoding.EncodeToString(data)
2344	} else {
2345		d.Content = string(data)
2346	}
2347	return c.emit(d, func(w io.Writer) {
2348		if binary {
2349			fmt.Fprintf(w, "%s is binary (%d bytes)\n", d.File, d.Size)
2350			return
2351		}
2352		io.WriteString(w, d.Content)
2353		if truncated {
2354			fmt.Fprintln(w, "... truncated")
2355		}
2356	})
2357}
2358```
2359
2360(Match `runRepoCat`'s actual truncation/binary field names and JSON tags
2361exactly — read the rest of its `out` struct at
2362`internal/control/read.go:355` onward and copy its shape rather than
2363inventing a divergent one, so a client handles both commands the same
2364way.)
2365
2366- [ ] **Step 6: Run**
2367
2368Run: `go test ./internal/control -run TestRepoReadmePicksTheRichestFormat -count=1`
2369Expected: PASS.
2370
2371- [ ] **Step 7: Wire the CLI passthrough**
2372
2373`cmd/gitbay/main.go`'s `repoCmd()`, next to `pass("cat", ...)`:
2374
2375```go
2376		pass("readme", passOpts{server: []string{"repo", "readme"}, needsRepo: true}),
2377```
2378
2379- [ ] **Step 8: Add it to the ReadOnly coverage list**
2380
2381`e2e/readonly_test.go`'s `readArgs` map gains, next to `"repo refs"`:
2382
2383```go
2384		"repo readme":                 {"alice/app"},
2385```
2386
2387(the fixture's `alice/app` already has a committed `README.md`, so this
2388does not need a `notFoundOK` entry.)
2389
2390- [ ] **Step 9: Update Parity**
2391
2392`.gitbay/wiki/Parity.org`'s Repositories table gains a row, next to
2393`| read a file                 | yes | yes | yes |`:
2394
2395```
2396| render a README             | yes | yes | yes |
2397```
2398
2399- [ ] **Step 10: Run the full local suite for touched packages**
2400
2401Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/httpd ./cmd/gitbay -count=1`
2402Expected: PASS.
2403
2404- [ ] **Step 11: Commit**
2405
2406```bash
2407git add internal/control/read.go internal/control/read_test.go internal/httpd/web.go cmd/gitbay/main.go e2e/readonly_test.go .gitbay/wiki/Parity.org
2408git commit -m "repo readme: print a repository's README, the web page's file order" -m "Ref #268"
2409```
2410
2411### Task 3.5: `repo show`'s mirror time drops the milliseconds
2412
2413`repo show`'s mirror sub-table prints `LAST SYNC` with milliseconds
2414(`2026-09-24T15:31:50.839Z`) instead of the second-truncated form every
2415other timestamp in a `view` uses.
2416
2417**Files:**
2418- Modify: `internal/control/repo.go` (`runRepoShow`'s mirror table row, around line 474)
2419- Test: `internal/control/repo_test.go`
2420
2421- [ ] **Step 1: Write the failing test**
2422
2423Find `repo show`'s existing mirror-table test (`grep -n
2424"func Test.*Mirror" internal/control/repo_test.go`), or add one if none
2425exists:
2426
2427```go
2428func TestRepoShowMirrorTimeIsTruncatedToTheSecond(t *testing.T) {
2429	c, repo, _ := newQueueTestRepo(t) // adjust to whatever gives an admin Ctx over a repo with a mirror row in repo_test.go's existing fixtures
2430	if err := c.Store.CreateMirror(repo.ID, "push", "ssh://example.test/x.git", ""); err != nil {
2431		t.Fatal(err)
2432	}
2433	if err := c.Store.MarkMirrorSynced(repo.ID, "ssh://example.test/x.git", "2026-09-24T15:31:50.839Z"); err != nil {
2434		t.Fatal(err)
2435	}
2436	var out bytes.Buffer
2437	c.Stdout, c.User.IsAdmin = &out, true // repo show's mirror section is admin-only in this Ctx
2438	if code := runRepoShow(c, []string{repo.Path()}); code != 0 {
2439		t.Fatalf("exit %d", code)
2440	}
2441	if strings.Contains(out.String(), ".839Z") {
2442		t.Errorf("milliseconds leaked: %s", out.String())
2443	}
2444	if !strings.Contains(out.String(), "2026-09-24T15:31:50Z") {
2445		t.Errorf("no truncated timestamp: %s", out.String())
2446	}
2447}
2448```
2449
2450(`CreateMirror`/`MarkMirrorSynced` are placeholders — `grep -n "func (s
2451\*Store) .*Mirror" internal/store/*.go` for the real names/signatures
2452that get a `ListMirrors` row with a non-empty `LastSync`, and use those;
2453`runRepoShow`'s mirror section additionally requires
2454`policy.CanAdmin(c.User, repo, grant)` to hold for the caller, so the
2455test's `Ctx` needs to be the repo's owner or otherwise admin over it —
2456`newQueueTestRepo`'s `uid` already owns the repo it returns, which
2457satisfies that.)
2458
2459- [ ] **Step 2: Run and see it fail**
2460
2461Run: `go test ./internal/control -run TestRepoShowMirrorTimeIsTruncatedToTheSecond -count=1`
2462Expected: FAIL (`.839Z` present).
2463
2464- [ ] **Step 3: Implement**
2465
2466```go
2467			tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status))
2468```
2469
2470(`c.when` is already what every other timestamp in a `view` goes
2471through: RFC3339-to-the-second in plain output, `2006-01-02 15:04 UTC`
2472at a terminal; `orDash` keeps an empty `LastSync` — a mirror that has
2473never synced — printing `-` rather than an empty cell, since `c.when("")`
2474returns `""` unchanged.)
2475
2476- [ ] **Step 4: Run**
2477
2478Run: `go test ./internal/control -run TestRepoShowMirrorTimeIsTruncatedToTheSecond -count=1`
2479Expected: PASS.
2480
2481- [ ] **Step 5: Run the package**
2482
2483Run: `go test ./internal/control -count=1`
2484Expected: PASS.
2485
2486- [ ] **Step 6: Commit, open the MR**
2487
2488```bash
2489git add internal/control/repo.go internal/control/repo_test.go
2490git commit -m "repo show: truncate the mirror's last-sync time to the second" -m "Closes #268"
2491git push -u origin cli-ux-fixes
2492gitbay mr create --source cli-ux-fixes --target main --title "CLI UX review small fixes"
2493```
2494
2495Wait for CI, merge with `--strategy ff`, delete the branch both places.
2496
2497---
2498
2499## Self-review
2500
2501**Spec coverage** (against #265/#267/#268's text, this plan's spec):
2502
2503- #265: sentences for activity (Task 1.1–1.3), no duplicate assigned
2504  issues (Task 1.4), one empty-state wording for `notifications list`
2505  (Task 1.5). The issue's other empty-state line ("Empty sections print
2506  none; empty lists elsewhere print nothing to list on stderr") already
2507  matches current behavior (`internal/control/dashboard.go`'s `section`
2508  helper, `internal/control/control.go`'s `emit`) — no task needed.
2509- #267: CLI sends `--term`/`Ctx.Term` (already present; verified, not
2510  re-implemented) and now also `--path`/`Ctx.CLIPath`, its own invoking
2511  path — the same mechanism, a sibling prefix — used by
2512  `usage()`/`usageWith()`/`helpVerb`/`helpNoun` so the eighteen commands
2513  whose CLI path differs from the registered one print a command that
2514  exists (Task 2.1); `[<owner/name>]` optional from the CLI (Task 2.1);
2515  `--help` check in `keys add`/`pgp add`, now also sending their own
2516  `cliPath` (Task 2.2); `auth` rendered with the registry layout, each
2517  row in the CLI's own path (Task 2.3); verb-phrase summaries (Task 2.4).
2518- #268: unregistered-key message (Task 3.1); `issue create` flags
2519  (Task 3.2); `mr show` plurals (Task 3.3); `repo readme` (Task 3.4);
2520  `repo show` mirror time (Task 3.5).
2521
2522**Placeholder scan:** Tasks 3.3 and 3.4's tests name real assertions but
2523lean on "copy this file's existing fixture setup" rather than spelling
2524out git plumbing calls verbatim, and Task 3.1's test invents
2525`newTestStore`/`fakeChannel` names to be replaced by whatever
2526`internal/sshd/sshd_test.go` actually has. That is intentional, not a
2527placeholder in the sense the skill warns against: the actual assertions
2528(what strings must appear, what exit code, what store rows) are
2529concrete; only the test-scaffolding names are marked as needing a look
2530at each file's neighbors before typing them in, because this plan was
2531written from reading the production code, not the test helper's exact
2532current shape in every file it touches. Anyone executing this plan
2533reads the named test file's other tests first, per each step's own
2534instruction, before writing the step.
2535
2536**Type consistency:** `FeedLine`/`FeedLines`/`WorstStatus` (Task 1.1)
2537are used with the same names in Tasks 1.2 and 1.3. `Ctx.CLIPath`,
2538`cmdUsage`/`cliUsage`/`shownAs` and the CLI-side `cliPathOf`/
2539`withCLIPath` (Task 2.1) are used with the same names and signatures in
2540Task 2.2 (`keysAdd.RunE`/`pgpAdd.RunE` sending their own `cliPath`) and
2541Task 2.3 (`helpNoun`'s `override` map, the alias table's `CLI` field
2542built from the same substitution `shownAs` performs for a single
2543command). `PickReadme` (Task 3.4) is the only name introduced for that
2544logic and is used consistently in its own task.