@msareen/knowledge-hub-builder 0.1.7 → 0.1.8

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.
@@ -0,0 +1,698 @@
1
+ // khb list / khb go / khb agent / khb forget — the machine-level hub shortcuts.
2
+ //
3
+ // These are the only commands that work *outside* a hub, and the only ones that read
4
+ // ~/.khb/hubs-config.json. They move you between hubs; everything else operates inside
5
+ // one. Nothing here touches knowledge — see lib/registry.ts for why the registry is
6
+ // disposable by design.
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, readdirSync, readSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { basename, join, relative, resolve } from "node:path";
11
+ import { StringDecoder } from "node:string_decoder";
12
+ import { findHub, markerIn, version } from "./lib/paths";
13
+ import { sameLocation, rewritePaths } from "./lib/relocate";
14
+ import { clearMoved, staleLocations } from "./lib/upgrade";
15
+ import { diffSourcesYamlAll, applySourcesDiff } from "./lib/schema";
16
+ import {
17
+ CONFIG,
18
+ agentFor,
19
+ findHubEntry,
20
+ forgetHub,
21
+ isAlive,
22
+ listHubs,
23
+ loadConfig,
24
+ registerHub,
25
+ canonical,
26
+ relocateHub,
27
+ relocationCandidates,
28
+ saveConfig,
29
+ touchHub,
30
+ type HubEntry,
31
+ } from "./lib/registry";
32
+ import { takeFlag } from "./lib/args";
33
+ import { detail, section, totalElapsed } from "./lib/log";
34
+
35
+ const cmd = process.env.KHB_SUBCOMMAND ?? "go";
36
+ const argv = process.argv.slice(2);
37
+
38
+ /** Take `--name value`, removing both. */
39
+ function takeOpt(args: string[], name: string): string | undefined {
40
+ const i = args.indexOf(name);
41
+ if (i < 0) return undefined;
42
+ const v = args[i + 1];
43
+ if (v === undefined) {
44
+ console.error(`${name} needs a value`);
45
+ process.exit(1);
46
+ }
47
+ args.splice(i, 2);
48
+ return v;
49
+ }
50
+
51
+ const ago = (iso?: string): string => {
52
+ if (!iso) return "";
53
+ const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000);
54
+ if (!Number.isFinite(days)) return "";
55
+ return days <= 0 ? "today" : days === 1 ? "yesterday" : `${days}d ago`;
56
+ };
57
+
58
+ function printList(hubs: HubEntry[]): void {
59
+ const w = Math.max(4, ...hubs.map((h) => h.name.length));
60
+ hubs.forEach((h, i) => {
61
+ const live = isAlive(h);
62
+ const tag = live ? String(i + 1).padStart(2) : " x";
63
+ const note = live ? ago(h.lastUsed) : "MISSING";
64
+ console.log(` ${tag} ${h.name.padEnd(w)} ${h.description}`);
65
+ console.log(` ${" ".repeat(w)} ${h.path}${note ? ` (${note})` : ""}`);
66
+ });
67
+ }
68
+
69
+ /**
70
+ * What a cold `khb` prints when there is nothing to open and no terminal to ask on.
71
+ * The interactive path is `wizard()`; this is its non-TTY twin.
72
+ */
73
+ function noHubs(): never {
74
+ console.log(`No hubs registered on this machine (${CONFIG}).\n`);
75
+ console.log(`Create one: khb init <dir>`);
76
+ console.log(`Or register an existing: khb go <dir> (any path holding a khb.json)`);
77
+ console.log(`\nRun 'khb' on a terminal and it walks you through the first one.`);
78
+ process.exit(0);
79
+ }
80
+
81
+ /**
82
+ * Ask on the terminal. Returns undefined when there is no terminal to ask on — a piped or
83
+ * scripted khb must print and stop rather than block forever on a prompt nobody sees.
84
+ *
85
+ * Input is read key by key rather than through prompt(), for one reason: Esc. A prompt you
86
+ * can only escape by typing nothing and pressing Enter is a prompt you have to think about,
87
+ * and Enter already means "take the default" two questions earlier in the wizard. Esc means
88
+ * the same thing at every prompt in this file — stop, do nothing — so it is handled here,
89
+ * once, by ending the command. Ctrl-C likewise: raw mode suppresses the signal, so the
90
+ * prompt would otherwise be unquittable.
91
+ */
92
+ function ask(question: string): string | undefined {
93
+ if (!process.stdin.isTTY) return undefined;
94
+ try {
95
+ return readKeys(question);
96
+ } catch {
97
+ // A terminal that will not go into raw mode still answers a line at a time. prompt()
98
+ // returns null for a bare Enter as well as for a cancel; both are "the user typed
99
+ // nothing", which is an answer here — only a missing terminal is "cannot ask".
100
+ try {
101
+ return prompt(question) ?? "";
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * One line of input, read a keypress at a time. Throws if the terminal has no raw mode,
110
+ * which is `ask`'s cue to fall back; exits on Esc and Ctrl-C, restoring the terminal first
111
+ * since process.exit runs no `finally`.
112
+ */
113
+ function readKeys(question: string): string {
114
+ const stdin = process.stdin;
115
+ // Something for Atomics.wait to block on. It is never notified — the wait exists only to
116
+ // hand the CPU back between reads, which is the one way to sleep without going async.
117
+ const idle = new Int32Array(new SharedArrayBuffer(4));
118
+ stdin.setRawMode(true);
119
+ const stop = (code: number, text: string): never => {
120
+ stdin.setRawMode(false);
121
+ process.stdout.write(text);
122
+ process.exit(code);
123
+ };
124
+
125
+ process.stdout.write(question);
126
+ const buf = Buffer.alloc(64);
127
+ const utf8 = new StringDecoder("utf8");
128
+ let line = "";
129
+ let read = false;
130
+ try {
131
+ for (;;) {
132
+ let n = 0;
133
+ try {
134
+ n = readSync(0, buf, 0, buf.length, null);
135
+ } catch (e) {
136
+ const code = (e as NodeJS.ErrnoException).code;
137
+ // Nothing typed yet on a non-blocking stdin. Wait a moment before asking again —
138
+ // a bare `continue` here is a spin loop on one core for as long as the prompt is up.
139
+ if (code === "EAGAIN") {
140
+ Atomics.wait(idle, 0, 0, 15);
141
+ continue;
142
+ }
143
+ if (code !== "EOF") throw e;
144
+ }
145
+ // End of input before a single key: this terminal cannot be read this way — some
146
+ // consoles report EOF rather than blocking. Say so, so ask() falls back to a line
147
+ // reader. Answering "" here instead would be the worse bug by far: silent empty
148
+ // answers to every question, and a wizard that runs itself on defaults.
149
+ if (!n && !read) throw new Error("stdin: no keys to read");
150
+ if (!n) break; // ended mid-answer: take what was typed, as a bare Enter would
151
+ read = true;
152
+
153
+ // A lone Esc is a cancel. Esc with bytes behind it is an arrow or function key —
154
+ // terminals deliver those as one read — and has no meaning at a prompt.
155
+ if (buf[0] === 0x1b) {
156
+ if (n === 1) stop(0, "\ncancelled\n");
157
+ continue;
158
+ }
159
+
160
+ // Decoded as text, not bytes: paths and descriptions are typed here, and a name with
161
+ // an accent in it must not arrive as two mangled characters. The decoder holds any
162
+ // trailing half-character back until the read that completes it — a pasted line can
163
+ // land across two buffers.
164
+ for (const ch of utf8.write(buf.subarray(0, n))) {
165
+ const c = ch.codePointAt(0) ?? 0;
166
+ if (c === 0x0d || c === 0x0a) return finish(stdin, line); // Enter
167
+ if (c === 0x03) stop(130, "^C\n"); // Ctrl-C, which raw mode swallowed
168
+ if (c === 0x04 && !line) stop(0, "\ncancelled\n"); // Ctrl-D on an empty line
169
+ if (c === 0x7f || c === 0x08) {
170
+ // Backspace. Erase on screen too — raw mode echoes nothing on its own.
171
+ if (line) {
172
+ line = line.slice(0, -1);
173
+ process.stdout.write("\b \b");
174
+ }
175
+ continue;
176
+ }
177
+ if (c < 0x20) continue; // every other control key: ignore
178
+ line += ch;
179
+ process.stdout.write(ch);
180
+ }
181
+ }
182
+ } finally {
183
+ stdin.setRawMode(false);
184
+ }
185
+ return finish(stdin, line);
186
+ }
187
+
188
+ /** Leave the terminal as a line-based prompt would have: cooked mode, cursor on a new line. */
189
+ function finish(stdin: typeof process.stdin, line: string): string {
190
+ stdin.setRawMode(false);
191
+ process.stdout.write("\n");
192
+ return line;
193
+ }
194
+
195
+ /**
196
+ * Hand the terminal to the configured agent, running in the hub. This is the payoff of
197
+ * the registry: `khb` from a cold shell anywhere ends with an agent open on the right
198
+ * folder. khb cannot change the caller's shell directory — no child process can — so the
199
+ * `cd` line is printed for the human and the hub is passed to the agent as its cwd.
200
+ */
201
+ function launch(hub: HubEntry, agentName?: string, noAgent = false): never {
202
+ touchHub(hub.path);
203
+ console.log(`\n${hub.name} — ${hub.path}`);
204
+ console.log(` cd ${/\s/.test(hub.path) ? JSON.stringify(hub.path) : hub.path}`);
205
+
206
+ const cfg = loadConfig();
207
+ const agent = noAgent ? undefined : agentFor(cfg, agentName);
208
+ if (!agent) {
209
+ if (!noAgent) console.log(`\nNo default agent set — khb agent <claude|codex> to set one.`);
210
+ process.exit(0);
211
+ }
212
+
213
+ console.log(` launching ${agent.name}…\n`);
214
+ // shell:true on Windows so PATH shims (claude.cmd, codex.cmd) resolve; stdio inherited
215
+ // so the agent owns the terminal from here.
216
+ const r = spawnSync(agent.spec.command, agent.spec.args ?? [], {
217
+ cwd: hub.path,
218
+ stdio: "inherit",
219
+ shell: process.platform === "win32",
220
+ });
221
+ if (r.error) {
222
+ console.error(`\nCould not launch ${agent.name} (${agent.spec.command}): ${r.error.message}`);
223
+ console.error(`Fix the command: khb agent ${agent.name} --command <exe>`);
224
+ process.exit(1);
225
+ }
226
+ process.exit(r.status ?? 0);
227
+ }
228
+
229
+ // ------------------------------------------------------------------- first-run wizard
230
+ //
231
+ // A bare `khb` on a machine with no hubs used to print three lines of guidance and stop,
232
+ // which asks someone who has just installed the tool to go and read about it before they
233
+ // can do anything. Since we are already on a terminal and already know the one thing they
234
+ // need — that there is no hub yet — walk them into one instead.
235
+ //
236
+ // It only ever asks what `khb init` takes as flags, and calls the same createHub(): a hub
237
+ // born here is byte-identical to one made by hand. Every question has a default, so
238
+ // holding Enter through the whole thing produces a working hub.
239
+
240
+ /** Is this command actually runnable? Probed with --version, which no agent acts on. */
241
+ function onPath(command: string): boolean {
242
+ try {
243
+ const r = spawnSync(command, ["--version"], {
244
+ stdio: "ignore",
245
+ shell: process.platform === "win32",
246
+ timeout: 5000,
247
+ });
248
+ return !r.error && r.status === 0;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ /** Ask with a default that Enter accepts. */
255
+ function askWith(question: string, fallback: string): string {
256
+ const a = ask(`${question}${fallback ? ` [${fallback}]` : ""} `);
257
+ const t = (a ?? "").trim();
258
+ return t || fallback;
259
+ }
260
+
261
+ async function wizard(): Promise<never> {
262
+ const cfg = loadConfig();
263
+ console.log(`khb ${version()} — Knowledge Hub Builder\n`);
264
+ console.log(`No hubs on this machine yet. Let's set one up.\n`);
265
+ console.log(`A hub is a folder of plain markdown that holds your knowledge. khb does the`);
266
+ console.log(`mechanical half — pulling documents in, extracting text, checking structure —`);
267
+ console.log(`and your coding agent does the thinking half. Press Enter to take any default.\n`);
268
+
269
+ // 1. Where. Default under the home directory rather than cwd: a hub is long-lived, and
270
+ // the folder someone happens to be standing in when they first run khb rarely is.
271
+ const suggested = join(homedir(), "knowledge");
272
+ const dir = resolve(askWith(`1/5 Where should the hub live?`, suggested));
273
+
274
+ if (markerIn(dir)) {
275
+ // Already a hub — the machine simply had not heard of it. Adopt, do not re-create.
276
+ const entry = registerHub(dir);
277
+ console.log(`\n${dir} is already a hub — added it to the list as "${entry.name}".`);
278
+ console.log(`Open it: khb`);
279
+ process.exit(0);
280
+ }
281
+ if (existsSync(dir) && readdirSync(dir).length) {
282
+ const ok = askWith(` ${dir} is not empty. Put the hub there anyway? [y/N]`, "n");
283
+ if (!/^y/i.test(ok)) {
284
+ console.log(`\nStopped. Nothing was created. Re-run 'khb' to start again.`);
285
+ process.exit(0);
286
+ }
287
+ }
288
+
289
+ // 2-3. Identity. Both land in the hub's own khb.json, so they travel with the folder.
290
+ const name = askWith(`2/5 What should it be called?`, basename(dir) || "knowledge");
291
+ const description = askWith(`3/5 One line describing it (optional)?`, "");
292
+
293
+ // 4. The agent. Detected rather than asked blind — being offered a tool you do not have
294
+ // installed is a worse first question than being told which one was found.
295
+ const found = ["claude", "codex"].filter(onPath);
296
+ console.log(`\n4/5 Which agent should 'khb' open the hub with?`);
297
+ for (const [i, k] of Object.keys(cfg.agents).entries())
298
+ console.log(` ${i + 1}) ${k}${found.includes(k) ? " (found on PATH)" : ""}`);
299
+ console.log(` or type any command, or 'none' to just print the path`);
300
+ const agentAnswer = askWith(` Choice?`, found[0] ?? "none");
301
+ const keys = Object.keys(cfg.agents);
302
+ const picked =
303
+ /^\d+$/.test(agentAnswer) && keys[Number(agentAnswer) - 1] ? keys[Number(agentAnswer) - 1] : agentAnswer;
304
+
305
+ // 5. A first bundle, because an empty hub has nowhere to put anything and the next
306
+ // question a new user hits is where material goes. Skippable with '-'.
307
+ console.log(`\n5/5 A bundle is a unit of ownership — you, a team, a client, a project.`);
308
+ const bundle = askWith(` Name your first one ('-' to skip)?`, "personal");
309
+ const scope =
310
+ bundle === "-" ? "" : askWith(` One line on what it covers?`, "TODO scope");
311
+
312
+ // ---- act ----
313
+ const { createHub } = await import("./lib/create");
314
+ const { hub, entry } = createHub(dir, { name, description });
315
+ console.log(`\nCreated ${hub}`);
316
+
317
+ // scaffold.ts resolves the hub through util.ts at import time, so the hub must be
318
+ // announced before it is loaded — hence the import inside the branch, not at the top.
319
+ let madeBundle = false;
320
+ if (bundle !== "-") {
321
+ process.env.KHB_HUB = hub;
322
+ const { createBundle, VALID_NAME } = await import("./lib/scaffold");
323
+ if (VALID_NAME.test(bundle)) {
324
+ createBundle(bundle, scope);
325
+ console.log(` bundles/${bundle}/ — registered in outer.index.md`);
326
+ madeBundle = true;
327
+ } else {
328
+ console.log(` skipped the bundle: '${bundle}' is not a valid name (lowercase, digits, hyphens)`);
329
+ console.log(` add one later: khb new-bundle <name> "<scope>"`);
330
+ }
331
+ }
332
+
333
+ if (picked === "none" || !picked) {
334
+ cfg.defaultAgent = "";
335
+ } else {
336
+ cfg.agents[picked] = cfg.agents[picked] ?? { command: picked, args: [] };
337
+ cfg.defaultAgent = picked;
338
+ }
339
+ saveConfig(cfg);
340
+ console.log(` registered as "${entry.name}", agent: ${cfg.defaultAgent || "none"}`);
341
+
342
+ console.log(`\nFrom any terminal, 'khb' comes back here${cfg.defaultAgent ? ` and starts ${cfg.defaultAgent}` : ""}.`);
343
+ if (madeBundle)
344
+ console.log(`Next: add sources to bundles/${bundle}/sources.yaml, then 'khb ingest ${bundle}'.`);
345
+
346
+ const go = askWith(`\nOpen it now?`, "y");
347
+ if (!/^y/i.test(go)) process.exit(0);
348
+ launch({ ...entry, path: hub });
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------- list
352
+
353
+ if (cmd === "list") {
354
+ const asJson = takeFlag(argv, "--json");
355
+ const hubs = listHubs();
356
+ if (asJson) {
357
+ console.log(JSON.stringify({ config: CONFIG, ...loadConfig(), hubs }, null, 2));
358
+ process.exit(0);
359
+ }
360
+ if (!hubs.length) noHubs();
361
+
362
+ console.log(`Hubs on this machine (${CONFIG})\n`);
363
+ printList(hubs);
364
+ const cfg = loadConfig();
365
+ const agent = agentFor(cfg);
366
+ console.log(`\nDefault agent: ${agent ? `${agent.name} (${agent.spec.command})` : "none"}`);
367
+ console.log(`Open one: khb go ${hubs[0].name} | khb go 1 | khb`);
368
+ process.exit(0);
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------- update
372
+ //
373
+ // Two independent repairs, selectable together or apart:
374
+ //
375
+ // --path/-p the hub moved on disk. The machine's shortcut list still points at the old
376
+ // folder, and any absolute path inside the hub that named the old location is
377
+ // now a dangling reference — `sources.yaml` entries, `source:` headers in
378
+ // `raw/`, `log.md` rows, `resource:` front matter.
379
+ // --schema/-s a bundle's sources.yaml predates a field the current schema knows about
380
+ // (e.g. `exclude:`) — backfill it, per scripts/lib/schema.ts.
381
+ //
382
+ // No flag runs both. `upgrade` refreshes package-owned contract docs and never touches
383
+ // user content; `update` is the reverse — it only ever repairs what the user's own bundles
384
+ // record, never the contract docs. That split is why `update` is no longer too close a
385
+ // neighbor of `upgrade` to use (see decisions.md) — the two now do genuinely different jobs.
386
+
387
+ /** Repair paths. Returns true if anything failed to write. Exits directly on cases that
388
+ * need the user's input to proceed (ambiguous identity, declined a guess) — there is
389
+ * nothing useful for a caller to do with those short of the same exit. */
390
+ function repairPaths(newPath: string, fromOpt: string | undefined, dryRun: boolean): boolean {
391
+ // Where it used to be. Given explicitly, or inferred from the registry entry that now
392
+ // points at nothing — proof only, since rewriting paths against a wrong guess would
393
+ // corrupt real references. Every spelling seen along the way is kept: the string the
394
+ // registry stored is the one the hub's files are most likely to contain.
395
+ const oldSpellings: string[] = fromOpt ? [fromOpt, resolve(fromOpt)] : [];
396
+ // canonical(), not resolve(): the safety checks below compare this against newPath, and
397
+ // two spellings of one directory must not read as two different places.
398
+ let oldPath = fromOpt ? canonical(fromOpt) : undefined;
399
+
400
+ // What the hub says about itself comes first — better evidence than anything the machine
401
+ // registry holds, since the marker moved with the folder and the registry only ever
402
+ // described it from outside. A hub moved more than once before anyone repaired it lists
403
+ // every stale home; all of them are rewritten in this one pass.
404
+ const recorded = staleLocations(newPath);
405
+ oldSpellings.push(...recorded);
406
+ // The most recent stale home is the one to report and to repoint the registry from; the
407
+ // earlier ones still get rewritten, they just are not the move anyone is describing.
408
+ if (!oldPath && recorded.length) oldPath = canonical(recorded[recorded.length - 1]);
409
+
410
+ if (!oldPath) {
411
+ const { certain, likely } = relocationCandidates(newPath);
412
+ if (certain.length === 1) {
413
+ oldPath = resolve(certain[0].path);
414
+ oldSpellings.push(certain[0].path);
415
+ } else if (certain.length > 1) {
416
+ console.error(`Several registered hubs share this hub's identity — name the old path:`);
417
+ for (const h of certain) console.error(` khb update ${newPath} --path --from ${h.path}`);
418
+ process.exit(1);
419
+ } else if (likely.length === 1) {
420
+ // A name match is circumstantial — hubs registered before khb recorded an identity
421
+ // stamp have nothing better. Confirm it rather than rewrite files on a hunch.
422
+ const guess = likely[0];
423
+ const yes = ask(`Was this hub at ${guess.path} (registered as "${guess.name}")? [y/N] `);
424
+ if (yes === undefined) {
425
+ console.error(`This hub carries no identity stamp, so the old path cannot be proven.`);
426
+ console.error(`Likely: ${guess.path}`);
427
+ console.error(`Confirm it: khb update --path --from ${guess.path}`);
428
+ process.exit(1);
429
+ }
430
+ if (!/^y/i.test(yes.trim())) process.exit(0);
431
+ oldPath = resolve(guess.path);
432
+ oldSpellings.push(guess.path);
433
+ } else if (likely.length > 1) {
434
+ console.error(`No registered hub matches this one's identity, but these went missing:`);
435
+ for (const h of likely) console.error(` ${h.name} ${h.path}`);
436
+ console.error(`\nIf one of those is this hub, say so: khb update --path --from <old-path>`);
437
+ process.exit(1);
438
+ }
439
+ }
440
+
441
+ if (!oldPath) {
442
+ // Nothing broken to repair — but the move may predate the registry, so make sure the
443
+ // hub is at least on the list before reporting there was nothing to do.
444
+ const entry = registerHub(newPath);
445
+ // Leave the hub knowing where it is, so the *next* move needs no argument at all.
446
+ clearMoved(newPath);
447
+ detail(`nothing to repair: khb.json already records this location, and no registered`);
448
+ detail(`hub is missing from disk (${entry.name} is registered at ${entry.path})`);
449
+ return false;
450
+ }
451
+
452
+ if (sameLocation(oldPath, newPath)) {
453
+ console.error(`Old and new paths are the same directory:`);
454
+ console.error(` ${newPath}`);
455
+ console.error(`There is no move to repair. Name the real old path: khb update --path --from <old-path>`);
456
+ process.exit(1);
457
+ }
458
+
459
+ detail(`from: ${oldPath}`);
460
+ // Earlier homes, when the hub moved more than once before anyone repaired it. Spellings
461
+ // of `oldPath` itself are not listed — they are the same directory under another name.
462
+ for (const p of [...new Set(oldSpellings)].filter((p) => !sameLocation(canonical(p), oldPath!)))
463
+ detail(` also: ${p}`);
464
+ detail(`to: ${newPath}`);
465
+ detail(`mode: ${dryRun ? "report only, nothing written" : "rewrite in place"}`);
466
+
467
+ // Search for every string that named the old location, not just its canonical form:
468
+ // files written before khb canonicalized paths may hold a short-name or symlinked
469
+ // spelling of the same directory, and those references are just as broken.
470
+ const froms = [...new Set([oldPath, ...oldSpellings])].filter(Boolean);
471
+
472
+ detail(`scanning ${newPath} …`);
473
+ const { scanned, hits, failed } = rewritePaths(newPath, froms, newPath, {
474
+ dryRun,
475
+ onStart: (n) => detail(`${n} file(s) to check (skipping .git/, node_modules/, inbox/)`),
476
+ });
477
+
478
+ const total = hits.reduce((n, h) => n + h.count, 0);
479
+ if (!hits.length) {
480
+ detail(`no old-path references in ${scanned} text file(s) — nothing inside the hub to fix`);
481
+ } else {
482
+ detail(`${total} reference(s) in ${hits.length} of ${scanned} text file(s):`);
483
+ for (const h of hits) console.log(` ${String(h.count).padStart(3)} ${h.file}`);
484
+ }
485
+ for (const f of failed) console.error(` could not write ${f.file}: ${f.reason}`);
486
+
487
+ if (dryRun) {
488
+ detail(`nothing was written`);
489
+ return false;
490
+ }
491
+
492
+ const entry = relocateHub(oldPath, newPath);
493
+ // The hub's own record of the move is the backlog, so it is cleared by the thing that
494
+ // works the backlog off — otherwise every later command would go on announcing a move
495
+ // that has already been repaired. Not on --dry-run: nothing was repaired there.
496
+ if (!failed.length) clearMoved(newPath);
497
+ detail(`${total} reference(s) rewritten in ${hits.length} file(s)`);
498
+ detail(`registry: ${entry.name} now points at ${entry.path}`);
499
+ detail(`khb.json: path now ${newPath}`);
500
+ return failed.length > 0;
501
+ }
502
+
503
+ /** Backfill sources.yaml to the current schema, across every bundle. Never fails short of
504
+ * an I/O exception, which throws rather than being reported as a soft failure. */
505
+ function repairSchema(hub: string, dryRun: boolean): void {
506
+ const diffs = diffSourcesYamlAll(hub);
507
+ if (!diffs.length) {
508
+ detail(`sources.yaml already current in every bundle`);
509
+ return;
510
+ }
511
+ const totalFields = diffs.reduce((n, d) => n + d.changes.length, 0);
512
+ detail(`${totalFields} field(s) across ${diffs.length} bundle(s):`);
513
+ for (const d of diffs) {
514
+ console.log(` ${relative(hub, d.path)}`);
515
+ for (const c of d.changes) console.log(` ${c}`);
516
+ }
517
+ if (dryRun) {
518
+ detail(`nothing was written`);
519
+ return;
520
+ }
521
+ for (const d of diffs) applySourcesDiff(d);
522
+ detail(`${diffs.length} file(s) updated`);
523
+ }
524
+
525
+ if (cmd === "update") {
526
+ const doPath = takeFlag(argv, "--path", "-p");
527
+ const doSchema = takeFlag(argv, "--schema", "-s");
528
+ const dryRun = takeFlag(argv, "--dry-run");
529
+ const fromOpt = takeOpt(argv, "--from");
530
+ const both = !doPath && !doSchema;
531
+ const [dest] = argv;
532
+
533
+ // The hub is wherever it is now: the path given, else the hub containing cwd.
534
+ const newPath = canonical(dest ?? findHub() ?? process.cwd());
535
+ if (!markerIn(newPath)) {
536
+ console.error(`Not a KHB hub: ${newPath}`);
537
+ console.error(`Run 'khb update' from inside the moved hub, or name it: khb update <new-path>`);
538
+ process.exit(1);
539
+ }
540
+
541
+ // State the whole plan before doing any of it — same contract as ingest: a command that
542
+ // rewrites files says what it is about to rewrite, and where, before the first write.
543
+ console.log(`khb update${dryRun ? " (dry run)" : ""}`);
544
+
545
+ let failed = false;
546
+ if (both || doPath) {
547
+ section(`path`);
548
+ failed = repairPaths(newPath, fromOpt, dryRun) || failed;
549
+ }
550
+ if (both || doSchema) {
551
+ section(`schema`);
552
+ repairSchema(newPath, dryRun);
553
+ }
554
+
555
+ console.log(`\ndone in ${totalElapsed()}`);
556
+ if (dryRun) console.log(`Re-run without --dry-run to apply.`);
557
+ else console.log(`Next: khb lint`);
558
+ process.exit(failed ? 1 : 0);
559
+ }
560
+
561
+ // --------------------------------------------------------------------------- forget
562
+
563
+ if (cmd === "forget") {
564
+ const [what] = argv;
565
+ if (!what) {
566
+ console.error(`Usage: khb forget <name|path>`);
567
+ console.error(`Removes the shortcut only — the hub folder is left untouched.`);
568
+ process.exit(1);
569
+ }
570
+ const gone = forgetHub(findHubEntry(what)?.path ?? what);
571
+ if (!gone) {
572
+ console.error(`Not registered: ${what}`);
573
+ process.exit(1);
574
+ }
575
+ console.log(`Forgot ${gone.name} (${gone.path}). The folder itself is untouched.`);
576
+ process.exit(0);
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------- agent
580
+
581
+ if (cmd === "agent") {
582
+ const command = takeOpt(argv, "--command");
583
+ const argsOpt = takeOpt(argv, "--args");
584
+ const [name] = argv;
585
+ const cfg = loadConfig();
586
+
587
+ if (!name && !command) {
588
+ const cur = agentFor(cfg);
589
+ console.log(`Default agent: ${cur ? `${cur.name} (${cur.spec.command})` : "none"}`);
590
+ console.log(`\nKnown:`);
591
+ for (const [k, v] of Object.entries(cfg.agents))
592
+ console.log(` ${k === cfg.defaultAgent ? "*" : " "} ${k.padEnd(8)} ${[v.command, ...(v.args ?? [])].join(" ")}`);
593
+ console.log(`\nSet: khb agent codex`);
594
+ console.log(` khb agent claude --command claude --args "--continue"`);
595
+ console.log(`Off: khb agent none (khb go just prints the path)`);
596
+ process.exit(0);
597
+ }
598
+
599
+ if (name === "none") {
600
+ cfg.defaultAgent = "";
601
+ saveConfig(cfg);
602
+ console.log(`Default agent cleared — khb go will print the hub path and stop.`);
603
+ process.exit(0);
604
+ }
605
+
606
+ const key = name ?? cfg.defaultAgent;
607
+ const existing = cfg.agents[key];
608
+ cfg.agents[key] = {
609
+ command: command ?? existing?.command ?? key,
610
+ args: argsOpt !== undefined ? argsOpt.split(" ").filter(Boolean) : (existing?.args ?? []),
611
+ };
612
+ cfg.defaultAgent = key;
613
+ saveConfig(cfg);
614
+ const spec = cfg.agents[key];
615
+ console.log(`Default agent: ${key} — ${[spec.command, ...(spec.args ?? [])].join(" ")}`);
616
+ console.log(`Saved to ${CONFIG}`);
617
+ process.exit(0);
618
+ }
619
+
620
+ // ------------------------------------------------------------------------------- go
621
+
622
+ const wantPath = takeFlag(argv, "--path");
623
+ const noAgent = takeFlag(argv, "--no-agent") || wantPath;
624
+ const agentName = takeOpt(argv, "--agent");
625
+ const [what] = argv;
626
+
627
+ // Named target: resolve through the registry, then fall back to any path that is a hub —
628
+ // `khb go ../other-hub` should work whether or not it has been seen before.
629
+ if (what) {
630
+ let entry = findHubEntry(what);
631
+ if (!entry && existsSync(resolve(what)) && markerIn(resolve(what))) entry = registerHub(resolve(what));
632
+ if (!entry) {
633
+ console.error(`No such hub: ${what}`);
634
+ console.error(`Registered hubs: khb list`);
635
+ process.exit(1);
636
+ }
637
+ if (!isAlive(entry)) {
638
+ console.error(`${entry.name} is registered at ${entry.path}, but that is no longer a hub.`);
639
+ console.error(`Drop the shortcut: khb forget ${entry.name}`);
640
+ process.exit(1);
641
+ }
642
+ if (wantPath) {
643
+ console.log(entry.path);
644
+ process.exit(0);
645
+ }
646
+ launch(entry, agentName, noAgent);
647
+ }
648
+
649
+ const hubs = listHubs().filter(isAlive);
650
+ // Nothing to open. On a terminal that is not an error, it is the first run — walk them
651
+ // into a hub. `--path` is a script asking for a path, so it never starts a conversation.
652
+ if (!hubs.length) {
653
+ if (wantPath || !process.stdin.isTTY) noHubs();
654
+ await wizard();
655
+ }
656
+
657
+ // Exactly one hub: there is nothing to choose, so confirm rather than list. Enter accepts.
658
+ if (hubs.length === 1) {
659
+ const only = hubs[0];
660
+ if (wantPath) {
661
+ console.log(only.path);
662
+ process.exit(0);
663
+ }
664
+ const cfg = loadConfig();
665
+ const agent = noAgent ? undefined : agentFor(cfg);
666
+ const answer = ask(
667
+ `Open ${only.name} (${only.path})${agent ? ` with ${agent.name}` : ""}? [Y/n] `,
668
+ );
669
+ if (answer === undefined) {
670
+ // No terminal to ask on — say where it is and how to go there, then stop.
671
+ console.log(`One hub registered:\n`);
672
+ printList([only]);
673
+ console.log(`\nOpen it: khb go ${only.name}`);
674
+ process.exit(0);
675
+ }
676
+ if (/^n/i.test(answer.trim())) process.exit(0);
677
+ launch(only, agentName, noAgent);
678
+ }
679
+
680
+ // More than one: show the list and take a pick.
681
+ console.log(`Hubs on this machine (${CONFIG})\n`);
682
+ printList(hubs);
683
+
684
+ if (wantPath) process.exit(0); // ambiguous: a script must name the hub it means
685
+
686
+ const answer = ask(`\nOpen which? [1-${hubs.length}, Esc to cancel] `);
687
+ if (answer === undefined) {
688
+ console.log(`\nOpen one: khb go ${hubs[0].name} | khb go 1`);
689
+ process.exit(0);
690
+ }
691
+ const picked = answer.trim();
692
+ if (!picked) process.exit(0);
693
+ const chosen = findHubEntry(picked);
694
+ if (!chosen || !isAlive(chosen)) {
695
+ console.error(`Not a listed hub: ${picked}`);
696
+ process.exit(1);
697
+ }
698
+ launch(chosen, agentName, noAgent);