@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 +4 -3
- package/README.md +41 -1
- package/SPEC.md +21 -5
- package/package.json +1 -1
- package/scripts/cli.ts +45 -25
- package/scripts/config.ts +229 -0
- package/scripts/doctor.ts +37 -8
- package/scripts/export.ts +8 -4
- package/scripts/hubs.ts +184 -92
- package/scripts/ingest/folder.ts +2 -1
- package/scripts/ingest/index.ts +21 -13
- package/scripts/init.ts +21 -12
- package/scripts/lib/color.ts +75 -0
- package/scripts/lib/config-check.ts +364 -0
- package/scripts/lib/log.ts +4 -2
- package/scripts/lib/registry.ts +20 -1
- package/scripts/lint.ts +9 -4
- package/scripts/new-bundle.ts +9 -4
- package/scripts/visualize.ts +7 -6
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
|
|
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
|
|
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
|
|
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
|
|
191
|
-
never touches the
|
|
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
|
|
200
|
-
the only ones that run **outside** a hub, and therefore the only ones that
|
|
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.
|
|
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: {
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
console.log(` ${
|
|
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(` ${
|
|
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
|
-
|
|
113
|
-
console.log(
|
|
114
|
-
console.log(
|
|
115
|
-
console.log(
|
|
116
|
-
console.log(
|
|
117
|
-
console.log(
|
|
118
|
-
console.log(
|
|
119
|
-
console.log(
|
|
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(
|
|
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(
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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(
|
|
172
|
-
if (pruned.length)
|
|
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) =>
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
19
|
+
if (existsSync(dest)) { console.error(`${paintErr.bad("Destination exists:")} ${paintErr.path(dest)}`); process.exit(1); }
|
|
19
20
|
|
|
20
|
-
console.log(
|
|
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(
|
|
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
|
+
);
|