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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/7372-docs-cli-md-says-every-forge-command-wit → main 5 commits fork

  • Merged. torque/7372-docs-cli-md-says-every-forge-command-wit was merged into main as 000e465.

Review

Sign in to review this change.
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
434434 - [`docs/FEED.md`](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload.
435435 - [`docs/PULLS.md`](docs/PULLS.md) — resolving a conflicted pull without a clone.
436436 - [`docs/GIT.md`](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories.
437 - [`docs/CLI.md`](docs/CLI.md) — the `forge` command on your machine: install, log in, and every command with its flags and one example.
437438 - [`docs/features/csv-view.md`](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).
438439 - [`docs/features/file-media.md`](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.
439440 - [`docs/ROLES.md`](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`](#forge-login) and bring your repos
25 down with [`forge sync`](#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 --json`](#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`](#forge) with no command.
317
318 Flags: `--root DIR`.
319
320 ```console
321 $ forge ui
322 ```
docs/DEVELOP.md +2
@@ -2,6 +2,8 @@
22
33 Moved from README.md (its "## Develop" section), unchanged.
44
5 Driving the forge from a shell — every `forge` command, its flags and one example: [docs/CLI.md](CLI.md).
6
57 ```sh
68 bun test # protocol-level + adversarial suite
79 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs