@msareen/knowledge-hub-builder 0.2.1 → 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,13 +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, the machine config's schema findings, and the command that fixes each finding. Writes nothing, and does not duplicate `lint` |
140
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 |
141
142
  | `khb visualize [--port N] [--no-open]` | serve the live bundle graph in your browser; aliases `vis`, `viz` |
142
143
  | `khb new-bundle <name>` | scaffold + register a bundle |
143
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` |
144
145
  | `khb export <bundle> [dest]` | standalone copy: bundle + common patterns, shareable alone |
145
146
 
146
- 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
147
148
  `~/.khb/hubs-config.json` (`%USERPROFILE%\.khb` on Windows). They hold no knowledge — only
148
149
  paths — and fill themselves in as hubs are used, so they need no maintenance.
149
150
 
@@ -153,7 +154,8 @@ paths — and fill themselves in as hubs are used, so they need no maintenance.
153
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 |
154
155
  | `khb agent [name]` | which agent `khb go` launches — `claude`, `codex`, a custom `--command`, or `none` |
155
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 |
156
- | `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 |
157
159
 
158
160
  A hub's `name` and `description` in that list come from its own `khb.json`, so they travel
159
161
  with it. Set them at creation with `khb init --name --description`, or edit the marker's
package/README.md CHANGED
@@ -251,34 +251,59 @@ Commands can be run directly or requested through the matching agent skill.
251
251
  | `khb new-bundle <name> ["scope"]` | Create and register a bundle |
252
252
  | `khb ingest [bundle] [--force] [--skip-ocr] [--skip-audio]` | Acquire and extract declared sources |
253
253
  | `khb lint` | Validate routing, bundle structure, and OKF metadata |
254
+ | `khb doctor` | Read-only report: version, location, per-bundle counts, catalog backlog, transcriber |
254
255
  | `khb visualize [--port N] [--no-open]` (aliases: `vis`, `viz`) | Serve the live bundle graph on a random free port and open it in your default browser — pan/zoomable cross-bundle map, drill into a bundle for its folder-clustered concepts, rebuild-on-refresh, exits when you close the tab |
255
256
  | `khb export <bundle> [dest]` | Export one standalone bundle |
256
257
 
257
- Additional ingest flags:
258
+ Run against a hub outside the current directory with `--hub <dir>` or `$KHB_HUB`.
258
259
 
259
- - `--skip-ocr`
260
- - `--skip-audio`
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.
261
264
 
262
- Run against a hub outside the current directory with `--hub <dir>` or `$KHB_HUB`.
265
+ ### `khb doctor` what state is this hub in?
266
+
267
+ A single read-only report. It writes nothing and repairs nothing; each finding names the
268
+ command that does.
269
+
270
+ ```text
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
273
+ Bundles per bundle: concepts, raw/ files, log.md rows, catalog backlog, pending
274
+ Extraction which formats are bundled, and whether a transcriber is ready
275
+ Findings what needs attention, each with its fix
276
+ ```
277
+
278
+ Every check existed already, spread across the margins of commands that each knew one of
279
+ them — a move is announced by whatever you happen to run next, the `khb update` hint comes
280
+ out of `khb upgrade`, the uncurated row count out of `khb ingest`, and the transcriber probe
281
+ only ever spoke during a run that needed it. `doctor` asks for the whole picture without
282
+ changing anything to get it.
283
+
284
+ It is not a validator: `khb lint` still owns structure and OKF conformance, and `doctor`
285
+ points at it rather than repeating a rule.
263
286
 
264
287
  ### Moving between hubs
265
288
 
266
- These four run **outside** any hub, from any terminal:
289
+ These run **outside** any hub, from any terminal:
267
290
 
268
291
  | Command | Purpose |
269
292
  |---|---|
270
293
  | `khb` | Open a hub. One registered hub asks; several show a list and take a pick; none walks you through creating the first |
271
294
  | `khb list [--json]` | Every hub on this machine, with its description and path |
272
- | `khb go <name\|N> [--path]` | Open one by name or list position. `--path` prints just the path, for `cd "$(khb go --path work)"` |
273
- | `khb agent [name] [--command X] [--args "…"]` | Which agent `khb go` launches — `claude`, `codex`, anything on your PATH, or `none` to just print the path |
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 |
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 |
274
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 |
275
- | `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 |
276
300
 
277
301
  `khb go` prints the hub's path and then starts your agent there, so a bare `khb` from a
278
302
  cold terminal ends with an agent open on the right folder. No program can change its
279
303
  parent shell's directory, which is why the `cd` line is printed rather than performed.
280
304
 
281
- The list lives in `~/.khb/hubs-config.json` (`%USERPROFILE%\.khb` on Windows). It holds
305
+ The list lives in `~/.khb/hubs-config.json` (`%USERPROFILE%\.khb` on Windows; `$KHB_HOME`
306
+ overrides the directory, for a portable install or a test run). It holds
282
307
  paths and one launch command — no knowledge — and fills itself in: any khb command run
283
308
  inside a hub registers it, so hubs you already had show up without a migration step.
284
309
  Delete the file and the next command in each hub puts it back.
@@ -287,6 +312,39 @@ A hub's name and description come from its own `khb.json`, so they travel with t
287
312
  rather than living in one machine's list. Set them with `khb init --name --description`,
288
313
  or edit those two keys in the marker later.
289
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
+
290
348
  ### `khb update` — repairing a hub
291
349
 
292
350
  Two independent repairs, run together or apart:
@@ -324,7 +382,7 @@ the same spelling of the new path; matches must end at a path boundary, so movin
324
382
  never touches a sibling `…/older`; and the new path is matched too and rewritten to itself,
325
383
  which is what makes overlapping moves safe — lifting a hub out of its parent, or pushing it
326
384
  down into a subdirectory of where it stood — and makes a second run a no-op.
327
- `.git/`, `node_modules/` and the `inbox/` cache are skipped, as are binaries.
385
+ `.git/`, `node_modules/` and the `.ingest-cache/` cache are skipped, as are binaries.
328
386
 
329
387
  **`--schema`/`-s`** — a bundle's `sources.yaml` can predate a field khb's since learned about
330
388
  (e.g. `exclude:`), with no way to discover it short of reading the docs. This backfills
@@ -373,7 +431,7 @@ everything under `bundles/`.
373
431
 
374
432
  ## Privacy
375
433
 
376
- `raw/` and the extraction cache under `inbox/` are gitignored. `log.md` is committed and
434
+ `raw/` and the extraction cache under `.ingest-cache/` are gitignored. `log.md` is committed and
377
435
  records source paths, which may be absolute. Ignore `log.md` before the first commit if
378
436
  those paths are sensitive.
379
437
 
package/SPEC.md CHANGED
@@ -72,7 +72,7 @@ my-knowledge/ # ~/OneDrive/my-knowledge, a private repo, a shar
72
72
  │ │ ├── index.md # (each may carry its own index)
73
73
  │ │ └── <concept>.md
74
74
  │ └── raw/ # ingested/extracted material, pre-curation (gitignored)
75
- ├── inbox/extracted/ # hub-wide extraction cache, keyed by content hash (gitignored)
75
+ ├── .ingest-cache/extracted/ # hub-wide extraction cache, keyed by content hash (gitignored)
76
76
 
77
77
  │ ── below: package-owned copies, refreshed by `khb upgrade`, never hand-edited ──
78
78
  ├── AGENTS.md # common contract; Codex discovers this directly
@@ -121,14 +121,24 @@ acting on them** — an agent can never read a protocol the CLI no longer implem
121
121
  │ ├── new-bundle.ts # scaffold from .bundle_template, register in outer.index.md
122
122
  │ ├── export.ts # bundle + common patterns → standalone shareable folder
123
123
  │ ├── lint.ts # enforce skills/lint/SKILL.md across the hub
124
+ │ ├── doctor.ts # read-only state report; writes nothing, repairs nothing
124
125
  │ ├── visualize.ts # serve the live bundle graph from indexes + refs
126
+ │ ├── config.ts # khb config: view / edit / check / fix the machine config
125
127
  │ ├── ingest/ # folder.ts / files.ts / web.ts → acquire.ts → bundle/raw
126
128
  │ └── lib/
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
132
+ │ ├── create.ts # making a hub, shared by `khb init` and the first-run wizard
127
133
  │ ├── extract.ts # every local extractor + the content-hash cache
134
+ │ ├── graph.ts # graph data for the visualizer — read-only, never writes
135
+ │ ├── graph-page.ts # the visualizer's browser UI, rendered from that data
128
136
  │ ├── ledger.ts # log.md read/write
137
+ │ ├── log.ts # progress reporting: each unit announces itself before it runs
129
138
  │ ├── paths.ts # package-side paths — importing it never needs a hub
130
139
  │ ├── registry.ts # ~/.khb/hubs-config.json: where this machine's hubs are
131
140
  │ ├── relocate.ts # khb update --path's path rewriter — pure text, no judgement
141
+ │ ├── scaffold.ts # bundle creation + lookup, shared by new-bundle and ingest
132
142
  │ ├── schema.ts # khb update --schema: sources.yaml field diff/apply
133
143
  │ ├── upgrade.ts # the refresh itself: `khb upgrade` and the drift check
134
144
  │ └── util.ts # hub resolution + shared helpers
@@ -180,8 +190,9 @@ Three properties define it:
180
190
  Delete it and nothing is lost — the next command run inside each hub puts it back.
181
191
  - **It fills itself in.** Every khb command that resolves a hub registers it, so hubs made
182
192
  before the registry existed appear the first time anything is run in them. There is no
183
- migration and no `register` command to remember. `khb forget <name>` drops a shortcut and
184
- 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.
185
196
  - **The hub is the authority on its own identity.** `name` and `description` are read out
186
197
  of the hub's `khb.json` (`khb init --name --description`, or edit the file), so a hub
187
198
  moved to another machine or cloned by a colleague describes itself the same way there.
@@ -189,9 +200,9 @@ Three properties define it:
189
200
  the bundles inside. `khb upgrade` merges rather than replaces the marker, so keys khb
190
201
  does not own survive an upgrade.
191
202
 
192
- The commands over it are `khb list`, `khb go`, `khb agent`, `khb update` and `khb forget`
193
- the only ones that run **outside** a hub, and therefore the only ones that skip hub
194
- 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,
195
206
  several show the list and take a pick, none prints the help.
196
207
 
197
208
  `khb go` ends by launching your configured agent with the hub as its working directory.
@@ -199,6 +210,18 @@ No process can change its parent shell's directory, so `khb go` prints the `cd`
199
210
  the human and passes the path to the agent as cwd — `khb go --path <name>` prints only the
200
211
  path, for `cd "$(khb go --path work)"`. `khb agent none` turns the launch off entirely.
201
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
+
202
225
  Every path stored in or compared against the registry is canonicalized first (`realpath`,
203
226
  case-folded on Windows). One directory has several true names — `C:\Users\MANASV~1\…` and
204
227
  `C:\Users\Manasvi Sareen\…` are the same folder, as is anything reached through a symlink —
@@ -272,7 +295,7 @@ unattended:
272
295
  changes nothing. Only old and new naming *the same directory* is refused, there being no
273
296
  move to repair.
274
297
 
275
- `.git/`, `node_modules/` and the `inbox/` extraction cache are not walked; binary files and
298
+ `.git/`, `node_modules/` and the `.ingest-cache/` extraction cache are not walked; binary files and
276
299
  anything over 8 MB are skipped. `--dry-run` reports the file-by-file hit count and writes
277
300
  nothing.
278
301
 
@@ -483,7 +506,7 @@ raw files, with one hard rule — subagents write concept docs, the orchestrator
483
506
 
484
507
  Extraction is deterministic, so `khb` owns all of it. Common formats use bundled pure-JS
485
508
  libraries with no system install; results are cached hub-wide by content hash
486
- (`inbox/extracted/<sha256>.md`) and reused across bundles. Nothing here contacts a model:
509
+ (`.ingest-cache/extracted/<sha256>.md`) and reused across bundles. Nothing here contacts a model:
487
510
  tesseract and whisper are local binaries, expensive in CPU but reproducible, which is what
488
511
  puts them on the CLI side of the §Division-of-labor line.
489
512
 
@@ -526,6 +549,10 @@ drops, and rewrites the raw file with `extract_tool: claude-vision`.
526
549
  - every concept doc is listed in an index and carries OKF frontmatter (`type` required)
527
550
  - `refs.md` targets exist; no cross-bundle inline links from concept docs
528
551
  - index files contain links only (routing, not content)
552
+ - intra-bundle links from a concept doc resolve to a file that exists (warning — OKF
553
+ tolerates a link to knowledge not yet written)
554
+ - `log.md` agrees with the bundle: a `curated` value naming a missing concept is an error,
555
+ a `raw` value naming a missing file or a `raw/` file with no row is a warning
529
556
 
530
557
  ## 8. Visualizer
531
558
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msareen/knowledge-hub-builder",
3
- "version": "0.2.1",
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",
@@ -35,6 +35,8 @@
35
35
  "scripts": {
36
36
  "khb": "bun scripts/cli.ts",
37
37
  "lint": "bun scripts/cli.ts lint",
38
+ "upgrade": "bun scripts/cli.ts upgrade",
39
+ "doctor": "bun scripts/cli.ts doctor",
38
40
  "visualize": "bun scripts/cli.ts visualize",
39
41
  "new-bundle": "bun scripts/cli.ts new-bundle",
40
42
  "export": "bun scripts/cli.ts export",
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)" },
@@ -13,6 +14,7 @@ const COMMANDS: Record<string, { load: () => Promise<unknown>; usage: string; de
13
14
  desc: "acquire + extract declared sources → raw/",
14
15
  },
15
16
  lint: { load: () => import("./lint"), usage: "khb lint", desc: "validate the hub against skills/lint/SKILL.md" },
17
+ doctor: { load: () => import("./doctor"), usage: "khb doctor", desc: "read-only report on this hub's state" },
16
18
  visualize: {
17
19
  load: () => import("./visualize"),
18
20
  usage: "khb visualize [--port N] [--no-open]",
@@ -35,14 +37,23 @@ const COMMANDS: Record<string, { load: () => Promise<unknown>; usage: string; de
35
37
  usage: "khb update [new-path] [--path|-p] [--schema|-s] [--from <old>] [--dry-run]",
36
38
  desc: "repair a moved hub's paths, and/or backfill sources.yaml",
37
39
  },
38
- 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
+ },
39
50
  };
40
51
 
41
52
  /**
42
53
  * Commands that work *outside* a hub, against ~/.khb/hubs-config.json. They must not
43
54
  * resolve or upgrade a hub — their whole job is running before you are in one.
44
55
  */
45
- const REGISTRY_COMMANDS = new Set(["list", "go", "agent", "forget", "update"]);
56
+ const REGISTRY_COMMANDS = new Set(["list", "go", "agent", "forget", "update", "config"]);
46
57
 
47
58
  // Short forms that just resolve to a canonical command above — kept out of COMMANDS
48
59
  // itself so help text lists each command once. `-v` is taken by --version, so
@@ -78,19 +89,20 @@ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
78
89
  const usages = Object.values(COMMANDS).map((c) => c.usage.length);
79
90
  const width = Math.max(...usages.filter((n) => n <= CAP));
80
91
  const printSection = (title: string, names: string[]) => {
81
- console.log(title);
92
+ console.log(paint.head(title));
82
93
  for (const name of names) {
83
- const c = COMMANDS[name];
84
- if (c.usage.length > width) {
85
- console.log(` ${c.usage}`);
86
- 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}`);
87
99
  } else {
88
- console.log(` ${c.usage.padEnd(width)} ${c.desc}`);
100
+ console.log(` ${paint.cmd(entry.usage.padEnd(width))} ${entry.desc}`);
89
101
  }
90
102
  }
91
103
  };
92
104
 
93
- console.log(`khb ${version()} — Knowledge Hub Builder`);
105
+ console.log(`${paint.head(`khb ${version()}`)} — Knowledge Hub Builder`);
94
106
  console.log();
95
107
  console.log(`khb is the supporting tool: it handles deterministic extraction, file plumbing,`);
96
108
  console.log(`validation, and export. Your AI agent — Claude, Codex, Gemini, or another`);
@@ -108,14 +120,16 @@ if (cmd === "help" || cmd === "--help" || cmd === "-h") {
108
120
  );
109
121
  console.log();
110
122
 
111
- console.log(`Global: --hub <dir> operate on that hub instead of searching upward from cwd`);
112
- console.log(` help | --help | -h this help --version | -v version`);
113
- console.log(`Env: KHB_HUB same as --hub`);
114
- console.log(` KHB_HOME where the hub list lives (default ~/.khb)`);
115
- console.log(` KHB_NO_AUTO_UPGRADE don't refresh a hub's contract docs on version drift`);
116
- console.log(`Exit: 0 on success, 1 on a usage error or a failure. An unknown option is an error;`);
117
- console.log(` a source khb cannot extract is not — it becomes a pending row in log.md.`);
118
- 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")}`);
119
133
  process.exit(0);
120
134
  }
121
135
 
@@ -126,8 +140,8 @@ if (cmd === "--version" || cmd === "-v") {
126
140
 
127
141
  const entry = COMMANDS[cmd];
128
142
  if (!entry) {
129
- console.error(`Unknown command: ${cmd}`);
130
- console.error(`Try: khb help`);
143
+ console.error(`${paintErr.bad("Unknown command:")} ${cmd}`);
144
+ console.error(`Try: ${paintErr.cmd("khb help")}`);
131
145
  process.exit(1);
132
146
  }
133
147
 
@@ -153,9 +167,13 @@ if (cmd !== "init" && cmd !== "upgrade" && !REGISTRY_COMMANDS.has(cmd)) {
153
167
  const { recordLocation } = await import("./lib/upgrade");
154
168
  const { moved } = recordLocation(hub);
155
169
  if (moved) {
156
- console.error(`khb: this hub was at ${moved} and is now at ${hub}.`);
157
- console.error(`khb: absolute paths recorded inside it still name the old location.`);
158
- 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
+ );
159
177
  }
160
178
  }
161
179
  if (hub && !process.env.KHB_NO_AUTO_UPGRADE) {
@@ -165,10 +183,13 @@ if (cmd !== "init" && cmd !== "upgrade" && !REGISTRY_COMMANDS.has(cmd)) {
165
183
  const { from, to, pruned, renamed } = upgradeHub(hub);
166
184
  // stderr, so a command's own output stays pipeable.
167
185
  console.error(
168
- `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.`,
169
187
  );
170
- if (renamed) console.error(`khb: renamed ${renamed} -> khb.json`);
171
- 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
+ );
172
193
  const hint = updateHint(hub);
173
194
  if (hint) console.error(hint);
174
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);