@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/AGENTS.md CHANGED
@@ -137,14 +137,14 @@ From outside, pass `--hub <dir>` or set `$KHB_HUB`.
137
137
  | Command | Purpose |
138
138
  |---|---|
139
139
  | `khb lint` | validate structure against `skills/lint/SKILL.md` |
140
- | `khb doctor` | read-only report on the hub: version, location, per-bundle concept/raw/backlog counts, transcriber, and the command that fixes each finding. Writes nothing, and does not duplicate `lint` |
140
+ | `khb doctor` | read-only report on the hub: version, location, per-bundle concept/raw/backlog counts, transcriber, the machine config's schema findings, and the command that fixes each finding. Writes nothing, and does not duplicate `lint` |
141
141
  | `khb upgrade` | refresh this hub's package-owned contract docs — runs by itself whenever `khb.json`'s stamped version differs from the installed khb, so these docs always match the CLI |
142
142
  | `khb visualize [--port N] [--no-open]` | serve the live bundle graph in your browser; aliases `vis`, `viz` |
143
143
  | `khb new-bundle <name>` | scaffold + register a bundle |
144
144
  | `khb ingest <bundle>` | acquire + extract every source in `sources.yaml` → `raw/`; maintains `log.md`. Name the bundle — with none it lists the hub's bundles and stops, unless there is nothing to choose between (no bundles, or only `default`), where it uses `default` |
145
145
  | `khb export <bundle> [dest]` | standalone copy: bundle + common patterns, shareable alone |
146
146
 
147
- These five work **outside** any hub, against a per-machine shortcut list at
147
+ These six work **outside** any hub, against a per-machine shortcut list at
148
148
  `~/.khb/hubs-config.json` (`%USERPROFILE%\.khb` on Windows). They hold no knowledge — only
149
149
  paths — and fill themselves in as hubs are used, so they need no maintenance.
150
150
 
@@ -154,7 +154,8 @@ paths — and fill themselves in as hubs are used, so they need no maintenance.
154
154
  | `khb go [name\|N]` | open a hub — prints its path, then launches your agent there. Bare `khb` is this: one hub asks, several offer a list, none walks the user through creating the first. `--path` prints only the path |
155
155
  | `khb agent [name]` | which agent `khb go` launches — `claude`, `codex`, a custom `--command`, or `none` |
156
156
  | `khb update [new-path]` | repair the hub: `--path`/`-p` repoints the list and rewrites every old-path reference after a move (needs no arguments — the move is detected and announced by any khb command run there); `--schema`/`-s` backfills a bundle's `sources.yaml` to the current schema (e.g. a newly added `exclude:` field). No flag runs both. `--dry-run` first. Unrelated to `khb upgrade`, which only touches package-owned contract docs, never bundle content |
157
- | `khb forget <name>` | drop a shortcut; the hub folder is untouched |
157
+ | `khb forget <name> [more…]` | drop one or more shortcuts; the hub folders are untouched. Every target is resolved before any is removed, so list positions (`khb forget 1 2`) mean what they meant when you typed them. With no name it points at `khb list` |
158
+ | `khb config [view\|edit\|check\|fix\|path]` | the machine config itself: `view` prints it, `edit` opens it in the OS default editor, `check` validates it against the schema, `fix` repairs what can be repaired mechanically (`--dry-run`, `--prune` to drop dead shortcuts), `path` prints just the location. `khb doctor` reports the same findings but never writes |
158
159
 
159
160
  A hub's `name` and `description` in that list come from its own `khb.json`, so they travel
160
161
  with it. Set them at creation with `khb init --name --description`, or edit the marker's
package/README.md CHANGED
@@ -257,6 +257,11 @@ Commands can be run directly or requested through the matching agent skill.
257
257
 
258
258
  Run against a hub outside the current directory with `--hub <dir>` or `$KHB_HUB`.
259
259
 
260
+ Output is colour-coded when the stream is a terminal — headings, the commands you can
261
+ type next, paths, and the difference between an error, a warning and a clean result.
262
+ `NO_COLOR=1` turns it off, `FORCE_COLOR=1` keeps it through a pipe, and a redirected
263
+ stdout is plain text either way.
264
+
260
265
  ### `khb doctor` — what state is this hub in?
261
266
 
262
267
  A single read-only report. It writes nothing and repairs nothing; each finding names the
@@ -264,6 +269,7 @@ command that does.
264
269
 
265
270
  ```text
266
271
  Hub name, description, stamped version vs installed, location, registered
272
+ Machine config where it is, the agent it names, how many hubs, schema findings
267
273
  Bundles per bundle: concepts, raw/ files, log.md rows, catalog backlog, pending
268
274
  Extraction which formats are bundled, and whether a transcriber is ready
269
275
  Findings what needs attention, each with its fix
@@ -289,7 +295,8 @@ These run **outside** any hub, from any terminal:
289
295
  | `khb go [name\|N] [--path] [--no-agent] [--agent X]` | Open one by name or list position. `--path` prints just the path, for `cd "$(khb go --path work)"`; `--no-agent` prints the path and the `cd` line without launching anything; `--agent X` launches `X` for this run only, leaving the configured default alone |
290
296
  | `khb agent [name\|none] [--command X] [--args "…"]` | Which agent `khb go` launches — `claude`, `codex`, anything on your PATH, or `none` to just print the path |
291
297
  | `khb update [new-path] [--path\|-p] [--schema\|-s] [--from <old>] [--dry-run]` | Repair the hub: path references after a move, and/or backfill `sources.yaml` to the current schema. No flag runs both |
292
- | `khb forget <name>` | Drop a hub from the list. The folder is untouched |
298
+ | `khb forget <name> [more…]` | Drop one or more hubs from the list. The folders are untouched. With no name it points you at `khb list` for the names |
299
+ | `khb config [view\|edit\|check\|fix\|path]` | The config file itself — see below |
293
300
 
294
301
  `khb go` prints the hub's path and then starts your agent there, so a bare `khb` from a
295
302
  cold terminal ends with an agent open on the right folder. No program can change its
@@ -305,6 +312,39 @@ A hub's name and description come from its own `khb.json`, so they travel with t
305
312
  rather than living in one machine's list. Set them with `khb init --name --description`,
306
313
  or edit those two keys in the marker later.
307
314
 
315
+ ### `khb config` — the file itself
316
+
317
+ The list is plain JSON and always has been editable by hand. These five reach it without
318
+ hunting for the path, and say something when a hand edit has gone wrong:
319
+
320
+ ```bash
321
+ khb config # or 'view' — the path, the file, and a findings count
322
+ khb config edit # open it in whatever this machine opens .json with
323
+ khb config check # validate it against the schema
324
+ khb config fix # repair what can be repaired mechanically
325
+ khb config fix --dry-run --prune # preview; --prune also drops dead shortcuts
326
+ khb config path # just the path, for cat "$(khb config path)"
327
+ ```
328
+
329
+ The reason `check` exists: `loadConfig` is forgiving on purpose — it ignores keys it does
330
+ not know and treats an unparseable file as an empty one, so a damaged registry never
331
+ blocks `khb lint` in a hub that is fine. The cost is silence. A `defaultagent` typo does
332
+ nothing at all, a pasted duplicate makes `khb forget` look broken, and a stray comma
333
+ loses you every shortcut with no message anywhere. `check` names each of those, `fix`
334
+ repairs the mechanical ones — canonicalizing paths, merging duplicate entries, dropping
335
+ keys the schema does not define, re-deriving a name or description that has drifted from
336
+ the hub's own `khb.json` — and leaves anything needing a decision to you, with the
337
+ command that settles it.
338
+
339
+ `khb doctor` runs the same checks and prints the same fixes, so a hub health check covers
340
+ the machine config too. Doctor never writes; `khb config fix` is the half that does.
341
+
342
+ Two things `fix` will not do on its own: drop a shortcut whose folder has gone (that is
343
+ `--prune`, or `khb forget` — a missing path can be an unplugged drive, and a moved hub
344
+ wants `khb update --path`, not forgetting), and rename a hub. A name is re-derived from
345
+ each hub's own `khb.json` every time a command runs there, so renaming the entry would
346
+ be undone on the next command — the rename belongs in the marker.
347
+
308
348
  ### `khb update` — repairing a hub
309
349
 
310
350
  Two independent repairs, run together or apart:
package/SPEC.md CHANGED
@@ -123,9 +123,12 @@ acting on them** — an agent can never read a protocol the CLI no longer implem
123
123
  │ ├── lint.ts # enforce skills/lint/SKILL.md across the hub
124
124
  │ ├── doctor.ts # read-only state report; writes nothing, repairs nothing
125
125
  │ ├── visualize.ts # serve the live bundle graph from indexes + refs
126
+ │ ├── config.ts # khb config: view / edit / check / fix the machine config
126
127
  │ ├── ingest/ # folder.ts / files.ts / web.ts → acquire.ts → bundle/raw
127
128
  │ └── lib/
128
129
  │ ├── args.ts # argv helpers; what they consume they remove, leaving positionals
130
+ │ ├── color.ts # semantic terminal colour; one palette per stream
131
+ │ ├── config-check.ts # the machine config's schema: findings + the repairs for them
129
132
  │ ├── create.ts # making a hub, shared by `khb init` and the first-run wizard
130
133
  │ ├── extract.ts # every local extractor + the content-hash cache
131
134
  │ ├── graph.ts # graph data for the visualizer — read-only, never writes
@@ -187,8 +190,9 @@ Three properties define it:
187
190
  Delete it and nothing is lost — the next command run inside each hub puts it back.
188
191
  - **It fills itself in.** Every khb command that resolves a hub registers it, so hubs made
189
192
  before the registry existed appear the first time anything is run in them. There is no
190
- migration and no `register` command to remember. `khb forget <name>` drops a shortcut and
191
- never touches the folder.
193
+ migration and no `register` command to remember. `khb forget <name> [more…]` drops one or more
194
+ shortcuts and never touches the folders. Every target is resolved before any is removed,
195
+ so list positions still mean what they meant when the command was typed.
192
196
  - **The hub is the authority on its own identity.** `name` and `description` are read out
193
197
  of the hub's `khb.json` (`khb init --name --description`, or edit the file), so a hub
194
198
  moved to another machine or cloned by a colleague describes itself the same way there.
@@ -196,9 +200,9 @@ Three properties define it:
196
200
  the bundles inside. `khb upgrade` merges rather than replaces the marker, so keys khb
197
201
  does not own survive an upgrade.
198
202
 
199
- The commands over it are `khb list`, `khb go`, `khb agent`, `khb update` and `khb forget`
200
- the only ones that run **outside** a hub, and therefore the only ones that skip hub
201
- resolution and the version drift check. A bare `khb` is `khb go`: one hub asks to open it,
203
+ The commands over it are `khb list`, `khb go`, `khb agent`, `khb update`, `khb forget` and
204
+ `khb config` — the only ones that run **outside** a hub, and therefore the only ones that
205
+ skip hub resolution and the version drift check. A bare `khb` is `khb go`: one hub asks to open it,
202
206
  several show the list and take a pick, none prints the help.
203
207
 
204
208
  `khb go` ends by launching your configured agent with the hub as its working directory.
@@ -206,6 +210,18 @@ No process can change its parent shell's directory, so `khb go` prints the `cd`
206
210
  the human and passes the path to the agent as cwd — `khb go --path <name>` prints only the
207
211
  path, for `cd "$(khb go --path work)"`. `khb agent none` turns the launch off entirely.
208
212
 
213
+ **The file is hand-editable, so it is also checked.** `loadConfig` is deliberately
214
+ forgiving — unknown keys ignored, a file that will not parse treated as empty — which is
215
+ right at load time and wrong as the only feedback anyone gets: a typo'd `defaultagent`
216
+ silently does nothing, and broken JSON silently costs you every shortcut. So the schema
217
+ is stated once, in `lib/config-check.ts`, as findings with repairs attached. `khb config
218
+ check` lists them, `khb config fix` applies the mechanical ones, and `khb doctor` reports
219
+ them without writing. A finding carries a repair only when the fix loses nothing:
220
+ canonicalizing a path, merging a duplicate entry, re-deriving a stale name from the hub's
221
+ own marker. Anything needing a human decision — which of two same-named hubs to rename,
222
+ whether a missing folder was deleted or is on an unplugged drive — is reported with the
223
+ command to run and left alone.
224
+
209
225
  Every path stored in or compared against the registry is canonicalized first (`realpath`,
210
226
  case-folded on Windows). One directory has several true names — `C:\Users\MANASV~1\…` and
211
227
  `C:\Users\Manasvi Sareen\…` are the same folder, as is anything reached through a symlink —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msareen/knowledge-hub-builder",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Knowledge Hub Builder — a bundle-of-bundles knowledge base you build with an agent",
5
5
  "keywords": ["knowledge-base", "okf", "agent", "wiki", "bundles", "claude", "codex", "rag"],
6
6
  "license": "MIT",
package/scripts/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // khb — the CLI. Subcommands are loaded lazily: `init` must run before a hub exists,
3
3
  // so nothing that resolves a hub may be imported at module scope.
4
4
  import { version, findHub, markerIn, MARKER } from "./lib/paths";
5
+ import { paint, paintErr } from "./lib/color";
5
6
 
6
7
  const COMMANDS: Record<string, { load: () => Promise<unknown>; usage: string; desc: string }> = {
7
8
  init: { load: () => import("./init"), usage: 'khb init [dir] [--name N] [--description "…"]', desc: "create a hub here (or in dir)" },
@@ -36,14 +37,23 @@ const COMMANDS: Record<string, { load: () => Promise<unknown>; usage: string; de
36
37
  usage: "khb update [new-path] [--path|-p] [--schema|-s] [--from <old>] [--dry-run]",
37
38
  desc: "repair a moved hub's paths, and/or backfill sources.yaml",
38
39
  },
39
- forget: { load: () => import("./hubs"), usage: "khb forget <name|path>", desc: "drop a hub from the list (folder untouched)" },
40
+ forget: {
41
+ load: () => import("./hubs"),
42
+ usage: "khb forget <name|path> [more…]",
43
+ desc: "drop one or more hubs from the list (folders untouched)",
44
+ },
45
+ config: {
46
+ load: () => import("./config"),
47
+ usage: "khb config [view|edit|check|fix|path]",
48
+ desc: "the machine config: show it, open it, validate it, repair it",
49
+ },
40
50
  };
41
51
 
42
52
  /**
43
53
  * Commands that work *outside* a hub, against ~/.khb/hubs-config.json. They must not
44
54
  * resolve or upgrade a hub — their whole job is running before you are in one.
45
55
  */
46
- const REGISTRY_COMMANDS = new Set(["list", "go", "agent", "forget", "update"]);
56
+ const REGISTRY_COMMANDS = new Set(["list", "go", "agent", "forget", "update", "config"]);
47
57
 
48
58
  // Short forms that just resolve to a canonical command above — kept out of COMMANDS
49
59
  // itself so help text lists each command once. `-v` is taken by --version, so
@@ -79,19 +89,20 @@ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
79
89
  const usages = Object.values(COMMANDS).map((c) => c.usage.length);
80
90
  const width = Math.max(...usages.filter((n) => n <= CAP));
81
91
  const printSection = (title: string, names: string[]) => {
82
- console.log(title);
92
+ console.log(paint.head(title));
83
93
  for (const name of names) {
84
- const c = COMMANDS[name];
85
- if (c.usage.length > width) {
86
- console.log(` ${c.usage}`);
87
- console.log(` ${" ".repeat(width)} ${c.desc}`);
94
+ const entry = COMMANDS[name];
95
+ // Pad before painting: escape sequences have width in the string and none on screen.
96
+ if (entry.usage.length > width) {
97
+ console.log(` ${paint.cmd(entry.usage)}`);
98
+ console.log(` ${" ".repeat(width)} ${entry.desc}`);
88
99
  } else {
89
- console.log(` ${c.usage.padEnd(width)} ${c.desc}`);
100
+ console.log(` ${paint.cmd(entry.usage.padEnd(width))} ${entry.desc}`);
90
101
  }
91
102
  }
92
103
  };
93
104
 
94
- console.log(`khb ${version()} — Knowledge Hub Builder`);
105
+ console.log(`${paint.head(`khb ${version()}`)} — Knowledge Hub Builder`);
95
106
  console.log();
96
107
  console.log(`khb is the supporting tool: it handles deterministic extraction, file plumbing,`);
97
108
  console.log(`validation, and export. Your AI agent — Claude, Codex, Gemini, or another`);
@@ -109,14 +120,16 @@ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
109
120
  );
110
121
  console.log();
111
122
 
112
- console.log(`Global: --hub <dir> operate on that hub instead of searching upward from cwd`);
113
- console.log(` help | --help | -h this help --version | -v version`);
114
- console.log(`Env: KHB_HUB same as --hub`);
115
- console.log(` KHB_HOME where the hub list lives (default ~/.khb)`);
116
- console.log(` KHB_NO_AUTO_UPGRADE don't refresh a hub's contract docs on version drift`);
117
- console.log(`Exit: 0 on success, 1 on a usage error or a failure. An unknown option is an error;`);
118
- console.log(` a source khb cannot extract is not — it becomes a pending row in log.md.`);
119
- console.log(`Docs: https://github.com/msareen/knowledge-hub-builder`);
123
+ const label = (text: string) => paint.head(text.padEnd(9));
124
+ console.log(`${label("Global:")}${paint.cmd("--hub <dir>")} operate on that hub instead of searching upward from cwd`);
125
+ console.log(`${" ".repeat(9)}${paint.cmd("help | --help | -h")} this help ${paint.cmd("--version | -v")} version`);
126
+ console.log(`${label("Env:")}${paint.cmd("KHB_HUB")} same as --hub`);
127
+ console.log(`${" ".repeat(9)}${paint.cmd("KHB_HOME")} where the hub list lives (default ~/.khb)`);
128
+ console.log(`${" ".repeat(9)}${paint.cmd("KHB_NO_AUTO_UPGRADE")} don't refresh a hub's contract docs on version drift`);
129
+ console.log(`${label("Colour:")}on when stdout is a terminal${paint.cmd("NO_COLOR")} turns it off, ${paint.cmd("FORCE_COLOR")} forces it on`);
130
+ console.log(`${label("Exit:")}0 on success, 1 on a usage error or a failure. An unknown option is an error;`);
131
+ console.log(`${" ".repeat(9)}a source khb cannot extract is not — it becomes a pending row in log.md.`);
132
+ console.log(`${label("Docs:")}${paint.path("https://github.com/msareen/knowledge-hub-builder")}`);
120
133
  process.exit(0);
121
134
  }
122
135
 
@@ -127,8 +140,8 @@ if (cmd === "--version" || cmd === "-v") {
127
140
 
128
141
  const entry = COMMANDS[cmd];
129
142
  if (!entry) {
130
- console.error(`Unknown command: ${cmd}`);
131
- console.error(`Try: khb help`);
143
+ console.error(`${paintErr.bad("Unknown command:")} ${cmd}`);
144
+ console.error(`Try: ${paintErr.cmd("khb help")}`);
132
145
  process.exit(1);
133
146
  }
134
147
 
@@ -154,9 +167,13 @@ if (cmd !== "init" && cmd !== "upgrade" && !REGISTRY_COMMANDS.has(cmd)) {
154
167
  const { recordLocation } = await import("./lib/upgrade");
155
168
  const { moved } = recordLocation(hub);
156
169
  if (moved) {
157
- console.error(`khb: this hub was at ${moved} and is now at ${hub}.`);
158
- console.error(`khb: absolute paths recorded inside it still name the old location.`);
159
- console.error(`khb: repair them: khb update --path (--dry-run to preview)`);
170
+ console.error(
171
+ `${paintErr.warn("khb:")} this hub was at ${paintErr.path(moved)} and is now at ${paintErr.path(hub)}.`,
172
+ );
173
+ console.error(`${paintErr.warn("khb:")} absolute paths recorded inside it still name the old location.`);
174
+ console.error(
175
+ `${paintErr.warn("khb:")} repair them: ${paintErr.cmd("khb update --path")} (--dry-run to preview)`,
176
+ );
160
177
  }
161
178
  }
162
179
  if (hub && !process.env.KHB_NO_AUTO_UPGRADE) {
@@ -166,10 +183,13 @@ if (cmd !== "init" && cmd !== "upgrade" && !REGISTRY_COMMANDS.has(cmd)) {
166
183
  const { from, to, pruned, renamed } = upgradeHub(hub);
167
184
  // stderr, so a command's own output stays pipeable.
168
185
  console.error(
169
- `khb: hub was built by ${from ?? "an unknown version"}, khb is ${to} — refreshed its contract docs.`,
186
+ `${paintErr.warn("khb:")} hub was built by ${from ?? "an unknown version"}, khb is ${to} — refreshed its contract docs.`,
170
187
  );
171
- if (renamed) console.error(`khb: renamed ${renamed} -> khb.json`);
172
- if (pruned.length) console.error(`khb: removed (no longer part of the contract): ${pruned.join(", ")}`);
188
+ if (renamed) console.error(`${paintErr.warn("khb:")} renamed ${renamed} -> khb.json`);
189
+ if (pruned.length)
190
+ console.error(
191
+ `${paintErr.warn("khb:")} removed (no longer part of the contract): ${pruned.join(", ")}`,
192
+ );
173
193
  const hint = updateHint(hub);
174
194
  if (hint) console.error(hint);
175
195
  }
@@ -0,0 +1,229 @@
1
+ // khb config — view, edit, check and repair the machine-level config.
2
+ //
3
+ // ~/.khb/hubs-config.json is the only khb file that is neither knowledge nor package-owned:
4
+ // it is this machine's shortcut list plus the agent `khb go` launches. It has always been
5
+ // hand-editable — the README says where it lives and what is in it — but nothing pointed at
6
+ // it from the CLI, and nothing ever told you when a hand edit had gone wrong. `loadConfig`
7
+ // is forgiving by design (unknown keys ignored, unparseable file treated as empty), so a
8
+ // typo costs you your hub list with no message at all.
9
+ //
10
+ // So: `view` and `edit` to reach it without hunting for the path, `check` to say what is
11
+ // wrong, `fix` to repair what can be repaired mechanically. The rules live once, in
12
+ // lib/config-check.ts, and `khb doctor` reads the same checker — doctor reports and never
13
+ // writes, this is where the writing happens.
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { checkConfig, pruneDead, type Finding } from "./lib/config-check";
17
+ import { CONFIG, KHB_HOME, loadConfig, saveConfig } from "./lib/registry";
18
+ import { takeFlag, rejectUnknownFlags } from "./lib/args";
19
+ import { detail, section, totalElapsed } from "./lib/log";
20
+ import { paint, paintErr } from "./lib/color";
21
+
22
+ const USAGE = "khb config [view|edit|check|fix|path] [--json] [--dry-run] [--prune]";
23
+ const argv = process.argv.slice(2);
24
+
25
+ const asJson = takeFlag(argv, "--json");
26
+ const dryRun = takeFlag(argv, "--dry-run");
27
+ const prune = takeFlag(argv, "--prune");
28
+ rejectUnknownFlags(argv, USAGE);
29
+
30
+ // Bare `khb config` shows the file. A command whose default action is read-only is the
31
+ // right default for the one file whose corruption costs you every shortcut you have.
32
+ const action = argv[0] ?? "view";
33
+ if (argv.length > 1) {
34
+ console.error(`${paintErr.bad("Too many arguments:")} ${argv.slice(1).join(" ")}`);
35
+ console.error(`Usage: ${paintErr.cmd(USAGE)}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ /** Ensure the file exists before anyone is told to look at it — first run has none. */
40
+ function ensureExists(): void {
41
+ if (!existsSync(CONFIG)) saveConfig(loadConfig());
42
+ }
43
+
44
+ function printFinding(finding: Finding): void {
45
+ const tag = finding.level === "error" ? paint.bad("ERROR") : paint.warn("warn ");
46
+ console.log(` ${tag} ${finding.what}`);
47
+ console.log(` ${paint.dim(finding.repair ? "fix (automatic):" : "fix:")} ${paint.cmd(finding.fix)}`);
48
+ }
49
+
50
+ /** The closing line every action shares: how many findings stand, and what clears them. */
51
+ function summarize(findings: Finding[]): void {
52
+ const errors = findings.filter((finding) => finding.level === "error").length;
53
+ const repairable = findings.filter((finding) => finding.repair).length;
54
+ if (!findings.length) {
55
+ console.log(`\n${paint.ok("no problems found")} in ${totalElapsed()}`);
56
+ return;
57
+ }
58
+ console.log(
59
+ `\n${paint.head("config")}: ${errors ? paint.bad(`${errors} error(s)`) : paint.ok("0 errors")}, ` +
60
+ `${paint.warn(`${findings.length - errors} warning(s)`)} in ${totalElapsed()}`,
61
+ );
62
+ if (repairable)
63
+ console.log(`${repairable} of them can be repaired for you: ${paint.cmd("khb config fix")}`);
64
+ }
65
+
66
+ // ------------------------------------------------------------------------------- path
67
+
68
+ if (action === "path") {
69
+ // Deliberately bare, so `cat "$(khb config path)"` works. Nothing else on stdout.
70
+ console.log(CONFIG);
71
+ process.exit(0);
72
+ }
73
+
74
+ // ------------------------------------------------------------------------------- view
75
+
76
+ if (action === "view") {
77
+ ensureExists();
78
+ const text = readFileSync(CONFIG, "utf8");
79
+ if (asJson) {
80
+ // The file itself, verbatim — pipeable into jq. No header, no findings.
81
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
82
+ process.exit(0);
83
+ }
84
+ console.log(`${paint.head("khb config")} → ${paint.path(CONFIG)}\n`);
85
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
86
+
87
+ const { findings } = checkConfig({ probeAgent: false });
88
+ if (findings.length) {
89
+ console.log(
90
+ `\n${paint.warn(`${findings.length} finding(s)`)} — see them in full: ${paint.cmd("khb config check")}`,
91
+ );
92
+ }
93
+ console.log(paint.dim(`Edit it: khb config edit Repair it: khb config fix`));
94
+ process.exit(0);
95
+ }
96
+
97
+ // ------------------------------------------------------------------------------- edit
98
+
99
+ if (action === "edit") {
100
+ ensureExists();
101
+ console.log(`${paint.head("khb config")} → ${paint.path(CONFIG)}`);
102
+
103
+ // The OS association, the same way `khb visualize` opens a browser: whatever this machine
104
+ // already opens a .json with is the editor the person has actually chosen. $EDITOR is not
105
+ // consulted — a terminal editor launched detached would draw over this session.
106
+ const command =
107
+ process.platform === "win32"
108
+ ? ["cmd", "/c", "start", "", CONFIG] // the empty string is start's window-title argument
109
+ : process.platform === "darwin"
110
+ ? ["open", CONFIG]
111
+ : ["xdg-open", CONFIG];
112
+
113
+ const launched = spawnSync(command[0], command.slice(1), { stdio: "ignore" });
114
+ if (launched.error || (launched.status ?? 0) !== 0) {
115
+ console.error(`\n${paintErr.warn("Could not open an editor")} — open the path above yourself.`);
116
+ process.exit(1);
117
+ }
118
+
119
+ console.log(` ${paint.dim("opened in your default editor for .json")}`);
120
+ // The editor is detached, so khb cannot wait for the save and validate it. Say what to run
121
+ // instead — a config that stops parsing is silently treated as empty, which is exactly the
122
+ // failure someone editing by hand is most likely to cause and least likely to notice.
123
+ console.log(`\nWhen you have saved it: ${paint.cmd("khb config check")}`);
124
+ console.log(paint.dim(`An unparseable file is ignored in full — khb would act as if you had no hubs.`));
125
+ process.exit(0);
126
+ }
127
+
128
+ // ------------------------------------------------------------------------------ check
129
+
130
+ if (action === "check") {
131
+ console.log(`${paint.head("khb config check")} → ${paint.path(CONFIG)}`);
132
+ const report = checkConfig();
133
+ if (!report.exists) {
134
+ detail(`no config yet — it is written the first time khb registers a hub`);
135
+ process.exit(0);
136
+ }
137
+ section(report.findings.length ? `Findings (${report.findings.length})` : "Findings");
138
+ if (!report.findings.length) detail(paint.ok("none — nothing here needs attention."));
139
+ else for (const finding of report.findings) printFinding(finding);
140
+ summarize(report.findings);
141
+ // Exit 0 with findings, like `khb lint` and `khb doctor`: the exit code says the command
142
+ // ran, not that the thing it inspected is perfect.
143
+ process.exit(0);
144
+ }
145
+
146
+ // -------------------------------------------------------------------------------- fix
147
+
148
+ if (action === "fix") {
149
+ console.log(`${paint.head("khb config fix")}${dryRun ? paint.dim(" (dry run)") : ""} → ${paint.path(CONFIG)}`);
150
+ const first = checkConfig();
151
+ if (!first.exists) {
152
+ detail(`no config yet — nothing to repair`);
153
+ process.exit(0);
154
+ }
155
+ if (!first.readable) {
156
+ // Every repair works on the parsed config, and there is none. Rewriting from the
157
+ // normalized (empty) view would silently delete every shortcut in the file.
158
+ section("Findings");
159
+ for (const finding of first.findings) printFinding(finding);
160
+ console.log(`\n${paintErr.bad("Nothing can be repaired while the file does not parse.")}`);
161
+ console.log(`Fix the JSON by hand: ${paint.cmd("khb config edit")}`);
162
+ process.exit(1);
163
+ }
164
+
165
+ section("Repairs");
166
+ const applied: string[] = [];
167
+ // One repair per pass, re-checking in between. Repairs interact — canonicalizing a path
168
+ // changes the key a later repair looks its entry up by, and merging a duplicate shifts
169
+ // every index after it — so the honest way to apply a set of them is to apply one and
170
+ // ask again. It converges: each pass either clears a finding or stops.
171
+ let previous = "";
172
+ for (let pass = 0; pass < 100; pass++) {
173
+ const { findings } = checkConfig({ probeAgent: false });
174
+ const next = findings.find((finding) => finding.repair);
175
+ if (!next) break;
176
+ if (next.what === previous) {
177
+ // The repair ran and the finding came back: report it rather than loop.
178
+ console.log(` ${paint.warn("could not repair:")} ${next.what}`);
179
+ break;
180
+ }
181
+ previous = next.what;
182
+ if (dryRun) {
183
+ // Nothing is written, so re-checking would return the same finding forever. Show the
184
+ // whole repairable set in one go instead of walking it.
185
+ for (const finding of findings.filter((each) => each.repair))
186
+ console.log(` ${paint.dim("would fix:")} ${finding.what}`);
187
+ break;
188
+ }
189
+ const cfg = loadConfig();
190
+ next.repair!(cfg);
191
+ saveConfig(cfg);
192
+ applied.push(next.what);
193
+ console.log(` ${paint.ok("fixed:")} ${next.what}`);
194
+ }
195
+
196
+ if (prune) {
197
+ const cfg = loadConfig();
198
+ const dead = pruneDead(cfg);
199
+ if (!dead.length) detail(`--prune: no dead shortcuts to drop`);
200
+ else if (dryRun)
201
+ for (const entry of dead)
202
+ console.log(` ${paint.dim("would drop:")} ${entry.name} (${entry.path})`);
203
+ else {
204
+ saveConfig(cfg);
205
+ for (const entry of dead)
206
+ console.log(` ${paint.ok("dropped:")} ${entry.name} ${paint.path(`(${entry.path})`)}`);
207
+ console.log(paint.dim(` the folders themselves are untouched`));
208
+ }
209
+ }
210
+
211
+ if (!applied.length && !dryRun) detail(`nothing to repair automatically`);
212
+
213
+ // What is left is what needed a person. Say so explicitly rather than reporting success
214
+ // on a config that still has problems in it.
215
+ const after = checkConfig();
216
+ const manual = after.findings.filter((finding) => !finding.repair);
217
+ if (manual.length) {
218
+ section(`Left for you (${manual.length})`);
219
+ for (const finding of manual) printFinding(finding);
220
+ }
221
+ summarize(after.findings);
222
+ if (dryRun) console.log(`Re-run without ${paint.cmd("--dry-run")} to apply.`);
223
+ process.exit(0);
224
+ }
225
+
226
+ console.error(`${paintErr.bad("Unknown action:")} ${action}`);
227
+ console.error(`Usage: ${paintErr.cmd(USAGE)}`);
228
+ console.error(`The config lives at ${paintErr.path(CONFIG)} (${paintErr.dim(`$KHB_HOME is ${KHB_HOME}`)})`);
229
+ process.exit(1);
package/scripts/doctor.ts CHANGED
@@ -16,9 +16,11 @@ import { staleLocations, hubVersion } from "./lib/upgrade";
16
16
  import { diffSourcesYamlAll } from "./lib/schema";
17
17
  import { transcriberStatus } from "./lib/extract";
18
18
  import { version, MARKER, markerIn } from "./lib/paths";
19
- import { listHubs, canonical } from "./lib/registry";
19
+ import { listHubs, canonical, loadConfig, agentFor, isAlive } from "./lib/registry";
20
+ import { checkConfig } from "./lib/config-check";
20
21
  import { section, detail, totalElapsed } from "./lib/log";
21
22
  import { rejectUnknownFlags } from "./lib/args";
23
+ import { paint } from "./lib/color";
22
24
  import { readdirSync, statSync } from "node:fs";
23
25
  import { relative } from "node:path";
24
26
 
@@ -26,9 +28,10 @@ rejectUnknownFlags(process.argv.slice(2), "khb doctor");
26
28
 
27
29
  /** Findings are advisory: doctor's exit code reports whether it ran, not what it found. */
28
30
  const findings: string[] = [];
29
- const flag = (msg: string, fix?: string) => findings.push(fix ? `${msg}\n fix: ${fix}` : msg);
31
+ const flag = (msg: string, fix?: string) =>
32
+ findings.push(fix ? `${msg}\n ${paint.dim("fix:")} ${paint.cmd(fix)}` : msg);
30
33
 
31
- console.log(`khb doctor → ${HUB}`);
34
+ console.log(`${paint.head("khb doctor")} → ${paint.path(HUB)}`);
32
35
 
33
36
  // ---- Hub identity -----------------------------------------------------------------------
34
37
  const marker = (() => {
@@ -78,6 +81,29 @@ if (stale.length)
78
81
  const registered = listHubs().some((entry) => canonical(entry.path) === canonical(HUB));
79
82
  detail(`registered ${registered ? "yes" : "no — 'khb list' and 'khb go' will not offer it"}`);
80
83
 
84
+ // ---- Machine config ---------------------------------------------------------------------
85
+ // Not about this hub — about the file that lists every hub on the machine and names the
86
+ // agent `khb go` launches. It belongs in doctor because it fails the same way a hub does:
87
+ // quietly. `loadConfig` ignores what it cannot read, so a hand edit that broke the JSON
88
+ // costs you every shortcut with no message anywhere. The rules live in lib/config-check.ts;
89
+ // doctor reports them and `khb config fix` is the half that writes.
90
+ section("Machine config");
91
+ const configReport = checkConfig();
92
+ detail(`file ${configReport.path}`);
93
+ if (!configReport.exists) detail(`state not written yet — created the first time khb registers a hub`);
94
+ else {
95
+ const machineConfig = loadConfig();
96
+ const agent = agentFor(machineConfig);
97
+ detail(`agent ${agent ? `${agent.name} (${agent.spec.command})` : "none — khb go prints the path"}`);
98
+ const hubs = listHubs();
99
+ const missing = hubs.filter((entry) => !isAlive(entry)).length;
100
+ detail(`hubs ${hubs.length} registered${missing ? `, ${missing} missing` : ""}`);
101
+ detail(
102
+ `schema ${configReport.findings.length ? `${configReport.findings.length} finding(s)` : "clean"}`,
103
+ );
104
+ }
105
+ for (const finding of configReport.findings) flag(`config: ${finding.what}`, finding.fix);
106
+
81
107
  // ---- sources.yaml schema ----------------------------------------------------------------
82
108
  const schemaDiffs = diffSourcesYamlAll(HUB);
83
109
  if (schemaDiffs.length) {
@@ -108,7 +134,7 @@ const bundles = listBundles();
108
134
  section(`Bundles (${bundles.length})`);
109
135
 
110
136
  if (!bundles.length) {
111
- detail("none yet — khb new-bundle <name> \"scope\"");
137
+ detail(`none yet — ${paint.cmd('khb new-bundle <name> "scope"')}`);
112
138
  } else {
113
139
  const summaries = bundles.map((bundle) => {
114
140
  const dir = join(BUNDLES, bundle);
@@ -169,9 +195,12 @@ if (!transcriber.ready)
169
195
 
170
196
  // ---- Findings ---------------------------------------------------------------------------
171
197
  section(findings.length ? `Findings (${findings.length})` : "Findings");
172
- if (!findings.length) detail("none — nothing here needs attention.");
173
- else for (const finding of findings) detail(`- ${finding}`);
198
+ if (!findings.length) detail(paint.ok("none — nothing here needs attention."));
199
+ else for (const finding of findings) detail(`${paint.warn("-")} ${finding}`);
174
200
 
175
201
  section("Next");
176
- detail("khb lint structural and OKF validation (doctor does not duplicate it)");
177
- console.log(`\ndoctor: ${findings.length} finding(s) across ${bundles.length} bundle(s) in ${totalElapsed()}`);
202
+ detail(`${paint.cmd("khb lint")} structural and OKF validation (doctor does not duplicate it)`);
203
+ console.log(
204
+ `\n${paint.head("doctor")}: ${findings.length ? paint.warn(`${findings.length} finding(s)`) : paint.ok("no findings")} ` +
205
+ `across ${bundles.length} bundle(s) in ${totalElapsed()}`,
206
+ );
package/scripts/export.ts CHANGED
@@ -5,19 +5,20 @@ import { cpSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "node
5
5
  import { HUB, bundleDir, join } from "./lib/util";
6
6
  import { detail, totalElapsed } from "./lib/log";
7
7
  import { rejectUnknownFlags } from "./lib/args";
8
+ import { paint, paintErr } from "./lib/color";
8
9
 
9
10
  const argv = process.argv.slice(2);
10
11
  // Before reading positionals: an unrecognized flag would otherwise become the destination,
11
12
  // and `khb export mybundle --force` would export into a folder named `--force`.
12
13
  rejectUnknownFlags(argv, "khb export <bundle> [dest]");
13
14
  const [name, destArg] = argv;
14
- if (!name) { console.error("Usage: khb export <bundle> [dest]"); process.exit(1); }
15
+ if (!name) { console.error(`Usage: ${paintErr.cmd("khb export <bundle> [dest]")}`); process.exit(1); }
15
16
 
16
17
  const src = bundleDir(name);
17
18
  const dest = destArg ?? join(HUB, "export", name);
18
- if (existsSync(dest)) { console.error(`Destination exists: ${dest}`); process.exit(1); }
19
+ if (existsSync(dest)) { console.error(`${paintErr.bad("Destination exists:")} ${paintErr.path(dest)}`); process.exit(1); }
19
20
 
20
- console.log(`khb export → ${name}`);
21
+ console.log(`${paint.head("khb export")} → ${paint.name(name)}`);
21
22
  detail(`from: ${src}`);
22
23
  detail(`to: ${dest}`);
23
24
 
@@ -44,4 +45,7 @@ writeFileSync(join(dest, "outer.index.md"),
44
45
  writeFileSync(join(dest, "README.md"),
45
46
  `# ${name} (exported KHB bundle)\n\nExported: ${new Date().toISOString()}\nOrigin: KHB bundle-of-bundles repo.\n\nStandalone unit: start at AGENTS.md → outer.index.md → bundle/index.md.\nWorkflow protocols (query, ingest, lint, …) live in skills/<name>/SKILL.md and are discoverable by Claude and Codex.\nNote: refs.md entries pointing at other bundles will not resolve here.\n`);
46
47
 
47
- console.log(`\nExported to ${dest} in ${totalElapsed()} (bundle + agent contracts, skills, single-bundle router)`);
48
+ console.log(
49
+ `\n${paint.ok("Exported to")} ${paint.path(dest)} in ${totalElapsed()} ` +
50
+ paint.dim("(bundle + agent contracts, skills, single-bundle router)"),
51
+ );