@msareen/knowledge-hub-builder 0.2.2 → 0.2.3

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/scripts/init.ts CHANGED
@@ -12,6 +12,7 @@ import { resolve, basename } from "node:path";
12
12
  import { MARKER, markerIn } from "./lib/paths";
13
13
  import { recordLocation, upgradeHub, updateHint } from "./lib/upgrade";
14
14
  import { takeOpt, rejectUnknownFlags } from "./lib/args";
15
+ import { paint, paintErr } from "./lib/color";
15
16
 
16
17
  const upgrading = process.env.KHB_SUBCOMMAND === "upgrade";
17
18
  const argv = process.argv.slice(2);
@@ -43,13 +44,17 @@ if (upgrading) {
43
44
  touchHub(HUB);
44
45
  const { moved } = recordLocation(HUB);
45
46
  if (moved) {
46
- console.log(`This hub was at ${moved} and is now at ${HUB}.`);
47
+ console.log(
48
+ `${paint.warn("This hub was at")} ${paint.path(moved)} and is now at ${paint.path(HUB)}.`,
49
+ );
47
50
  console.log(` absolute paths recorded inside it still name the old location.`);
48
- console.log(` repair them: khb update --path (--dry-run to preview)`);
51
+ console.log(
52
+ ` repair them: ${paint.cmd("khb update --path")} ${paint.dim("(--dry-run to preview)")}`,
53
+ );
49
54
  }
50
55
 
51
56
  const { from, to, synced, pruned, renamed } = upgradeHub(HUB);
52
- console.log(`Upgraded ${HUB}: ${from ?? "?"} -> ${to}`);
57
+ console.log(`${paint.ok("Upgraded")} ${paint.path(HUB)}: ${from ?? "?"} -> ${paint.name(to)}`);
53
58
  // An empty list is not an empty result: in the khb development repo the package *is* the
54
59
  // hub, so every managed path is its own source and there is genuinely nothing to copy.
55
60
  // Printing a bare "refreshed:" there reads as a failure rather than as the no-op it is.
@@ -60,28 +65,32 @@ if (upgrading) {
60
65
  );
61
66
  if (renamed) console.log(` renamed: ${renamed} -> ${MARKER}`);
62
67
  if (pruned.length) console.log(` removed (no longer part of the contract): ${pruned.join(", ")}`);
63
- console.log(`Your bundles/ and outer.index.md were not touched. Next: khb lint`);
68
+ console.log(`Your bundles/ and outer.index.md were not touched. Next: ${paint.cmd("khb lint")}`);
64
69
  const hint = updateHint(HUB);
65
70
  if (hint) console.log(hint);
66
71
  } else {
67
72
  const hub = resolve(dirArg ?? process.cwd());
68
73
 
69
74
  if (markerIn(hub)) {
70
- console.error(`Already a KHB hub: ${hub}`);
71
- console.error(`To refresh its contract docs: khb upgrade`);
75
+ console.error(`${paintErr.bad("Already a KHB hub:")} ${paintErr.path(hub)}`);
76
+ console.error(`To refresh its contract docs: ${paintErr.cmd("khb upgrade")}`);
72
77
  process.exit(1);
73
78
  }
74
79
 
75
80
  const { createHub } = await import("./lib/create");
76
81
  const { synced, entry } = createHub(hub, { name: nameOpt, description: descOpt });
77
82
 
78
- console.log(`Hub created: ${hub}`);
83
+ console.log(`${paint.ok("Hub created:")} ${paint.path(hub)}`);
79
84
  console.log(` khb.json, outer.index.md, bundles/, .gitignore, .gitattributes`);
80
85
  console.log(` contract docs (package-owned, refreshed by 'khb upgrade'): ${synced.join(", ")}`);
81
- console.log(`\nNext:`);
82
- console.log(` cd ${basename(hub)}`);
83
- console.log(` git init # optional, but recommended`);
84
- console.log(` khb new-bundle <name> "<scope>" # your first bundle`);
86
+ console.log(`\n${paint.head("Next")}:`);
87
+ console.log(` ${paint.cmd(`cd ${basename(hub)}`)}`);
88
+ console.log(` ${paint.cmd("git init")} ${paint.dim("# optional, but recommended")}`);
89
+ console.log(
90
+ ` ${paint.cmd('khb new-bundle <name> "<scope>"')} ${paint.dim("# your first bundle")}`,
91
+ );
85
92
  console.log(`\nThen open this folder with Claude or Codex — both load AGENTS.md and the workflow skills.`);
86
- console.log(`Registered as "${entry.name}" — from any terminal, 'khb' comes back here and starts your agent.`);
93
+ console.log(
94
+ `Registered as ${paint.name(`"${entry.name}"`)} — from any terminal, '${paint.cmd("khb")}' comes back here and starts your agent.`,
95
+ );
87
96
  }
@@ -0,0 +1,75 @@
1
+ // Colour for the terminal — semantic names only, so every colour decision lives in this
2
+ // file and call sites say what a thing *is* rather than which escape code it wants.
3
+ //
4
+ // Two palettes, one per stream, because khb deliberately splits its output: a command's
5
+ // result goes to stdout and its asides go to stderr (the version-drift notice in cli.ts,
6
+ // the in-place counter in log.ts). `khb list > hubs.txt` run on a terminal should write a
7
+ // clean file while the warning still reaching the terminal keeps its colour, and a single
8
+ // shared flag cannot be right for both.
9
+ //
10
+ // NO_COLOR (any value) turns everything off; FORCE_COLOR (anything but "0") turns it on
11
+ // even through a pipe. Both are the usual informal conventions, and both are what a CI log
12
+ // or a test harness will reach for.
13
+
14
+ type Paint = (text: string) => string;
15
+
16
+ /** Semantic roles. Nothing outside this file names a colour. */
17
+ export interface Palette {
18
+ /** A section heading. */
19
+ head: Paint;
20
+ /** A literal command the user can type — the one thing they should be able to spot. */
21
+ cmd: Paint;
22
+ /** A filesystem path. */
23
+ path: Paint;
24
+ /** A hub, bundle or agent name. */
25
+ name: Paint;
26
+ /** Healthy, finished, nothing owed. */
27
+ ok: Paint;
28
+ /** Needs attention, but the command still did its job. */
29
+ warn: Paint;
30
+ /** A failure. */
31
+ bad: Paint;
32
+ /** Secondary text that should not compete with the line it sits under. */
33
+ dim: Paint;
34
+ bold: Paint;
35
+ }
36
+
37
+ // Closing codes matter for nesting: colour closes with 39 (default foreground) and
38
+ // bold/dim with 22, so an inner span never cancels the attribute wrapping it.
39
+ const wrap =
40
+ (open: number, close: number): Paint =>
41
+ (text) =>
42
+ `\x1b[${open}m${text}\x1b[${close}m`;
43
+
44
+ const plain: Paint = (text) => text;
45
+
46
+ function supported(stream: NodeJS.WriteStream): boolean {
47
+ if (process.env.NO_COLOR) return false;
48
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
49
+ if (process.env.TERM === "dumb") return false;
50
+ return Boolean(stream.isTTY);
51
+ }
52
+
53
+ function palette(stream: NodeJS.WriteStream): Palette {
54
+ if (!supported(stream))
55
+ return { head: plain, cmd: plain, path: plain, name: plain, ok: plain, warn: plain, bad: plain, dim: plain, bold: plain };
56
+ const bold = wrap(1, 22);
57
+ const dim = wrap(2, 22);
58
+ return {
59
+ head: bold,
60
+ cmd: wrap(36, 39), // cyan
61
+ path: dim,
62
+ name: bold,
63
+ ok: wrap(32, 39), // green
64
+ warn: wrap(33, 39), // yellow
65
+ bad: wrap(31, 39), // red
66
+ dim,
67
+ bold,
68
+ };
69
+ }
70
+
71
+ /** Colours for stdout — a command's actual output. */
72
+ export const paint = palette(process.stdout);
73
+
74
+ /** Colours for stderr — warnings, errors, and everything said in the margin. */
75
+ export const paintErr = palette(process.stderr);
@@ -0,0 +1,364 @@
1
+ // Schema check for the machine-level config, ~/.khb/hubs-config.json.
2
+ //
3
+ // The registry is the one khb file a person is invited to open in an editor — it holds
4
+ // their agent command and their hub list — and `loadConfig()` is deliberately forgiving:
5
+ // it fills in defaults, ignores keys it does not know, and treats an unparseable file as
6
+ // an empty one so a damaged registry never blocks `khb lint` in a hub that is perfectly
7
+ // fine. That forgiveness is right at load time and wrong as the only feedback anyone ever
8
+ // gets: a typo'd `defaultagent` is silently ignored, a hand-pasted duplicate makes `khb
9
+ // forget` look broken, and a hub renamed in its own khb.json goes on being listed under
10
+ // the old name until something happens to run in it.
11
+ //
12
+ // So the tolerance stays, and the diagnosis lives here: one checker, two readers. `khb
13
+ // doctor` reports what it finds and names the command that repairs it — doctor writes
14
+ // nothing, ever — and `khb config fix` is that command. Neither has its own copy of the
15
+ // rules.
16
+ //
17
+ // A finding carries a `repair` only when the fix is mechanical and loses nothing. Anything
18
+ // that needs a human decision (which of two same-named hubs should be renamed, whether a
19
+ // missing folder is deleted or on an unplugged drive) is reported with the command to run
20
+ // and left alone.
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { isAbsolute, join, resolve } from "node:path";
23
+ import { MARKER, markerIn } from "./paths";
24
+ import {
25
+ CONFIG,
26
+ canonical,
27
+ describeHub,
28
+ isAlive,
29
+ loadConfig,
30
+ markerFields,
31
+ onPath,
32
+ samePath,
33
+ type AgentSpec,
34
+ type Config,
35
+ type HubEntry,
36
+ } from "./registry";
37
+
38
+ export type Finding = {
39
+ /** error: something is being silently dropped or is unusable. warn: it still works. */
40
+ level: "error" | "warn";
41
+ /** What is wrong, in one line. */
42
+ what: string;
43
+ /** The command that puts it right. */
44
+ fix: string;
45
+ /**
46
+ * Present only when `khb config fix` can repair it unattended. Mutates the *normalized*
47
+ * config, which is then written back.
48
+ */
49
+ repair?: (cfg: Config) => void;
50
+ };
51
+
52
+ export type ConfigReport = {
53
+ path: string;
54
+ exists: boolean;
55
+ /** False when the file is there but unparseable — everything in it is being ignored. */
56
+ readable: boolean;
57
+ findings: Finding[];
58
+ };
59
+
60
+ /** Top-level keys the schema defines. Anything else is dropped by `loadConfig`. */
61
+ const KNOWN_KEYS = ["version", "defaultAgent", "agents", "hubs"];
62
+ /** Keys a hub entry may carry. Unlike the top level, unknown ones here survive a rewrite. */
63
+ const KNOWN_HUB_KEYS = ["name", "description", "path", "added", "lastUsed", "created"];
64
+
65
+ const isTimestamp = (value: unknown) => typeof value === "string" && !Number.isNaN(Date.parse(value));
66
+
67
+ /**
68
+ * Everything wrong with the machine config, worst first.
69
+ *
70
+ * `probeAgent` runs the default agent's command to see whether it exists. It is the only
71
+ * check that costs anything, and the only one a caller may want to skip.
72
+ */
73
+ export function checkConfig({ probeAgent = true } = {}): ConfigReport {
74
+ const findings: Finding[] = [];
75
+ const add = (finding: Finding) => findings.push(finding);
76
+
77
+ if (!existsSync(CONFIG))
78
+ // Not a fault: the file is written on first use. Nothing to check.
79
+ return { path: CONFIG, exists: false, readable: true, findings };
80
+
81
+ let raw: Record<string, unknown> | undefined;
82
+ try {
83
+ const parsed: unknown = JSON.parse(readFileSync(CONFIG, "utf8"));
84
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
85
+ raw = parsed as Record<string, unknown>;
86
+ } catch (e) {
87
+ add({
88
+ level: "error",
89
+ what: `not valid JSON (${(e as Error).message}) — khb is ignoring the whole file, so every hub shortcut and your agent setting are gone until it parses`,
90
+ fix: "khb config edit",
91
+ });
92
+ return { path: CONFIG, exists: true, readable: false, findings };
93
+ }
94
+
95
+ // The normalized view: what khb actually acts on. Comparing it against `raw` is how the
96
+ // silent drops become visible.
97
+ const cfg = loadConfig();
98
+
99
+ // ---- file shape -----------------------------------------------------------------------
100
+ const unknown = Object.keys(raw).filter((key) => !KNOWN_KEYS.includes(key));
101
+ if (unknown.length)
102
+ add({
103
+ level: "warn",
104
+ what: `unknown top-level key(s) ignored on load: ${unknown.join(", ")}${
105
+ unknown.some((key) => KNOWN_KEYS.some((known) => known.toLowerCase() === key.toLowerCase()))
106
+ ? " — one of them differs from a real key only in case"
107
+ : ""
108
+ }`,
109
+ fix: "khb config fix",
110
+ // Nothing to do: these exist only in the file, and the rewrite is what removes them.
111
+ repair: () => {},
112
+ });
113
+
114
+ if (raw.version !== undefined && raw.version !== 1)
115
+ add({
116
+ level: "warn",
117
+ what: `version is ${JSON.stringify(raw.version)}, not 1 — this file may have been written by a different khb`,
118
+ fix: "khb config view (then khb config fix if the contents look right)",
119
+ });
120
+
121
+ // ---- agents ---------------------------------------------------------------------------
122
+ for (const [name, spec] of Object.entries(cfg.agents)) {
123
+ const command = (spec as AgentSpec)?.command;
124
+ if (typeof command !== "string" || !command.trim())
125
+ add({
126
+ level: "error",
127
+ what: `agent '${name}' has no command — launching it would spawn nothing`,
128
+ fix: `khb agent ${name} --command <exe>`,
129
+ repair: (config) => {
130
+ config.agents[name] = { command: name, args: (spec as AgentSpec)?.args ?? [] };
131
+ },
132
+ });
133
+ else if ((spec as AgentSpec).args !== undefined && !Array.isArray((spec as AgentSpec).args))
134
+ add({
135
+ level: "error",
136
+ what: `agent '${name}' has a non-list 'args'`,
137
+ fix: `khb agent ${name} --args "…"`,
138
+ repair: (config) => {
139
+ config.agents[name] = { command, args: [] };
140
+ },
141
+ });
142
+ }
143
+
144
+ if (cfg.defaultAgent && !cfg.agents[cfg.defaultAgent])
145
+ add({
146
+ level: "warn",
147
+ what: `defaultAgent '${cfg.defaultAgent}' is not in 'agents' — khb go runs it as a bare command, which works but records nothing about it`,
148
+ fix: "khb config fix",
149
+ repair: (config) => {
150
+ config.agents[config.defaultAgent] = { command: config.defaultAgent, args: [] };
151
+ },
152
+ });
153
+
154
+ if (probeAgent && cfg.defaultAgent) {
155
+ const command = cfg.agents[cfg.defaultAgent]?.command ?? cfg.defaultAgent;
156
+ if (!onPath(command))
157
+ add({
158
+ level: "warn",
159
+ what: `default agent '${cfg.defaultAgent}' runs '${command}', which is not on PATH — khb go will fail at launch`,
160
+ fix: `khb agent ${cfg.defaultAgent} --command <exe> (or 'khb agent none' to just print the path)`,
161
+ });
162
+ }
163
+
164
+ // ---- hub entries ----------------------------------------------------------------------
165
+ const rawHubs = Array.isArray(raw.hubs) ? (raw.hubs as unknown[]) : [];
166
+ if (raw.hubs !== undefined && !Array.isArray(raw.hubs))
167
+ add({
168
+ level: "error",
169
+ what: `'hubs' is not a list — every shortcut is being ignored`,
170
+ fix: "khb config edit",
171
+ });
172
+
173
+ const dropped = rawHubs.length - cfg.hubs.length;
174
+ if (dropped > 0)
175
+ add({
176
+ level: "error",
177
+ what: `${dropped} hub entr${dropped === 1 ? "y has" : "ies have"} no usable 'path' and ${dropped === 1 ? "is" : "are"} ignored on load`,
178
+ fix: "khb config fix",
179
+ // Same as the unknown keys: they survive only in the file, and rewriting drops them.
180
+ repair: () => {},
181
+ });
182
+
183
+ for (const entry of cfg.hubs) {
184
+ const label = entry.name || entry.path;
185
+ const extra = Object.keys(entry).filter((key) => !KNOWN_HUB_KEYS.includes(key));
186
+ if (extra.length)
187
+ add({
188
+ level: "warn",
189
+ what: `hub '${label}' carries key(s) the schema does not define: ${extra.join(", ")}`,
190
+ fix: "khb config fix",
191
+ repair: (config) => {
192
+ const target = config.hubs.find((hub) => hub.path === entry.path);
193
+ for (const key of extra) delete (target as unknown as Record<string, unknown>)[key];
194
+ },
195
+ });
196
+
197
+ if (!isAbsolute(entry.path))
198
+ add({
199
+ level: "error",
200
+ what: `hub '${label}' has a relative path (${entry.path}) — it resolves against whatever directory khb happens to run in`,
201
+ fix: "khb config fix",
202
+ repair: (config) => {
203
+ const target = config.hubs.find((hub) => hub.path === entry.path);
204
+ if (target) target.path = canonical(target.path);
205
+ },
206
+ });
207
+ // Only for a hub that still exists. A dead entry's path string is evidence: `khb update
208
+ // --path` searches the hub's files for exactly that spelling, so rewriting it to a
209
+ // canonical form khb has never seen would destroy the one clue the repair needs.
210
+ else if (isAlive(entry) && canonical(entry.path) !== entry.path)
211
+ add({
212
+ level: "warn",
213
+ what: `hub '${label}' is listed as ${entry.path}, which is not the spelling khb compares against (${canonical(entry.path)})`,
214
+ fix: "khb config fix",
215
+ repair: (config) => {
216
+ const target = config.hubs.find((hub) => hub.path === entry.path);
217
+ if (target) target.path = canonical(target.path);
218
+ },
219
+ });
220
+
221
+ for (const field of ["added", "lastUsed", "created"] as const) {
222
+ const value = entry[field];
223
+ if (value !== undefined && !isTimestamp(value))
224
+ add({
225
+ level: "warn",
226
+ what: `hub '${label}' has an unreadable ${field} (${JSON.stringify(value)})${
227
+ field === "lastUsed" ? " — it sorts to the bottom of khb list" : ""
228
+ }`,
229
+ fix: "khb config fix",
230
+ repair: (config) => {
231
+ const target = config.hubs.find((hub) => hub.path === entry.path);
232
+ if (!target) return;
233
+ if (field === "added") target.added = target.lastUsed ?? new Date().toISOString();
234
+ else delete target[field];
235
+ },
236
+ });
237
+ }
238
+ if (entry.added === undefined)
239
+ add({
240
+ level: "warn",
241
+ what: `hub '${label}' has no 'added' timestamp`,
242
+ fix: "khb config fix",
243
+ repair: (config) => {
244
+ const target = config.hubs.find((hub) => hub.path === entry.path);
245
+ if (target) target.added = target.lastUsed ?? new Date().toISOString();
246
+ },
247
+ });
248
+
249
+ if (!isAlive(entry)) {
250
+ const gone = !existsSync(entry.path);
251
+ add({
252
+ level: "warn",
253
+ what: `hub '${label}' is registered at ${entry.path}, which ${gone ? "does not exist" : "is no longer a hub (no khb.json)"} — khb list shows it as MISSING`,
254
+ // Deliberately not auto-repaired: a path that is gone today can be an unplugged
255
+ // drive or an unmounted share tomorrow, and a moved hub wants repointing, not
256
+ // forgetting. Both need the person to say which it is.
257
+ fix: `khb forget ${entry.name} (or 'khb update --path' from its new location, if it moved)`,
258
+ });
259
+ continue; // the checks below all read the hub's own marker, and there is none
260
+ }
261
+
262
+ // Name and description are derived from the hub's own khb.json and refreshed whenever a
263
+ // command runs in that hub — so a hub renamed in its marker keeps its old listing here
264
+ // until something happens to run there. Nothing is broken; the list is just stale.
265
+ const own = markerFields(entry.path);
266
+ if (own.name && own.name !== entry.name)
267
+ add({
268
+ level: "warn",
269
+ what: `hub '${entry.name}' calls itself '${own.name}' in its own khb.json — the list is stale`,
270
+ fix: "khb config fix",
271
+ repair: (config) => {
272
+ const target = config.hubs.find((hub) => hub.path === entry.path);
273
+ if (target) target.name = own.name!;
274
+ },
275
+ });
276
+ const described = describeHub(entry.path);
277
+ if (described !== entry.description)
278
+ add({
279
+ level: "warn",
280
+ what: `hub '${label}' is described as "${entry.description}", but its hub now reads "${described}"`,
281
+ fix: "khb config fix",
282
+ repair: (config) => {
283
+ const target = config.hubs.find((hub) => hub.path === entry.path);
284
+ if (target) target.description = described;
285
+ },
286
+ });
287
+ if (own.created && entry.created !== own.created)
288
+ add({
289
+ level: "warn",
290
+ what: `hub '${label}' has a 'created' stamp that does not match its marker — khb update --path uses it to recognise a moved hub`,
291
+ fix: "khb config fix",
292
+ repair: (config) => {
293
+ const target = config.hubs.find((hub) => hub.path === entry.path);
294
+ if (target) target.created = own.created;
295
+ },
296
+ });
297
+ }
298
+
299
+ // ---- collisions between entries --------------------------------------------------------
300
+ for (let i = 0; i < cfg.hubs.length; i++) {
301
+ for (let j = i + 1; j < cfg.hubs.length; j++) {
302
+ const [first, second] = [cfg.hubs[i], cfg.hubs[j]];
303
+ if (samePath(resolve(first.path), resolve(second.path)))
304
+ add({
305
+ level: "error",
306
+ what: `${first.path} is listed twice (as '${first.name}' and '${second.name}') — khb list shows it twice and khb forget removes only one of them`,
307
+ fix: "khb config fix",
308
+ repair: (config) => mergeDuplicate(config, first, second),
309
+ });
310
+ else if (first.name === second.name)
311
+ add({
312
+ level: "error",
313
+ what: `two hubs are both named '${first.name}' — 'khb go ${first.name}' can only ever reach the first, and the second is unreachable by name`,
314
+ // Not auto-repairable, and the reason matters: a name is re-derived from each
315
+ // hub's own khb.json on every command run there, so renaming the entry here
316
+ // would be undone by the next command in that hub. The rename has to happen in
317
+ // the marker, which is the hub owner's call.
318
+ fix: `edit the 'name' in ${join(second.path, markerIn(second.path) ?? MARKER)} (a registry rename is overwritten on the next command run there)`,
319
+ });
320
+ }
321
+ }
322
+
323
+ // A hub whose name is a number shadows the list-position selector: findHubEntry matches
324
+ // names before positions, so `khb go 2` opens the hub *called* "2" and the hub at
325
+ // position 2 becomes unreachable by number.
326
+ for (const entry of cfg.hubs) {
327
+ const asNumber = Number(entry.name);
328
+ if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= cfg.hubs.length)
329
+ add({
330
+ level: "warn",
331
+ what: `hub named '${entry.name}' shadows the list position of the same number — 'khb go ${entry.name}' opens this hub, never the ${entry.name}${ordinal(asNumber)} in khb list`,
332
+ fix: `edit the 'name' in ${join(entry.path, markerIn(entry.path) ?? MARKER)}`,
333
+ });
334
+ }
335
+
336
+ const order = { error: 0, warn: 1 };
337
+ findings.sort((a, b) => order[a.level] - order[b.level]);
338
+ return { path: CONFIG, exists: true, readable: true, findings };
339
+ }
340
+
341
+ const ordinal = (n: number) => (n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th");
342
+
343
+ /**
344
+ * Fold one duplicate entry into the other, keeping the longer history: the earliest
345
+ * `added` and the most recent `lastUsed`, the same way `relocateHub` merges the pair a
346
+ * repaired move can leave behind.
347
+ */
348
+ function mergeDuplicate(config: Config, first: HubEntry, second: HubEntry): void {
349
+ const keep = config.hubs.find((hub) => hub.path === first.path);
350
+ const dropAt = config.hubs.findIndex((hub) => hub !== keep && hub.path === second.path);
351
+ if (!keep || dropAt < 0) return;
352
+ const drop = config.hubs[dropAt];
353
+ keep.added = [keep.added, drop.added].filter(Boolean).sort()[0] ?? keep.added;
354
+ keep.lastUsed = [keep.lastUsed, drop.lastUsed].filter(Boolean).sort().pop();
355
+ keep.created = keep.created ?? drop.created;
356
+ config.hubs.splice(dropAt, 1);
357
+ }
358
+
359
+ /** Drop every entry whose folder is no longer a hub. Only `khb config fix --prune` does this. */
360
+ export function pruneDead(config: Config): HubEntry[] {
361
+ const dead = config.hubs.filter((hub) => !existsSync(hub.path) || !markerIn(hub.path));
362
+ config.hubs = config.hubs.filter((hub) => !dead.includes(hub));
363
+ return dead;
364
+ }
@@ -10,6 +10,8 @@
10
10
  // trail for a pass that rewrites a bundle's raw/, and a run whose output you have to re-run
11
11
  // to reconstruct is worse than a noisy one. Nothing here changes what khb does.
12
12
 
13
+ import { paint } from "./color";
14
+
13
15
  const RUN_START = Date.now();
14
16
  let itemStart = RUN_START;
15
17
 
@@ -25,7 +27,7 @@ export function pos(i: number, n: number): string {
25
27
 
26
28
  /** A blank-line-separated heading: a source, a bundle, a phase. */
27
29
  export function section(title: string) {
28
- console.log(`\n${title}`);
30
+ console.log(`\n${paint.head(title)}`);
29
31
  }
30
32
 
31
33
  /** Indented context under a heading — settings, counts, where things are going. */
@@ -39,7 +41,7 @@ export function detail(msg: string) {
39
41
  */
40
42
  export function item(prefix: string, label: string) {
41
43
  itemStart = Date.now();
42
- console.log(` ${prefix} ${label}`);
44
+ console.log(` ${paint.dim(prefix)} ${label}`);
43
45
  }
44
46
 
45
47
  /** A step inside the current item: what khb is about to do, or what it just learned. */
@@ -7,6 +7,7 @@
7
7
  // it and nothing is lost but the shortcuts.
8
8
  //
9
9
  // Package-side, like paths.ts — importing this must never require a hub to exist.
10
+ import { spawnSync } from "node:child_process";
10
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, realpathSync } from "node:fs";
11
12
  import { homedir } from "node:os";
12
13
  import { join, resolve, basename } from "node:path";
@@ -109,7 +110,7 @@ export function canonical(p: string): string {
109
110
  }
110
111
 
111
112
  /** Case-insensitive on Windows, after canonicalizing — see `canonical`. */
112
- const samePath = (a: string, b: string) => {
113
+ export const samePath = (a: string, b: string) => {
113
114
  const [x, y] = [canonical(a), canonical(b)];
114
115
  return process.platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y;
115
116
  };
@@ -296,6 +297,24 @@ export function findHubEntry(what: string): HubEntry | undefined {
296
297
  return hubs.find((h) => samePath(resolve(h.path), resolve(what)));
297
298
  }
298
299
 
300
+ /**
301
+ * Is this command actually runnable? Probed with `--version`, which no agent acts on.
302
+ * Used by the first-run wizard to offer what is installed, and by the config check to say
303
+ * so before `khb go` fails at spawn.
304
+ */
305
+ export function onPath(command: string): boolean {
306
+ try {
307
+ const probe = spawnSync(command, ["--version"], {
308
+ stdio: "ignore",
309
+ shell: process.platform === "win32",
310
+ timeout: 5000,
311
+ });
312
+ return !probe.error && probe.status === 0;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
299
318
  export function agentFor(cfg: Config, name?: string): { name: string; spec: AgentSpec } | undefined {
300
319
  const key = name ?? cfg.defaultAgent;
301
320
  if (!key) return undefined;
package/scripts/lint.ts CHANGED
@@ -6,6 +6,7 @@ import { readdirSync, statSync } from "node:fs";
6
6
  import { dirname, relative } from "node:path";
7
7
  import { parse as parseYaml } from "yaml";
8
8
  import { rejectUnknownFlags } from "./lib/args";
9
+ import { paint, paintErr } from "./lib/color";
9
10
 
10
11
  rejectUnknownFlags(process.argv.slice(2), "khb lint");
11
12
 
@@ -20,8 +21,8 @@ const isTimestamp = (value: unknown) =>
20
21
  : typeof value === "string" && !isNaN(Date.parse(value));
21
22
 
22
23
  let errors = 0, warnings = 0;
23
- const err = (rule: string, msg: string) => { errors++; console.error(`ERROR ${rule}: ${msg}`); };
24
- const warn = (rule: string, msg: string) => { warnings++; console.warn(`warn ${rule}: ${msg}`); };
24
+ const err = (rule: string, msg: string) => { errors++; console.error(`${paintErr.bad("ERROR")} ${paintErr.name(rule)}: ${msg}`); };
25
+ const warn = (rule: string, msg: string) => { warnings++; console.warn(`${paintErr.warn("warn ")} ${paintErr.name(rule)}: ${msg}`); };
25
26
 
26
27
  /**
27
28
  * Drop everything that is markup *about* markdown rather than markdown: HTML comments, and
@@ -74,7 +75,7 @@ function resolveLink(bundleRoot: string, fromRelative: string, target: string):
74
75
  return resolved.replace(/\/$/, "");
75
76
  }
76
77
 
77
- console.log(`khb lint → ${HUB}`);
78
+ console.log(`${paint.head("khb lint")} → ${paint.path(HUB)}`);
78
79
  detail(`${bundles.length} bundle(s): ${bundles.join(", ") || "none"}`);
79
80
 
80
81
  for (const [bundleIndex, bundle] of bundles.entries()) {
@@ -268,4 +269,8 @@ for (const bundle of bundles)
268
269
  if (existsSync(join(BUNDLES, bundle, "index.md")))
269
270
  proseCheck(`${bundle}/index.md`, read(join(BUNDLES, bundle, "index.md")));
270
271
 
271
- console.log(`\nlint: ${errors} error(s), ${warnings} warning(s) across ${bundles.length} bundle(s) in ${totalElapsed()}`);
272
+ console.log(
273
+ `\n${paint.head("lint")}: ${errors ? paint.bad(`${errors} error(s)`) : paint.ok("0 errors")}, ` +
274
+ `${warnings ? paint.warn(`${warnings} warning(s)`) : paint.ok("0 warnings")} ` +
275
+ `across ${bundles.length} bundle(s) in ${totalElapsed()}`,
276
+ );
@@ -3,18 +3,23 @@
3
3
  import { existsSync } from "node:fs";
4
4
  import { BUNDLES, join } from "./lib/util";
5
5
  import { createBundle, VALID_NAME } from "./lib/scaffold";
6
+ import { paint, paintErr } from "./lib/color";
6
7
  import { rejectUnknownFlags } from "./lib/args";
7
8
 
8
9
  const argv = process.argv.slice(2);
9
10
  rejectUnknownFlags(argv, 'khb new-bundle <name> ["scope"]');
10
11
  const [name, scope = "TODO scope"] = argv;
11
12
  if (!name || !VALID_NAME.test(name)) {
12
- console.error("Usage: khb new-bundle <name> [scope] (lowercase, digits, hyphens)");
13
+ console.error(
14
+ `Usage: ${paintErr.cmd("khb new-bundle <name> [scope]")} ${paintErr.dim("(lowercase, digits, hyphens)")}`,
15
+ );
13
16
  process.exit(1);
14
17
  }
15
- if (existsSync(join(BUNDLES, name))) { console.error(`Bundle '${name}' already exists`); process.exit(1); }
18
+ if (existsSync(join(BUNDLES, name))) { console.error(`${paintErr.bad("Bundle already exists:")} ${name}`); process.exit(1); }
16
19
 
17
20
  createBundle(name, scope);
18
21
 
19
- console.log(`Created bundles/${name}/ and registered it in outer.index.md`);
20
- console.log("Next: set its scope line in outer.index.md, add sources to sources.yaml, run: khb lint");
22
+ console.log(`${paint.ok("Created")} ${paint.name(`bundles/${name}/`)} and registered it in outer.index.md`);
23
+ console.log(
24
+ `Next: set its scope line in outer.index.md, add sources to sources.yaml, run: ${paint.cmd("khb lint")}`,
25
+ );