codebase-onboarder 0.4.1 β†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,8 +22,11 @@ Onboarder reads a software repository the way a senior engineer would: starting
22
22
  # Install from npm (Node 20+, zero runtime dependencies)
23
23
  npm install -g codebase-onboarder
24
24
 
25
- # First run: a short setup wizard, then the server
25
+ # Explore the repo you are standing in, right in your terminal
26
26
  onboarder
27
+
28
+ # …or start the web UI instead
29
+ onboarder start
27
30
  ```
28
31
 
29
32
  Or from source:
@@ -36,12 +39,53 @@ npm start
36
39
 
37
40
  Open **http://localhost:4310** in your browser (the CLI opens it for you).
38
41
 
42
+ - **Two front doors, one engine.** `onboarder` opens an interactive terminal session; `onboarder start` opens the web UI. Both run the *same* analyzer, so they can never disagree about a repository.
39
43
  - **Zero build steps**: Native ES modules.
40
44
  - **Zero runtime dependencies**: Powered by Node.js built-ins (`node:http`, `node:fs`, `node:crypto`).
41
45
  - **Offline ready**: Vendored Mermaid.js and Monaco Editor builds are included in `public/vendor/`.
42
46
 
43
47
  ---
44
48
 
49
+ ## πŸ–₯️ The terminal app
50
+
51
+ Type `onboarder` in any repo and you get the website, in your terminal. No server, no browser, no network β€” it reads the folder directly.
52
+
53
+ ```bash
54
+ onboarder # explore the current directory
55
+ onboarder explore ~/work/some-repo
56
+ onboarder --server # force the web UI (what bare `onboarder` used to do)
57
+ ```
58
+
59
+ It lands on a `map` of the repo, then waits:
60
+
61
+ ```
62
+ codebase > tour
63
+ codebase > find resolveImport
64
+ codebase > deps logger.js
65
+ codebase > show logger.js
66
+ codebase > exit
67
+ ```
68
+
69
+ | | |
70
+ |---|---|
71
+ | **map Β· tour Β· explain** | What this is, the reading order for a new teammate, and a file or folder explained in prose |
72
+ | **tree Β· find Β· show Β· deps** | Browse it, search it, read it, and trace what it connects to |
73
+ | **health Β· hubs Β· layers Β· patterns** | The analysis, in the same words the site uses |
74
+ | **stats Β· security Β· stack Β· entry Β· externals** | Numbers, findings, dependencies, and drift |
75
+ | **cd Β· rescan Β· web** | Switch repo, reload from disk, or start the web UI without leaving |
76
+
77
+ **It is the website's engine, not a reimplementation.** `onboarder explore` runs the same modules in the same order as `POST /api/scan` β€” `scanRepo` β†’ `detectManifest` β†’ `computeFacts` β†’ `buildSearchIndex` β€” and the same projections the browser's views are projections of. A second implementation would drift, and a drifted map is worse than no map.
78
+
79
+ Details worth knowing:
80
+
81
+ - **Forgiving paths.** `show logger.js` finds `server/logger.js`. A genuinely ambiguous name (`index.js`) lists the candidates instead of guessing β€” silently picking the wrong file is how a map loses your trust.
82
+ - **The site's search language.** `find ext:rs -test`, `find "exact phrase"`, `find /regex/` all work because it is the same query parser the web palette uses.
83
+ - **Fits your terminal.** Every line is fitted to the current width and re-fits on resize; prose wraps instead of running off the edge. `NO_COLOR` and `COLUMNS` are honored.
84
+ - **It refuses to hang.** Without a TTY, `onboarder explore` explains itself and exits rather than waiting for input that will never come. Scripts and CI keep working.
85
+ - **Bridges to the web.** `web` starts the UI in the background and prints the URL, without ending your session.
86
+
87
+ ---
88
+
45
89
  ## πŸš€ Ways to Load a Repository
46
90
 
47
91
  1. **Local Directory** β€” Enter any absolute path (`/Users/you/projects/repo` or `~/work/repo`). Analyzed in-place without copying files.
@@ -312,6 +356,13 @@ Cloudflare quick tunnels and Tailscale remain supported. They terminate TLS and
312
356
  codebase-onboarder/
313
357
  β”œβ”€β”€ bin/ # npm entry point (shebang trampoline) & postinstall note
314
358
  β”œβ”€β”€ cli/ # Onboarding wizard, config commands, doctor, tunnels
359
+ β”‚ β”œβ”€β”€ main.js # argv β†’ command; bare `onboarder` picks explorer vs server
360
+ β”‚ β”œβ”€β”€ ui.js # Terminal paint; --no-color / NO_COLOR strip it in one place
361
+ β”‚ └── explorer/ # The terminal app
362
+ β”‚ β”œβ”€β”€ app.js # The readline session, TTY guards, `web` bridge
363
+ β”‚ β”œβ”€β”€ session.js # Loads a repo exactly as POST /api/scan does
364
+ β”‚ β”œβ”€β”€ views.js # The site's screens, drawn as text (pure functions)
365
+ β”‚ └── commands.js# One command table: dispatch, help, and tests share it
315
366
  β”œβ”€β”€ server/ # Zero-dependency Node.js HTTP server
316
367
  β”‚ β”œβ”€β”€ index.js # createServer / startServer / startup banner
317
368
  β”‚ β”œβ”€β”€ config.js # Settings schema, normalization, atomic 0600 writes
@@ -339,7 +390,7 @@ codebase-onboarder/
339
390
  β”‚ β”œβ”€β”€ vendor/ # Vendored Mermaid & Monaco Editor (Offline)
340
391
  β”‚ β”œβ”€β”€ index.html # Main application interface
341
392
  β”‚ └── login.html # Self-hosted access-key sign-in
342
- └── tests/ # Comprehensive node:test suite (565 tests)
393
+ └── tests/ # Comprehensive node:test suite
343
394
  ```
344
395
 
345
396
  ---
@@ -0,0 +1,295 @@
1
+ // The interactive session.
2
+ //
3
+ // This is a prompt, not a full-screen TUI, and that is a deliberate choice.
4
+ // A curses app takes the scrollback, the selection, and copy-paste away, and it
5
+ // behaves badly over SSH and inside a tmux pane β€” which is exactly where people
6
+ // read a codebase. A readline session keeps all of that, prints scrollable
7
+ // output you can select, and degrades to something scriptable. The research
8
+ // agrees: a CLI is a conversation, and a TUI earns its cost only when you are
9
+ // manipulating state with the keyboard rather than reading.
10
+ //
11
+ // Two rules from the design research are load-bearing here:
12
+ // * TTY detection. A session that needs a terminal must never be started by a
13
+ // pipe, a cron job, or CI β€” so `runExplore` refuses and explains instead of
14
+ // hanging forever waiting for input nobody is there to type.
15
+ // * Flags β†’ env β†’ config precedence. `NO_COLOR` and `COLUMNS` are honored
16
+ // before anything is drawn, so a redirect gets plain, fitted text.
17
+
18
+ import readline from 'node:readline';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ import { openRepo } from './session.js';
23
+ import { CLEAR, EXIT, commandNames, helpText, lookup, tokenize } from './commands.js';
24
+ import { overview } from './views.js';
25
+ import { bold, cyan, dim, ok, bad, paint } from '../ui.js';
26
+ import { configPath, readSettings, serverUrls } from '../../server/config.js';
27
+ import { readPidFile, pidIsAlive } from '../../server/pidfile.js';
28
+
29
+ const VERSION = JSON.parse(
30
+ fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
31
+ ).version;
32
+
33
+ // The one-time note. `onboarder` used to start a web server; now it opens this.
34
+ // Someone who upgrades and has muscle memory for the old thing deserves to be
35
+ // told once where the server went, and then never again.
36
+ const HINT_MARKER = 'explorer-hint-v1';
37
+
38
+ export async function runExplore({ target = '.', flags = {}, out = console.log, err = console.error, version = VERSION } = {}) {
39
+ // The guard. `stdin` matters as much as `stdout`: a session with no input
40
+ // source is a hang, and a hang in CI is worse than any error.
41
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
42
+ out('');
43
+ out(' The terminal explorer needs an interactive terminal.');
44
+ out(dim(' In a script or a pipe, use these instead:'));
45
+ out(dim(' onboarder start start the web UI'));
46
+ out(dim(' onboarder start background start it detached'));
47
+ out(dim(' onboarder status | stop manage a running one'));
48
+ out(dim(' onboarder --help everything else'));
49
+ out('');
50
+ return 0;
51
+ }
52
+
53
+ let repo;
54
+ try {
55
+ repo = await openRepo(target, { onProgress: progressReporter(out) });
56
+ } catch (e) {
57
+ err(' ' + bad((e.message || String(e))));
58
+ err(dim(' Point it at a folder: onboarder explore /path/to/repo'));
59
+ return 1;
60
+ }
61
+
62
+ out('');
63
+ out(overview(repo));
64
+ out('');
65
+ if (await shouldShowHint(flags)) {
66
+ out(dim(' Note: `onboarder` used to start the web server. That is now `onboarder start`'));
67
+ out(dim(' (or the `web` command here) β€” this session reads the repo directly,'));
68
+ out(dim(' so it needs no server and works offline.'));
69
+ out('');
70
+ }
71
+ return session({ repo, flags, out, err, version });
72
+ }
73
+
74
+ // Scanning a large monorepo is the one genuinely slow thing this does, so it
75
+ // says so. A silent eight seconds is indistinguishable from a hang.
76
+ function progressReporter(out) {
77
+ let last = 0;
78
+ return ({ phase, done }) => {
79
+ if (!process.stdout.isTTY) return;
80
+ const now = Date.now();
81
+ if (phase === 'parse' && done && now - last > 400) {
82
+ last = now;
83
+ out(dim(`\r scanning… ${done} files`));
84
+ }
85
+ };
86
+ }
87
+
88
+ async function shouldShowHint(flags) {
89
+ if (process.env.ONBOARDER_NO_HINT) return false;
90
+ try {
91
+ const marker = path.join(path.dirname(flags.config || configPath()), HINT_MARKER);
92
+ const seen = await fs.promises.stat(marker).then(() => true).catch(() => false);
93
+ if (seen) return false;
94
+ await fs.promises.mkdir(path.dirname(marker), { recursive: true });
95
+ await fs.promises.writeFile(marker, 'shown\n');
96
+ return true;
97
+ } catch {
98
+ // A read-only config home is not a reason to nag on every launch.
99
+ return false;
100
+ }
101
+ }
102
+
103
+ // The read loop. Commands are queued rather than awaited inline, because
104
+ // readline emits the next line while a slow command is still running and two
105
+ // interleaved `cd`s would leave the session pointing at a repo nobody asked
106
+ // for.
107
+ function session({ repo, flags, out, err, version }) {
108
+ const ctx = {
109
+ repo,
110
+ version,
111
+ flags,
112
+ help: (topic) => helpText(ctx, topic),
113
+ rescan: () => reload(ctx, ctx.repo.root),
114
+ loadRepo: (where) => reload(ctx, where),
115
+ web: () => startWeb(ctx),
116
+ };
117
+
118
+ const rl = readline.createInterface({
119
+ input: process.stdin,
120
+ output: process.stdout,
121
+ prompt: promptFor(repo),
122
+ terminal: true,
123
+ historySize: 200,
124
+ });
125
+
126
+ let queue = Promise.resolve();
127
+ let closed = false;
128
+ let interrupts = 0;
129
+
130
+ rl.on('line', (line) => {
131
+ queue = queue.then(() => handleLine(ctx, line, rl, out, err));
132
+ });
133
+
134
+ // Ctrl-D on an empty line is the universal "I'm done". On a line with text,
135
+ // readline handles it itself; this only sees the empty-line case.
136
+ //
137
+ // Closing does NOT end the session. A piped or fast-typed sequence of commands
138
+ // can already be queued when the last line arrives, and resolving here would
139
+ // throw those away mid-flight β€” which is exactly what happens to `tour` when
140
+ // its input is followed immediately by `exit`. The exit waits on the queue.
141
+ rl.on('close', () => {
142
+ closed = true;
143
+ process.stdout.off('resize', onResize);
144
+ });
145
+
146
+ // Ctrl-C twice leaves. Once clears the line, which is what readline already
147
+ // does, and matches every other REPL people use.
148
+ rl.on('SIGINT', () => {
149
+ if (closed) return;
150
+ interrupts++;
151
+ if (interrupts >= 2) {
152
+ rl.close();
153
+ return;
154
+ }
155
+ out(dim('\n Ctrl-C again to leave.'));
156
+ rl.setPrompt(promptFor(ctx.repo));
157
+ rl.prompt();
158
+ });
159
+
160
+ // A resize redraws rather than leaving the prompt stranded mid-wrap.
161
+ const onResize = () => {
162
+ if (!closed) rl.write(null, { ctrl: true, name: 'l' });
163
+ };
164
+ process.stdout.on('resize', onResize);
165
+
166
+ queue = queue.then(() => rl.prompt());
167
+
168
+ return new Promise((resolve) => {
169
+ const poll = setInterval(() => {
170
+ if (!closed) return;
171
+ clearInterval(poll);
172
+ // Drain whatever is still running, then say goodbye. A command that throws
173
+ // has already been caught in `handleLine`, so this cannot reject.
174
+ queue.then(() => {
175
+ out('');
176
+ out(dim(' bye.'));
177
+ resolve(0);
178
+ });
179
+ }, 20);
180
+ });
181
+ }
182
+
183
+ function promptFor(repo) {
184
+ return paint(' ', 'cyan') + bold(repo.name.slice(0, 24)) + paint(' > ', 'gray');
185
+ }
186
+
187
+ async function handleLine(ctx, line, rl, out, err) {
188
+ const words = tokenize(line);
189
+ if (!words.length) {
190
+ rl.prompt();
191
+ return;
192
+ }
193
+ const [name, ...args] = words;
194
+ const cmd = lookup(name);
195
+
196
+ if (!cmd) {
197
+ // The nearest command by edit distance is almost always what was meant, and
198
+ // a one-line "did you mean" beats a paragraph about `help`.
199
+ const near = nearest(name);
200
+ err(' ' + bad('Unknown command: ' + name) + (near ? dim(' did you mean `' + near + '`?') : dim(' try `help`')));
201
+ rl.prompt();
202
+ return;
203
+ }
204
+
205
+ try {
206
+ const result = await cmd.run(ctx, args);
207
+ if (result === EXIT) {
208
+ rl.close();
209
+ return;
210
+ }
211
+ if (result === CLEAR) {
212
+ out('\x1b[2J\x1b[3J\x1b[H');
213
+ } else if (result) {
214
+ out(result);
215
+ }
216
+ } catch (e) {
217
+ err(' ' + bad((e.message || String(e))));
218
+ }
219
+ // The prompt carries the repo name, and `cd` can change which repo that is.
220
+ // Re-reading it after every command is cheaper than tracking which commands
221
+ // swap the repo, and it cannot go stale.
222
+ rl.setPrompt(promptFor(ctx.repo));
223
+ rl.prompt();
224
+ }
225
+
226
+ // Swap the loaded repo. Used by `cd` and `rescan`; the scan is the slow part,
227
+ // so the new name reaches the prompt only after it succeeds.
228
+ async function reload(ctx, where) {
229
+ const target = String(where || '').trim();
230
+ if (!target) return ' cd needs a folder β€” `cd ../other-repo`.';
231
+ const next = await openRepo(target, { onProgress: () => {} });
232
+ ctx.repo = next;
233
+ return '\n' + overview(next);
234
+ }
235
+
236
+ // The bridge between the two surfaces. If the server is already up this just
237
+ // prints where it is; if not, it starts it detached, so the person keeps their
238
+ // session. The URL is the same one `onboarder start` would print, because it
239
+ // comes from the same `serverUrls` the CLI and the banner use.
240
+ async function startWeb(ctx) {
241
+ const file = ctx.flags.config || configPath();
242
+ const settings = await readSettings(file).catch(() => null);
243
+ const urls = serverUrls(settings || undefined);
244
+ const recorded = readPidFile(file);
245
+
246
+ if (recorded && pidIsAlive(recorded)) {
247
+ return [' ' + ok('Already running.') + dim(` PID ${recorded.pid}`), ' ' + cyan(urls.local)].join('\n');
248
+ }
249
+
250
+ const { runStartBackground } = await import('../commands.js');
251
+ const code = await runStartBackground({
252
+ flags: { ...ctx.flags, json: true },
253
+ out: () => {},
254
+ err: () => {},
255
+ });
256
+ if (code !== 0) return ' ' + bad('Could not start the web UI.') + dim(' Try `onboarder doctor`.');
257
+ return [
258
+ ' ' + ok('Web UI started in the background.'),
259
+ ' ' + cyan(urls.local),
260
+ dim(' onboarder logs -f to watch it Β· onboarder stop to shut it down'),
261
+ ].join('\n');
262
+ }
263
+
264
+ // Cheap "did you mean": plain Levenshtein over the command names, with a
265
+ // distance cap of 2 so a wildly misspelled word gets "try `help`" instead of a
266
+ // confident wrong suggestion. A wrong suggestion is worse than none.
267
+ function nearest(word) {
268
+ const w = String(word).toLowerCase();
269
+ let best = null;
270
+ let bestScore = Infinity;
271
+ for (const name of commandNames()) {
272
+ const d = distance(w, name);
273
+ if (d < bestScore) {
274
+ bestScore = d;
275
+ best = name;
276
+ }
277
+ }
278
+ return bestScore <= 2 ? best : null;
279
+ }
280
+
281
+ function distance(a, b) {
282
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
283
+ for (let i = 1; i <= a.length; i++) {
284
+ const cur = [i];
285
+ for (let j = 1; j <= b.length; j++) {
286
+ cur[j] = Math.min(
287
+ prev[j] + 1,
288
+ cur[j - 1] + 1,
289
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
290
+ );
291
+ }
292
+ prev = cur;
293
+ }
294
+ return prev[b.length];
295
+ }
@@ -0,0 +1,197 @@
1
+ // The command table.
2
+ //
3
+ // One list, used three ways: to dispatch what someone typed, to build the `help`
4
+ // screen, and to assert in the tests that every command is reachable, has a
5
+ // summary, and is wired to a real view. A command that exists in one of those
6
+ // places but not the others is the kind of thing nobody notices until a user
7
+ // types it, so the table is the only place a command is defined at all.
8
+ //
9
+ // `run(ctx, args)` returns a string to print, or a marker the app layer acts on
10
+ // (`EXIT`, `CLEAR`). `args` is an array of already-parsed words, so quoting is
11
+ // handled once, in the tokenizer, and every command sees the same shape.
12
+
13
+ import * as V from './views.js';
14
+
15
+ export const EXIT = Symbol('exit');
16
+ export const CLEAR = Symbol('clear');
17
+
18
+ export const COMMANDS = [
19
+ {
20
+ name: 'help', aliases: ['?'], group: 'basics',
21
+ usage: 'help', summary: 'This list.',
22
+ run: (ctx) => ctx.help(),
23
+ },
24
+ {
25
+ name: 'map', aliases: ['overview', 'home'], group: 'basics',
26
+ usage: 'map', summary: 'What this repo is, and where to start.',
27
+ run: (ctx) => V.overview(ctx.repo),
28
+ },
29
+ {
30
+ name: 'tour', aliases: ['start', 'onboarding'], group: 'basics',
31
+ usage: 'tour', summary: 'The reading order a new teammate should follow.',
32
+ run: (ctx) => V.tour(ctx.repo),
33
+ },
34
+ {
35
+ name: 'explain', aliases: ['why', 'what'], group: 'basics',
36
+ usage: 'explain [file|folder]', summary: 'This repo, or one file/folder, in prose. No AI key needed.',
37
+ run: (ctx, args) => V.explain(ctx.repo, { target: args.join(' ') }),
38
+ },
39
+ {
40
+ name: 'tree', aliases: ['ls', 'files'], group: 'navigate',
41
+ usage: 'tree [folder] [depth]', summary: 'The file tree, with entries and hubs marked.',
42
+ run: (ctx, args) => {
43
+ // A lone number is the depth, not a folder called "2". `tree 3` is what
44
+ // everyone types when they want to see more; making them spell
45
+ // `tree . 3` would be pedantry in a tool built to be forgiving.
46
+ const onlyDepth = args.length === 1 && /^\d+$/.test(args[0]);
47
+ return V.tree(ctx.repo, {
48
+ sub: onlyDepth ? '' : (args[0] || ''),
49
+ depth: onlyDepth ? Number(args[0]) : (Number(args[1]) || 2),
50
+ });
51
+ },
52
+ },
53
+ {
54
+ name: 'find', aliases: ['search', 'grep'], group: 'navigate',
55
+ usage: 'find <query>', summary: 'Search. Supports ext:js, -exclude, "phrases", /regex/.',
56
+ run: (ctx, args) => V.find(ctx.repo, { query: args.join(' ') }),
57
+ },
58
+ {
59
+ name: 'show', aliases: ['open', 'cat', 'read'], group: 'navigate',
60
+ usage: 'show <file> [from] [count]', summary: 'Read a file. Name it loosely: `show logger.js` works.',
61
+ run: (ctx, args) => V.show(ctx.repo, { target: args[0] || '', from: Number(args[1]) || 0, count: Number(args[2]) || 0 }),
62
+ },
63
+ {
64
+ name: 'deps', aliases: ['connections', 'graph'], group: 'navigate',
65
+ usage: 'deps <file>', summary: 'What a file imports, and what imports it.',
66
+ run: (ctx, args) => V.deps(ctx.repo, { target: args.join(' ') }),
67
+ },
68
+ {
69
+ name: 'health', aliases: ['grade'], group: 'analyze',
70
+ usage: 'health', summary: 'Health grade, riskiest files, debt.',
71
+ run: (ctx) => V.health(ctx.repo),
72
+ },
73
+ {
74
+ name: 'hubs', aliases: ['core'], group: 'analyze',
75
+ usage: 'hubs', summary: 'The most depended-on files.',
76
+ run: (ctx) => V.hubs(ctx.repo),
77
+ },
78
+ {
79
+ name: 'layers', aliases: ['depth'], group: 'analyze',
80
+ usage: 'layers', summary: 'Import depth, shallowest first.',
81
+ run: (ctx) => V.layers(ctx.repo),
82
+ },
83
+ {
84
+ name: 'patterns', aliases: ['architecture'], group: 'analyze',
85
+ usage: 'patterns', summary: 'What the architecture looks like, in prose.',
86
+ run: (ctx) => V.patterns(ctx.repo),
87
+ },
88
+ {
89
+ name: 'stats', aliases: ['numbers'], group: 'analyze',
90
+ usage: 'stats', summary: 'Languages, biggest folders and files, complexity.',
91
+ run: (ctx) => V.stats(ctx.repo),
92
+ },
93
+ {
94
+ name: 'security', aliases: ['audit'], group: 'analyze',
95
+ usage: 'security', summary: 'Heuristic findings from the built-in rules.',
96
+ run: (ctx) => V.security(ctx.repo),
97
+ },
98
+ {
99
+ name: 'stack', aliases: ['packages'], group: 'analyze',
100
+ usage: 'stack', summary: 'Declared dependencies and frameworks.',
101
+ run: (ctx) => V.stack(ctx.repo),
102
+ },
103
+ {
104
+ name: 'entry', aliases: ['entries'], group: 'analyze',
105
+ usage: 'entry', summary: 'Recognized entry points and how far they reach.',
106
+ run: (ctx) => V.entry(ctx.repo),
107
+ },
108
+ {
109
+ name: 'externals', aliases: ['drift'], group: 'analyze',
110
+ usage: 'externals', summary: 'External packages, plus dependency drift.',
111
+ run: (ctx) => V.externals(ctx.repo),
112
+ },
113
+ {
114
+ name: 'about', aliases: ['version'], group: 'session',
115
+ usage: 'about', summary: 'Version, repo path, and what is indexed.',
116
+ run: (ctx) => V.about(ctx.repo, ctx.version),
117
+ },
118
+ {
119
+ name: 'rescan', aliases: ['reload'], group: 'session',
120
+ usage: 'rescan', summary: 'Re-read the repo from disk.',
121
+ run: (ctx) => ctx.rescan(),
122
+ },
123
+ {
124
+ name: 'cd', aliases: ['open-repo', 'use'], group: 'session',
125
+ usage: 'cd <folder>', summary: 'Load a different repository.',
126
+ run: (ctx, args) => ctx.loadRepo(args.join(' ')),
127
+ },
128
+ {
129
+ name: 'web', aliases: ['site', 'serve'], group: 'session',
130
+ usage: 'web', summary: 'Start the web UI and print its URL.',
131
+ run: (ctx) => ctx.web(),
132
+ },
133
+ {
134
+ name: 'clear', aliases: ['cls'], group: 'session',
135
+ usage: 'clear', summary: 'Clear the screen.',
136
+ run: () => CLEAR,
137
+ },
138
+ {
139
+ name: 'exit', aliases: ['quit', 'q'], group: 'session',
140
+ usage: 'exit', summary: 'Leave. Ctrl-D does the same.',
141
+ run: () => EXIT,
142
+ },
143
+ ];
144
+
145
+ // One lookup for every spelling a person might type, built once at import.
146
+ const BY_NAME = new Map();
147
+ for (const cmd of COMMANDS) {
148
+ BY_NAME.set(cmd.name, cmd);
149
+ for (const a of cmd.aliases) BY_NAME.set(a, cmd);
150
+ }
151
+
152
+ export function lookup(word) {
153
+ return BY_NAME.get(String(word || '').toLowerCase());
154
+ }
155
+
156
+ // Every canonical name, for "did you mean" matching. Aliases are deliberately
157
+ // excluded: suggesting `open` when someone typed `sho` is less useful than
158
+ // suggesting `show`, which is the word they were reaching for.
159
+ export function commandNames() {
160
+ return COMMANDS.map((c) => c.name);
161
+ }
162
+
163
+ // `help <command>` answers about one command; bare `help` lists them, grouped so
164
+ // the shape of the tool is visible rather than alphabetical.
165
+ export function helpText(ctx, topic = '') {
166
+ if (topic) {
167
+ const cmd = lookup(topic);
168
+ if (!cmd) return ` No command called "${topic}". Try \`help\`.`;
169
+ const also = cmd.aliases.length ? ` (also: ${cmd.aliases.join(', ')})` : '';
170
+ return [' ' + cmd.usage + also, ' ' + cmd.summary].join('\n');
171
+ }
172
+ const groups = new Map();
173
+ for (const cmd of COMMANDS) {
174
+ if (!groups.has(cmd.group)) groups.set(cmd.group, []);
175
+ groups.get(cmd.group).push(cmd);
176
+ }
177
+ const room = Math.max(...COMMANDS.map((c) => c.usage.length));
178
+ const out = [];
179
+ for (const [group, list] of groups) {
180
+ out.push(' ' + group.toUpperCase());
181
+ for (const c of list) out.push(' ' + c.usage.padEnd(room) + ' ' + c.summary);
182
+ }
183
+ out.push('');
184
+ out.push(' ' + (ctx?.repo ? ctx.repo.name : 'onboarder') + ' Β· Ctrl-D or `exit` to leave Β· Ctrl-C twice to quit');
185
+ return out.join('\n');
186
+ }
187
+
188
+ // Split a typed line into words, honoring quotes so `find "exact phrase"` and
189
+ // `show "my file.js"` arrive as one argument each. Done once, here, so no
190
+ // command has to re-implement it.
191
+ export function tokenize(line) {
192
+ const out = [];
193
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
194
+ let m;
195
+ while ((m = re.exec(String(line || '')))) out.push(m[1] ?? m[2] ?? m[3]);
196
+ return out;
197
+ }