Public: anyone can read this; the crew can change it.
docs/CLI.md says every `forge` command with one example each, and a test fails when `forge help` lists one it does not #7410
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7372-docs-cli-md-says-every-forge-command-wit → main 5 commits fork
4 files
changed +407
−0
README.md +1
| @@ -434,6 +434,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 434 | 434 | - [](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload. |
| 435 | 435 | - [](docs/PULLS.md) — resolving a conflicted pull without a clone. |
| 436 | 436 | - [](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories. |
| 437 | - [](docs/CLI.md) — the `forge` command on your machine: install, log in, and every command with its flags and one example. | |
| 437 | 438 | - [](docs/features/csv-view.md) — open a `.csv` or `.tsv` in the code view to read it as a table; `?all=1` draws more rows, `?view=source` shows the text. The terminal has its own table view for them (see the `.csv` paragraph above). |
| 438 | 439 | - [](docs/features/file-media.md) — a file in the browser: every file page has **Raw** and **Download** (any size), and `/:owner/:name/media/:ref/*path` serves images, audio, video and PDFs under their real type, sandboxed, with byte ranges. |
| 439 | 440 | - [](docs/ROLES.md) — guest, member and admin, and what each may do. |
docs/CLI.md added +322
| @@ -0,0 +1,322 @@ | ||
| 1 | # The `forge` command | |
| 2 | ||
| 3 | `forge` is the client that lives on your machine: it keeps a copy of the repos | |
| 4 | you work on under one root, reports how each one stands, and opens the local | |
| 5 | terminal UI over them. Source: `src/cli/main.ts`. This page has one section | |
| 6 | per command `forge help` prints; `test/cli-docs.test.ts` fails when a command | |
| 7 | is added to `forge help` without a `## forge <command>` heading here. | |
| 8 | ||
| 9 | Every example below is run with `NO_COLOR=1`. On a terminal, the same lines | |
| 10 | are coloured and nothing else changes. | |
| 11 | ||
| 12 | ## Install | |
| 13 | ||
| 14 | Build it once and put it on your PATH: | |
| 15 | ||
| 16 | sh | |
| 17 | bun build --compile src/cli/main.ts --outfile dist/forge | |
| 18 | ||
| 19 | ||
| 20 | That is a self-contained binary (it embeds the Bun runtime, 60–110 MB; | |
| 21 | `dist/` is gitignored). Until you build it, `bun run src/cli/main.ts …` is the | |
| 22 | same program. | |
| 23 | ||
| 24 | Then sign in once with [](#forge-login) and bring your repos | |
| 25 | down with [](#forge-sync). | |
| 26 | ||
| 27 | ## Where things live | |
| 28 | ||
| 29 | | What | Where | Override | | |
| 30 | |---|---|---| | |
| 31 | | The config (`config.json`, mode 0600) | `$XDG_CONFIG_HOME/forge`, else `~/.config/forge` | `FORGE_CONFIG_DIR` | | |
| 32 | | The token | the macOS login keychain, else the config file | `FORGE_TOKEN`; `FORGE_KEYCHAIN=0` skips the keychain | | |
| 33 | | The forge | the config file | `FORGE_URL` | | |
| 34 | | Clones | `~/src/<host>/<owner>/<name>` | `--root DIR`, then `FORGE_ROOT` | | |
| 35 | | Colour | on when stdout is a terminal | `NO_COLOR` or `FORGE_COLOR=0` turns it off | | |
| 36 | ||
| 37 | `FORGE_URL` and `FORGE_TOKEN` together let a script or a CI job run any | |
| 38 | command without a config file at all. | |
| 39 | ||
| 40 | ## Exit codes | |
| 41 | ||
| 42 | | Code | Meaning | | |
| 43 | |---|---| | |
| 44 | | 0 | done | | |
| 45 | | 1 | failed (the forge refused, or a program could not be run) | | |
| 46 | | 2 | usage: an unknown command or a missing argument | | |
| 47 | | 3 | not signed in, or the stored token is unusable | | |
| 48 | | 4 | partial: the run finished, but at least one repo did not | | |
| 49 | | 5 | refused: the forge heard the request and said no (a rule, a conflict, a hold) | | |
| 50 | ||
| 51 | A script can branch on these without parsing text. | |
| 52 | ||
| 53 | ## Flags every command takes | |
| 54 | ||
| 55 | - `--help`, `-h` — print `forge help` and exit 0. | |
| 56 | - `--version`, `-v` — print the version and exit 0. | |
| 57 | ||
| 58 | console | |
| 59 | $ forge --version | |
| 60 | forge 0.0.1 | |
| 61 | ||
| 62 | ||
| 63 | ## forge | |
| 64 | ||
| 65 | With no command, `forge` opens the local terminal UI: the library of clones | |
| 66 | under the root, then a working-tree screen for one of them (`space` stages, | |
| 67 | `enter` opens the hunks, `c` commits, `P` pushes, `?` shows every binding). | |
| 68 | It works signed out; signed in, it also knows the forge. When stdout is not a | |
| 69 | terminal it prints `forge help` instead and exits 2. | |
| 70 | ||
| 71 | Flags: `--root DIR`. | |
| 72 | ||
| 73 | console | |
| 74 | $ forge --root ~/src | |
| 75 | ||
| 76 | ||
| 77 | ## forge help | |
| 78 | ||
| 79 | Prints the commands, the config path and the root, and exits 0. | |
| 80 | ||
| 81 | console | |
| 82 | $ forge help | |
| 83 | forge 0.0.1 — your workbench, on this machine | |
| 84 | ||
| 85 | forge the local terminal UI | |
| 86 | forge login <url> [--token T] [--insecure] | |
| 87 | forge logout | |
| 88 | forge whoami | |
| 89 | forge sync [--watch] [--interval 5m] [--ff] [--root DIR] | |
| 90 | forge status [--root DIR] [--json] | |
| 91 | forge ls [--json] | |
| 92 | forge pin <owner/name> | |
| 93 | forge unpin <owner/name> | |
| 94 | forge open <owner/name> [--web] | |
| 95 | forge pulls [--repo owner/name] [--closed] [--json] | |
| 96 | forge asks [--repo owner/name] [--json] | |
| 97 | forge ship <number> [--repo owner/name] [--yes] | |
| 98 | ||
| 99 | config /home/you/.config/forge/config.json root /home/you/src | |
| 100 | FORGE_URL, FORGE_TOKEN, FORGE_ROOT and NO_COLOR are honoured. | |
| 101 | ||
| 102 | ||
| 103 | ## forge login | |
| 104 | ||
| 105 | Remembers a forge and a token. The URL may be bare (`forge.example.com` means | |
| 106 | `https://`, `localhost:3000` means `http://`). The token is asked for without | |
| 107 | echo, read from one line of stdin when stdin is a pipe, or given with | |
| 108 | `--token`. It is checked against the forge before anything is stored: a token | |
| 109 | the forge refuses is never saved (exit 3). | |
| 110 | ||
| 111 | Plain `http://` to any host but this machine (`localhost`, `127.0.0.1`, | |
| 112 | `[::1]`) is refused before the token is asked for, because every request | |
| 113 | carries the token in clear (exit 2). `--insecure` lets it through for that one | |
| 114 | forge and is remembered with it; every later command refuses the same way if | |
| 115 | `FORGE_URL` points at a different plain-http host. | |
| 116 | ||
| 117 | Flags: `--token T`, `--url URL` (the same as the positional), `--insecure`. | |
| 118 | ||
| 119 | console | |
| 120 | $ echo "$TOKEN" | forge login forge.example.com | |
| 121 | ✓ signed in to https://forge.example.com as alice | |
| 122 | token in /home/you/.config/forge/config.json (0600) | |
| 123 | ||
| 124 | ||
| 125 | On macOS the second line reads `token in your login keychain; | |
| 126 | /home/you/.config/forge/config.json holds only the URL`. | |
| 127 | ||
| 128 | ## forge logout | |
| 129 | ||
| 130 | Deletes the config and the keychain entry. It is not an error to run it | |
| 131 | signed out. | |
| 132 | ||
| 133 | console | |
| 134 | $ forge logout | |
| 135 | signed out | |
| 136 | ||
| 137 | ||
| 138 | ## forge whoami | |
| 139 | ||
| 140 | Asks the forge who the stored token belongs to. | |
| 141 | ||
| 142 | console | |
| 143 | $ forge whoami | |
| 144 | alice at https://forge.example.com | |
| 145 | ||
| 146 | ||
| 147 | Signed out, it prints `forge: not signed in — run \`forge login <url>\`` and | |
| 148 | exits 3. | |
| 149 | ||
| 150 | ## forge sync | |
| 151 | ||
| 152 | Clones every pinned repo that is missing under the root, fetches the rest, | |
| 153 | and prints one row per repo. It never merges, rebases, resets or checks | |
| 154 | anything out. On a forge without pins it syncs the repos you own, and says so. | |
| 155 | ||
| 156 | Flags: | |
| 157 | ||
| 158 | - `--ff` — fast-forward a repo only when its tree is clean, nothing is ahead, | |
| 159 | no rebase or merge is half-finished, and `merge-base --is-ancestor` proves | |
| 160 | the move loses no commit. | |
| 161 | - `--watch` — keep running; after the first report, print and send a desktop | |
| 162 | notification only when something changed. | |
| 163 | - `--interval 5m` — how often `--watch` syncs: `90s`, `5m`, `2h`, `500ms` or a | |
| 164 | bare number of seconds. Default `5m`. | |
| 165 | - `--root DIR` — the library to sync into. | |
| 166 | - `--json` — one JSON object per report instead of the table (the same fields | |
| 167 | as [](#forge-status)). | |
| 168 | ||
| 169 | Exits 4 when any repo failed. | |
| 170 | ||
| 171 | console | |
| 172 | $ forge sync | |
| 173 | REPO BRANCH SYNC DIRTY STATE FETCHED | |
| 174 | alice/notes main cloned now | |
| 175 | alice/site main ↓2 1 now | |
| 176 | alice/docs — not cloned | |
| 177 | ||
| 178 | ○ alice/docs: not cloned yet — run `forge sync` | |
| 179 | ||
| 180 | ||
| 181 | `↑n` is commits ahead of the upstream, `↓n` behind; `DIRTY` counts staged, | |
| 182 | changed, untracked and conflicted files; a repo with nothing to say gets `✓`. | |
| 183 | ||
| 184 | The token goes to git only when the repo's `origin` is this workbench's own | |
| 185 | origin over https (or plain http to loopback). Any other `origin` — an edited | |
| 186 | remote, say — is fetched without it, and the row's state reads `no token sent`. | |
| 187 | ||
| 188 | ## forge status | |
| 189 | ||
| 190 | The same report as `forge sync` without touching the network: nothing is | |
| 191 | cloned or fetched, so it answers when the forge does not. | |
| 192 | ||
| 193 | Flags: `--root DIR`, `--json`. | |
| 194 | ||
| 195 | console | |
| 196 | $ forge status --json | |
| 197 | {"root":"/home/you/src","pinsUnavailable":false,"failures":0,"repos":[{"owner":"alice","name":"notes","path":"/home/you/src/forge.example.com/alice/notes","present":true,"cloned":false,"pinned":true,"branch":"main","upstream":"origin/main","ahead":0,"behind":0,"staged":0,"changed":0,"untracked":0,"conflicted":0,"dirty":0,"stashes":0,"unpushed":[],"fastForwarded":false,"lastFetch":"2026-10-27T09:12:00.000Z","error":null}]} | |
| 198 | ||
| 199 | ||
| 200 | ## forge ls | |
| 201 | ||
| 202 | What is on disk and how stale, read from disk alone. | |
| 203 | ||
| 204 | Flags: `--json` (the same object as `forge status --json`), `--root DIR`. | |
| 205 | ||
| 206 | console | |
| 207 | $ forge ls | |
| 208 | /home/you/src | |
| 209 | REPO BRANCH SYNC STATE FETCHED | |
| 210 | alice/notes main ✓ 2h | |
| 211 | alice/site main ↑1 1 dirty 2h | |
| 212 | alice/docs — not cloned | |
| 213 | ||
| 214 | ||
| 215 | ## forge pin | |
| 216 | ||
| 217 | Asks the forge to keep a repo on your machines; the next `forge sync` clones | |
| 218 | it. Takes `owner/name` and nothing else (exit 2 otherwise). | |
| 219 | ||
| 220 | console | |
| 221 | $ forge pin alice/docs | |
| 222 | ✓ pinned alice/docs | |
| 223 | run `forge sync` to bring it down | |
| 224 | ||
| 225 | ||
| 226 | A forge without pins answers `forge pin: this forge does not support pins | |
| 227 | yet` (exit 1). | |
| 228 | ||
| 229 | ## forge unpin | |
| 230 | ||
| 231 | Stops keeping a repo. Nothing on disk is deleted; `forge sync` simply stops | |
| 232 | fetching it. | |
| 233 | ||
| 234 | console | |
| 235 | $ forge unpin alice/docs | |
| 236 | ✓ unpinned alice/docs | |
| 237 | ||
| 238 | ||
| 239 | ## forge open | |
| 240 | ||
| 241 | Prints a repo's web address; with `--web` it also opens it in your browser | |
| 242 | (`open` on macOS, `xdg-open` elsewhere). | |
| 243 | ||
| 244 | Flags: `--web`. | |
| 245 | ||
| 246 | console | |
| 247 | $ forge open alice/notes | |
| 248 | https://forge.example.com/alice/notes | |
| 249 | ||
| 250 | ||
| 251 | ## forge pulls | |
| 252 | ||
| 253 | A repository's pulls, one line each: the number, the title cut to fit the | |
| 254 | terminal, the author, the check (`green`, `red`, `running` or `none`) and the | |
| 255 | preview address when there is one. The repository is `--repo`, or else the | |
| 256 | current folder's `origin` when it is a clone of this workbench (exit 2 | |
| 257 | otherwise). With none, it prints `No open pulls.` | |
| 258 | ||
| 259 | Flags: | |
| 260 | ||
| 261 | - `--repo owner/name` — the repository (exit 2 if it is not `owner/name`). | |
| 262 | - `--closed` — closed pulls instead of open ones. | |
| 263 | - `--json` — the forge's answer as it came. | |
| 264 | ||
| 265 | console | |
| 266 | $ forge pulls --repo alice/shop | |
| 267 | #3 Checkout totals in cents bob green https://shop--3.example.com | |
| 268 | #2 Dark print view carol red | |
| 269 | ||
| 270 | ||
| 271 | ## forge asks | |
| 272 | ||
| 273 | What waits on you, one line each, most important first: the kind, the | |
| 274 | repository, the title cut to fit the terminal, and the address where you | |
| 275 | decide it. The forge's own needs have no repository. With none, it prints | |
| 276 | `Nothing waits on you.` | |
| 277 | ||
| 278 | Flags: | |
| 279 | ||
| 280 | - `--repo owner/name` — only that repository's asks (exit 2 if it is not | |
| 281 | `owner/name`). | |
| 282 | - `--json` — the forge's `/api/v1/asks` answer as it came, narrowed by | |
| 283 | `--repo` when given. | |
| 284 | ||
| 285 | console | |
| 286 | $ forge asks | |
| 287 | look alice/shop Pull #3 is green — look and merge https://forge.example.com/alice/shop/pulls/3 | |
| 288 | parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9 | |
| 289 | ||
| 290 | ||
| 291 | ## forge ship | |
| 292 | ||
| 293 | Lands a pull, as its Merge button does: prints the pull with its check and | |
| 294 | whether it merges, asks `Ship #N? [y/N]`, then merges. Anything but `y` | |
| 295 | prints `not shipped` and exits 0. The forge's merge rules stay the forge's; | |
| 296 | a refusal prints its own words and exits 5 (exit 3 when the token is | |
| 297 | refused). The repository is `--repo`, or else the current folder's `origin`. | |
| 298 | ||
| 299 | Flags: | |
| 300 | ||
| 301 | - `--repo owner/name` — the repository. | |
| 302 | - `--yes` — ship without asking. Without a terminal to ask on, `--yes` is | |
| 303 | required (exit 2). | |
| 304 | ||
| 305 | console | |
| 306 | $ forge ship 3 | |
| 307 | alice/shop#3 Checkout totals in cents | |
| 308 | check green · mergeable | |
| 309 | Ship #3? [y/N] y | |
| 310 | ✓ shipped #3 as 9f2c1ab | |
| 311 | https://forge.example.com/alice/shop/pulls/3 | |
| 312 | ||
| 313 | ||
| 314 | ## forge ui | |
| 315 | ||
| 316 | The local terminal UI, the same as [](#forge) with no command. | |
| 317 | ||
| 318 | Flags: `--root DIR`. | |
| 319 | ||
| 320 | console | |
| 321 | $ forge ui | |
| 322 |
docs/DEVELOP.md +2
| @@ -2,6 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | Moved from README.md (its "## Develop" section), unchanged. |
| 4 | 4 | |
| 5 | Driving the forge from a shell — every `forge` command, its flags and one example: [](CLI.md). | |
| 6 | ||
| 5 | 7 | sh |
| 6 | 8 | bun test # protocol-level + adversarial suite |
| 7 | 9 | bun scripts/design-lint.ts # no colour or off-scale font-size beyond docs/design/baseline.json, no class docs/DESIGN.md §5 does not name |
test/cli-docs.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * docs/CLI.md keeps up with the CLI (#7372): every command `forge help` | |
| 3 | * prints has a `## forge <command>` heading there. The help is captured the | |
| 4 | * way a shell sees it — `bun src/cli/main.ts help` as a process — because | |
| 5 | * `main` writes to the real stdout, not to a writer a test can hand it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { readFileSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | ||
| 12 | const root = new URL("..", import.meta.url).pathname; | |
| 13 | const DOC = "docs/CLI.md"; | |
| 14 | ||
| 15 | async function help(): Promise<string> { | |
| 16 | const ran = Bun.spawn([process.execPath, "src/cli/main.ts", "help"], { | |
| 17 | cwd: root, | |
| 18 | env: { | |
| 19 | PATH: process.env["PATH"] ?? "", | |
| 20 | HOME: tmpdir(), | |
| 21 | NO_COLOR: "1", | |
| 22 | FORGE_KEYCHAIN: "0", | |
| 23 | }, | |
| 24 | stdout: "pipe", | |
| 25 | stderr: "pipe", | |
| 26 | }); | |
| 27 | const [stdout, exitCode] = await Promise.all([ | |
| 28 | new Response(ran.stdout).text(), | |
| 29 | ran.exited, | |
| 30 | ]); | |
| 31 | expect(exitCode).toBe(0); | |
| 32 | return stdout; | |
| 33 | } | |
| 34 | ||
| 35 | /** ` forge sync [--watch]` → "sync"; the bare ` forge the local…` → "": a command is one space after `forge`, a description many. */ | |
| 36 | function helpCommands(text: string): string[] { | |
| 37 | const names: string[] = []; | |
| 38 | for (const line of text.split("\n")) { | |
| 39 | const m = /^\s+forge(?: ([a-z][a-z-]*))?(?:\s|$)/.exec(line); | |
| 40 | if (m !== null) names.push(m[1] ?? ""); | |
| 41 | } | |
| 42 | return names; | |
| 43 | } | |
| 44 | ||
| 45 | /** `## forge`, `## forge sync` → "", "sync". */ | |
| 46 | function docHeadings(markdown: string): Set<string> { | |
| 47 | const found = new Set<string>(); | |
| 48 | for (const line of markdown.split("\n")) { | |
| 49 | const m = /^## forge(?: ([a-z][a-z-]*))?\s*$/.exec(line); | |
| 50 | if (m !== null) found.add(m[1] ?? ""); | |
| 51 | } | |
| 52 | return found; | |
| 53 | } | |
| 54 | ||
| 55 | test("helpCommands reads each command off its usage line", () => { | |
| 56 | const sample = [ | |
| 57 | "forge 0.0.1 — your workbench, on this machine", | |
| 58 | "", | |
| 59 | " forge the local terminal UI", | |
| 60 | " forge login <url> [--token T]", | |
| 61 | " forge logout", | |
| 62 | " config /x/config.json root /x/src", | |
| 63 | ].join("\n"); | |
| 64 | expect(helpCommands(sample)).toEqual(["", "login", "logout"]); | |
| 65 | }); | |
| 66 | ||
| 67 | test("every command `forge help` lists has a heading in docs/CLI.md", async () => { | |
| 68 | const commands = helpCommands(await help()); | |
| 69 | // The help has never had fewer than these; an empty parse is a broken test. | |
| 70 | expect(commands).toContain("login"); | |
| 71 | expect(commands).toContain("sync"); | |
| 72 | const headings = docHeadings(readFileSync(`${root}${DOC}`, "utf8")); | |
| 73 | const missing = commands | |
| 74 | .filter((c) => !headings.has(c)) | |
| 75 | .map((c) => (c === "" ? "## forge" : `## forge ${c}`)); | |
| 76 | if (missing.length > 0) | |
| 77 | throw new Error( | |
| 78 | `${DOC} has no section for ${missing.length} command(s) \`forge help\` lists. ` + | |
| 79 | `Add each heading, with what it does, its flags and one example:\n` + | |
| 80 | missing.map((h) => ` ${h}`).join("\n"), | |
| 81 | ); | |
| 82 | }); |
new above
offline