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