@msareen/knowledge-hub-builder 0.1.7 → 0.1.8

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.
@@ -0,0 +1,37 @@
1
+ // One rule for `exclude:` entries in sources.yaml, shared by folder and files sources. An
2
+ // entry that starts with a drive letter or `/` is absolute and is checked against the file's
3
+ // absolute path; anything else is relative and checked against the file's path relative to
4
+ // its source root (or, for `files` sources, its basename). Within either case, an entry with
5
+ // no glob metacharacter (* ? [) is a plain prefix — it matches the path itself or anything
6
+ // under it — and an entry with one is a Bun.Glob pattern. Bun.Glob is a global, no import
7
+ // needed.
8
+ import { isAbsolute } from "node:path";
9
+
10
+ const isGlobPattern = (p: string) => /[*?[]/.test(p);
11
+ const posix = (p: string) => p.replaceAll("\\", "/");
12
+
13
+ type Matcher = (path: string) => boolean;
14
+
15
+ function makeMatcher(patterns: string[]): Matcher {
16
+ const plain: string[] = [];
17
+ const globs: Bun.Glob[] = [];
18
+ for (const raw of patterns) {
19
+ const p = posix(raw).replace(/\/+$/, ""); // trailing slash is cosmetic on a plain entry
20
+ if (isGlobPattern(p)) globs.push(new Bun.Glob(p));
21
+ else plain.push(p);
22
+ }
23
+ return (path: string) =>
24
+ plain.some((p) => path === p || path.startsWith(`${p}/`)) || globs.some((g) => g.match(path));
25
+ }
26
+
27
+ /**
28
+ * Build an excluder from `sources.yaml`'s `exclude:` list. The returned function takes a
29
+ * file's absolute path and its path relative to the source (or basename, for `files`
30
+ * sources) and reports whether either matched an exclude entry of the corresponding kind.
31
+ */
32
+ export function makeExcluder(patterns: string[] | undefined): (absPath: string, relPath: string) => boolean {
33
+ if (!patterns?.length) return () => false;
34
+ const abs = makeMatcher(patterns.filter((p) => isAbsolute(p)).map(posix));
35
+ const rel = makeMatcher(patterns.filter((p) => !isAbsolute(p)));
36
+ return (absPath: string, relPath: string) => abs(posix(absPath)) || rel(relPath);
37
+ }
@@ -5,6 +5,7 @@ import { basename } from "../lib/util";
5
5
  import type { Entry } from "../lib/ledger";
6
6
  import { acquireFile, newCounters, report, type Options } from "./acquire";
7
7
  import { detail, item, outcome, pos } from "../lib/log";
8
+ import { makeExcluder } from "./exclude";
8
9
  import type { Source } from "./index";
9
10
 
10
11
  export async function ingestFiles(
@@ -15,9 +16,14 @@ export async function ingestFiles(
15
16
  opts: Options,
16
17
  ) {
17
18
  detail(`${s.paths.length} file(s) declared`);
19
+ const excluded = makeExcluder(s.exclude);
20
+ const paths = s.paths.filter((p) => !excluded(p, basename(p)));
21
+ const skippedCount = s.paths.length - paths.length;
22
+ if (skippedCount) detail(`${skippedCount} excluded by 'exclude' rule(s), ${paths.length} remain`);
23
+
18
24
  const c = newCounters();
19
- for (const [i, p] of s.paths.entries()) {
20
- const at = pos(i + 1, s.paths.length);
25
+ for (const [i, p] of paths.entries()) {
26
+ const at = pos(i + 1, paths.length);
21
27
  if (!existsSync(p)) {
22
28
  item(at, p);
23
29
  outcome("missing, skipped");
@@ -6,6 +6,7 @@ import { join } from "../lib/util";
6
6
  import type { Entry } from "../lib/ledger";
7
7
  import { acquireFile, newCounters, report, type Options } from "./acquire";
8
8
  import { detail, pos } from "../lib/log";
9
+ import { makeExcluder } from "./exclude";
9
10
  import type { Source } from "./index";
10
11
 
11
12
  export async function ingestFolder(
@@ -30,14 +31,23 @@ export async function ingestFolder(
30
31
  // take a while to enumerate, and knowing the denominator is what makes "[ 3/57]" mean
31
32
  // anything to someone deciding whether to wait.
32
33
  detail(`scanning ${s.path} …`);
33
- const files = walk(s.path);
34
- detail(`${files.length} file(s) found`);
34
+ const all = walk(s.path);
35
+ detail(`${all.length} file(s) found`);
36
+
37
+ // relOf is posix-normalized so an 'exclude' entry like "drafts/" behaves the same whether
38
+ // the corpus was walked on Windows or POSIX; it's computed once and reused for both the
39
+ // exclude check and the flattened raw/ filename below.
40
+ const relOf = (p: string) => p.slice(s.path.length + 1).replaceAll("\\", "/");
41
+ const excluded = makeExcluder(s.exclude);
42
+ const files = all.filter((p) => !excluded(p, relOf(p)));
43
+ const skippedCount = all.length - files.length;
44
+ if (skippedCount) detail(`${skippedCount} excluded by 'exclude' rule(s), ${files.length} remain`);
35
45
 
36
46
  const c = newCounters();
37
47
  for (const [i, p] of files.entries()) {
38
48
  // Flatten the subtree into the filename so two `notes.md` in sibling folders don't
39
49
  // collide in raw/, and so the origin stays legible without opening the file.
40
- const rel = p.slice(s.path.length + 1).replaceAll(/[\\/]/g, "__");
50
+ const rel = relOf(p).replaceAll("/", "__");
41
51
  await acquireFile(pos(i + 1, files.length), p, rel, rawDir, bundleDir, entries, c, opts);
42
52
  }
43
53
  report(c);
@@ -21,8 +21,8 @@ import { ingestWeb } from "./web";
21
21
  import type { Options } from "./acquire";
22
22
 
23
23
  export type Source =
24
- | { type: "folder"; path: string }
25
- | { type: "files"; paths: string[] }
24
+ | { type: "folder"; path: string; exclude?: string[] }
25
+ | { type: "files"; paths: string[]; exclude?: string[] }
26
26
  | { type: "web"; urls: string[] };
27
27
 
28
28
  const argv = process.argv.slice(2);
@@ -94,6 +94,14 @@ for (const [i, s] of sources.entries()) {
94
94
  process.exit(1);
95
95
  }
96
96
  }
97
+ if (
98
+ (s.type === "folder" || s.type === "files") &&
99
+ s.exclude !== undefined &&
100
+ (!Array.isArray(s.exclude) || s.exclude.some((v: unknown) => typeof v !== "string"))
101
+ ) {
102
+ console.error(`${bundle}: ${at}.exclude must be a list of strings`);
103
+ process.exit(1);
104
+ }
97
105
  }
98
106
  if (!sources.length) {
99
107
  console.log(`${bundle}: no sources configured.`);
package/scripts/init.ts CHANGED
@@ -8,13 +8,30 @@
8
8
  //
9
9
  // The mechanism itself lives in lib/upgrade.ts, because cli.ts also runs it on version
10
10
  // drift before any hub command.
11
- import { cpSync, mkdirSync, existsSync } from "node:fs";
12
- import { join, resolve, basename } from "node:path";
13
- import { HUB_TEMPLATE, MARKER, markerIn } from "./lib/paths";
14
- import { upgradeHub, syncManaged, stamp } from "./lib/upgrade";
11
+ import { resolve, basename } from "node:path";
12
+ import { MARKER, markerIn } from "./lib/paths";
13
+ import { upgradeHub, updateHint } from "./lib/upgrade";
15
14
 
16
15
  const upgrading = process.env.KHB_SUBCOMMAND === "upgrade";
17
- const [dirArg] = process.argv.slice(2);
16
+ const argv = process.argv.slice(2);
17
+
18
+ // A hub describes itself in its own marker, and the machine-level registry reads those
19
+ // two fields from there — so the label follows the hub when it is moved or cloned onto
20
+ // another machine, instead of living only in one laptop's shortcut list.
21
+ function takeOpt(name: string): string | undefined {
22
+ const i = argv.indexOf(name);
23
+ if (i < 0) return undefined;
24
+ const v = argv[i + 1];
25
+ if (v === undefined) {
26
+ console.error(`${name} needs a value`);
27
+ process.exit(1);
28
+ }
29
+ argv.splice(i, 2);
30
+ return v;
31
+ }
32
+ const nameOpt = takeOpt("--name");
33
+ const descOpt = takeOpt("--description");
34
+ const [dirArg] = argv;
18
35
 
19
36
  if (upgrading) {
20
37
  const { HUB } = await import("./lib/util"); // resolves the hub, or exits with guidance
@@ -24,6 +41,8 @@ if (upgrading) {
24
41
  if (renamed) console.log(` renamed: ${renamed} -> ${MARKER}`);
25
42
  if (pruned.length) console.log(` removed (no longer part of the contract): ${pruned.join(", ")}`);
26
43
  console.log(`Your bundles/ and outer.index.md were not touched. Next: khb lint`);
44
+ const hint = updateHint(HUB);
45
+ if (hint) console.log(hint);
27
46
  } else {
28
47
  const hub = resolve(dirArg ?? process.cwd());
29
48
 
@@ -33,14 +52,8 @@ if (upgrading) {
33
52
  process.exit(1);
34
53
  }
35
54
 
36
- mkdirSync(join(hub, "bundles"), { recursive: true });
37
- cpSync(join(HUB_TEMPLATE, "outer.index.md"), join(hub, "outer.index.md"));
38
- // Dotfiles: shipped unprefixed so npm doesn't swallow them, renamed on the way in.
39
- // Never clobber — `khb init` may be run inside a folder that is already a git repo.
40
- for (const f of ["gitignore", "gitattributes"])
41
- if (!existsSync(join(hub, `.${f}`))) cpSync(join(HUB_TEMPLATE, f), join(hub, `.${f}`));
42
- const synced = syncManaged(hub);
43
- stamp(hub);
55
+ const { createHub } = await import("./lib/create");
56
+ const { synced, entry } = createHub(hub, { name: nameOpt, description: descOpt });
44
57
 
45
58
  console.log(`Hub created: ${hub}`);
46
59
  console.log(` khb.json, outer.index.md, bundles/, .gitignore, .gitattributes`);
@@ -50,4 +63,5 @@ if (upgrading) {
50
63
  console.log(` git init # optional, but recommended`);
51
64
  console.log(` khb new-bundle <name> "<scope>" # your first bundle`);
52
65
  console.log(`\nThen open this folder with Claude or Codex — both load AGENTS.md and the workflow skills.`);
66
+ console.log(`Registered as "${entry.name}" — from any terminal, 'khb' comes back here and starts your agent.`);
53
67
  }
@@ -1,8 +1,12 @@
1
1
  // Tiny argv helpers shared by the scripts that take flags. Each `take*` removes what it
2
2
  // consumed from the array, so whatever is left is the positional arguments.
3
- export function takeFlag(args: string[], name: string): boolean {
4
- const i = args.indexOf(name);
5
- if (i < 0) return false;
6
- args.splice(i, 1);
7
- return true;
3
+ export function takeFlag(args: string[], ...names: string[]): boolean {
4
+ for (const name of names) {
5
+ const i = args.indexOf(name);
6
+ if (i >= 0) {
7
+ args.splice(i, 1);
8
+ return true;
9
+ }
10
+ }
11
+ return false;
8
12
  }
@@ -0,0 +1,39 @@
1
+ // Creating a hub, as a function rather than a script.
2
+ //
3
+ // Two callers need it and must not diverge: `khb init`, and the first-run wizard a bare
4
+ // `khb` opens when the machine has no hubs yet. A hub made by the wizard is the same hub
5
+ // down to the byte — the wizard only asks the questions `init` takes as flags.
6
+ import { cpSync, mkdirSync, existsSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ import { HUB_TEMPLATE } from "./paths";
9
+ import { syncManaged, stamp } from "./upgrade";
10
+ import { registerHub, type HubEntry } from "./registry";
11
+
12
+ export type CreatedHub = {
13
+ hub: string;
14
+ /** Package-owned contract files copied in, for the caller to report. */
15
+ synced: string[];
16
+ entry: HubEntry;
17
+ };
18
+
19
+ export function createHub(
20
+ dir: string,
21
+ opts: { name?: string; description?: string } = {},
22
+ ): CreatedHub {
23
+ const hub = resolve(dir);
24
+ mkdirSync(join(hub, "bundles"), { recursive: true });
25
+ cpSync(join(HUB_TEMPLATE, "outer.index.md"), join(hub, "outer.index.md"));
26
+ // Dotfiles: shipped unprefixed so npm doesn't swallow them, renamed on the way in.
27
+ // Never clobber — a hub may be created inside a folder that is already a git repo.
28
+ for (const f of ["gitignore", "gitattributes"])
29
+ if (!existsSync(join(hub, `.${f}`))) cpSync(join(HUB_TEMPLATE, f), join(hub, `.${f}`));
30
+ const synced = syncManaged(hub);
31
+ stamp(hub, undefined, {
32
+ ...(opts.name ? { name: opts.name } : {}),
33
+ ...(opts.description ? { description: opts.description } : {}),
34
+ });
35
+ // Put it on the machine's shortcut list straight away, so a bare `khb` from any
36
+ // terminal can find its way back here without the user remembering the path.
37
+ const entry = registerHub(hub);
38
+ return { hub, synced, entry };
39
+ }
@@ -51,3 +51,43 @@ export function note(msg: string) {
51
51
  export function outcome(msg: string) {
52
52
  console.log(` ${msg} (${secs(Date.now() - itemStart)})`);
53
53
  }
54
+
55
+ /**
56
+ * A running counter for a walk whose units are too fast and too many to deserve a line
57
+ * each — thousands of files checked in milliseconds, where `item()` per file would bury
58
+ * the result in its own progress.
59
+ *
60
+ * On a terminal it rewrites one line in place, on **stderr**: the transient text never
61
+ * lands in a redirected log or a pipe, so `khb … > out.txt` still gets clean output. With
62
+ * no terminal it degrades to a milestone line every `every` units, so a CI log or a
63
+ * captured run still shows the walk moving rather than appearing hung.
64
+ *
65
+ * Always call `done()`, including on the early-exit paths — it is what erases the line.
66
+ */
67
+ export function ticker(label: string, total?: number, every = 500) {
68
+ const tty = process.stderr.isTTY;
69
+ const of = total ? `/${total}` : "";
70
+ let n = 0;
71
+ let lastPaint = 0;
72
+ let width = 0;
73
+ return {
74
+ tick(suffix = "") {
75
+ n++;
76
+ const text = ` ${label} ${n}${of}${suffix ? ` — ${suffix}` : ""}`;
77
+ if (tty) {
78
+ // Repaint at ~12fps, not per unit: the terminal is slower than the walk, and an
79
+ // unthrottled counter spends more time drawing than working.
80
+ const now = Date.now();
81
+ if (now - lastPaint < 80 && n !== total) return;
82
+ lastPaint = now;
83
+ width = Math.max(width, text.length);
84
+ process.stderr.write(`\r${text.padEnd(width)}`);
85
+ } else if (n % every === 0) {
86
+ console.log(text);
87
+ }
88
+ },
89
+ done() {
90
+ if (tty && width) process.stderr.write(`\r${" ".repeat(width)}\r`);
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,306 @@
1
+ // The machine-level registry: ~/.khb/hubs-config.json.
2
+ //
3
+ // A hub is self-contained and knows nothing about the machine it sits on — that is the
4
+ // point of the marker file. But a person with hubs in three places has no way to find
5
+ // them from a cold terminal, so khb keeps one small file per *machine* listing where the
6
+ // hubs are and which agent to open them with. It holds no knowledge, only paths: delete
7
+ // it and nothing is lost but the shortcuts.
8
+ //
9
+ // Package-side, like paths.ts — importing this must never require a hub to exist.
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, realpathSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join, resolve, basename } from "node:path";
13
+ import { markerIn } from "./paths";
14
+
15
+ /** $KHB_HOME overrides the location — tests and portable installs need it moveable. */
16
+ export const KHB_HOME = process.env.KHB_HOME ? resolve(process.env.KHB_HOME) : join(homedir(), ".khb");
17
+ export const CONFIG = join(KHB_HOME, "hubs-config.json");
18
+
19
+ /** How to start an agent in a hub folder. `args` is appended after the hub is cd'd into. */
20
+ export type AgentSpec = { command: string; args?: string[] };
21
+
22
+ export type HubEntry = {
23
+ name: string;
24
+ description: string;
25
+ path: string;
26
+ added: string;
27
+ lastUsed?: string;
28
+ /**
29
+ * The `created` stamp from the hub's own marker. Copied here purely as an identity
30
+ * fingerprint: after a hub is moved, its registry entry points at nothing, and matching
31
+ * this against the marker at the new location is how `khb update --path` knows which dead entry
32
+ * is the same hub rather than a different one that also went missing.
33
+ */
34
+ created?: string;
35
+ };
36
+
37
+ export type Config = {
38
+ version: 1;
39
+ /** Key into `agents`. Empty string means "never launch anything, just show the path". */
40
+ defaultAgent: string;
41
+ agents: Record<string, AgentSpec>;
42
+ hubs: HubEntry[];
43
+ };
44
+
45
+ /**
46
+ * Shipped defaults. Both are the plain binary name — resolved on PATH at launch time, so
47
+ * a machine without one installed simply fails at spawn with its own error rather than
48
+ * khb pretending to know where it lives.
49
+ */
50
+ const DEFAULT_AGENTS: Record<string, AgentSpec> = {
51
+ claude: { command: "claude", args: [] },
52
+ codex: { command: "codex", args: [] },
53
+ };
54
+
55
+ const blank = (): Config => ({
56
+ version: 1,
57
+ defaultAgent: "claude",
58
+ agents: { ...DEFAULT_AGENTS },
59
+ hubs: [],
60
+ });
61
+
62
+ /**
63
+ * Read the registry, creating it on first run. Never throws on a damaged file: a
64
+ * corrupted shortcut list must not block `khb lint` in a hub that is perfectly fine, so
65
+ * unreadable JSON is reported once and treated as empty.
66
+ */
67
+ export function loadConfig(): Config {
68
+ if (!existsSync(CONFIG)) {
69
+ const fresh = blank();
70
+ saveConfig(fresh);
71
+ return fresh;
72
+ }
73
+ try {
74
+ const raw = JSON.parse(readFileSync(CONFIG, "utf8")) as Partial<Config>;
75
+ return {
76
+ version: 1,
77
+ defaultAgent: raw.defaultAgent ?? "claude",
78
+ agents: { ...DEFAULT_AGENTS, ...(raw.agents ?? {}) },
79
+ hubs: Array.isArray(raw.hubs) ? raw.hubs.filter((h) => h && typeof h.path === "string") : [],
80
+ };
81
+ } catch {
82
+ console.error(`khb: could not read ${CONFIG} — ignoring it. Fix or delete the file.`);
83
+ return blank();
84
+ }
85
+ }
86
+
87
+ export function saveConfig(cfg: Config): void {
88
+ mkdirSync(KHB_HOME, { recursive: true });
89
+ writeFileSync(CONFIG, JSON.stringify(cfg, null, 2) + "\n");
90
+ }
91
+
92
+ /**
93
+ * The one true spelling of a path. `resolve` is not enough on Windows, where the same
94
+ * folder is reachable as `C:\Users\MANASV~1\…` and `C:\Users\Manasvi Sareen\…` with
95
+ * different casing again on top — three strings, one directory. Registering it under two
96
+ * of them would list one hub twice and defeat the move repair, so every path stored in or
97
+ * compared against the registry goes through here first.
98
+ *
99
+ * realpath also follows symlinks, which is the behaviour we want: a hub reached through a
100
+ * link is the same hub as the hub itself.
101
+ */
102
+ export function canonical(p: string): string {
103
+ const abs = resolve(p);
104
+ try {
105
+ return realpathSync.native(abs);
106
+ } catch {
107
+ return abs; // path does not exist (a moved-away hub): the resolved form is the best we have
108
+ }
109
+ }
110
+
111
+ /** Case-insensitive on Windows, after canonicalizing — see `canonical`. */
112
+ const samePath = (a: string, b: string) => {
113
+ const [x, y] = [canonical(a), canonical(b)];
114
+ return process.platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y;
115
+ };
116
+
117
+ /** Whatever the hub's own marker says about itself. Unreadable marker → nothing known. */
118
+ export function markerFields(hub: string): { name?: string; description?: string; created?: string } {
119
+ const marker = markerIn(hub);
120
+ if (!marker) return {};
121
+ try {
122
+ const j = JSON.parse(readFileSync(join(hub, marker), "utf8"));
123
+ const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
124
+ return { name: str(j.name), description: str(j.description), created: str(j.created) };
125
+ } catch {
126
+ return {}; // a hub with an unreadable marker still deserves a listing
127
+ }
128
+ }
129
+
130
+ /**
131
+ * A one-line description for a hub. Authored in the hub's own `khb.json` (`description`),
132
+ * so it travels with the hub and is not a second place to maintain the same sentence;
133
+ * with none, describe the hub by what is in it. Deliberately does not scrape
134
+ * `outer.index.md` — its opening line is template boilerplate identical in every hub.
135
+ */
136
+ export function describeHub(hub: string): string {
137
+ const own = markerFields(hub).description;
138
+ if (own) return own;
139
+ const names = bundleNames(hub);
140
+ if (!names.length) return "no bundles yet";
141
+ const shown = names.slice(0, 4).join(", ");
142
+ return `${names.length} bundle${names.length === 1 ? "" : "s"}: ${shown}${names.length > 4 ? ", …" : ""}`;
143
+ }
144
+
145
+ export function bundleNames(hub: string): string[] {
146
+ try {
147
+ return readdirSync(join(hub, "bundles"), { withFileTypes: true })
148
+ .filter((d) => d.isDirectory())
149
+ .map((d) => d.name);
150
+ } catch {
151
+ return [];
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Unique registry name for a hub: what its marker calls itself, else the folder name,
157
+ * suffixed if two hubs on this machine want the same one.
158
+ */
159
+ function uniqueName(cfg: Config, hub: string): string {
160
+ const base = markerFields(hub).name ?? basename(hub) ?? "hub";
161
+ if (!cfg.hubs.some((h) => h.name === base)) return base;
162
+ for (let i = 2; ; i++) if (!cfg.hubs.some((h) => h.name === `${base}-${i}`)) return `${base}-${i}`;
163
+ }
164
+
165
+ /**
166
+ * Record a hub in the registry, or refresh what is already recorded. Called whenever khb
167
+ * resolves a hub, so hubs created before the registry existed register themselves the
168
+ * first time any command runs in them — no migration step, no `khb register` to remember.
169
+ * A user-edited name is never overwritten; the description is, since it is derived.
170
+ */
171
+ export function registerHub(hub: string, opts: { name?: string; description?: string } = {}): HubEntry {
172
+ const path = canonical(hub);
173
+ const cfg = loadConfig();
174
+ const own = markerFields(path);
175
+ let entry = cfg.hubs.find((h) => samePath(resolve(h.path), path));
176
+ if (entry) {
177
+ entry.path = path;
178
+ // Both fields are derived from the hub, so both re-derive: rename or re-describe a hub
179
+ // in its own khb.json and the listing follows on the next command run there.
180
+ if (opts.name ?? own.name) entry.name = opts.name ?? own.name!;
181
+ entry.description = opts.description ?? describeHub(path);
182
+ entry.created = own.created;
183
+ } else {
184
+ entry = {
185
+ name: opts.name ?? uniqueName(cfg, path),
186
+ description: opts.description ?? describeHub(path),
187
+ path,
188
+ added: new Date().toISOString(),
189
+ created: own.created,
190
+ };
191
+ cfg.hubs.push(entry);
192
+ }
193
+ saveConfig(cfg);
194
+ return entry;
195
+ }
196
+
197
+ /** Stamp a hub as most recently used, so the picker can order by recency. */
198
+ export function touchHub(hub: string): void {
199
+ const path = canonical(hub);
200
+ const cfg = loadConfig();
201
+ const entry = cfg.hubs.find((h) => samePath(resolve(h.path), path));
202
+ if (!entry) return;
203
+ entry.lastUsed = new Date().toISOString();
204
+ saveConfig(cfg);
205
+ }
206
+
207
+ export function forgetHub(nameOrPath: string): HubEntry | undefined {
208
+ const cfg = loadConfig();
209
+ const i = cfg.hubs.findIndex(
210
+ (h) => h.name === nameOrPath || samePath(resolve(h.path), resolve(nameOrPath)),
211
+ );
212
+ if (i < 0) return undefined;
213
+ const [gone] = cfg.hubs.splice(i, 1);
214
+ saveConfig(cfg);
215
+ return gone;
216
+ }
217
+
218
+ /** A registered hub whose folder no longer holds a marker — moved, deleted, or unmounted. */
219
+ export const isAlive = (h: HubEntry): boolean => existsSync(h.path) && !!markerIn(h.path);
220
+
221
+ /**
222
+ * Dead entries that are plausibly `hub` under its former name, best evidence first:
223
+ * an identical `created` stamp is proof (it is minted once, at `khb init`), a matching
224
+ * registry name is a guess. Returning candidates rather than picking one is deliberate —
225
+ * `khb update --path` acts on proof and asks when it only has a guess.
226
+ */
227
+ export function relocationCandidates(hub: string): { certain: HubEntry[]; likely: HubEntry[] } {
228
+ const path = canonical(hub);
229
+ const own = markerFields(path);
230
+ const dead = loadConfig().hubs.filter((h) => !isAlive(h) && !samePath(resolve(h.path), path));
231
+ const certain = own.created ? dead.filter((h) => h.created === own.created) : [];
232
+ const likely = dead.filter(
233
+ (h) => !certain.includes(h) && h.name === (own.name ?? basename(path)),
234
+ );
235
+ return { certain, likely };
236
+ }
237
+
238
+ /**
239
+ * Point a registry entry at where the hub now lives. If the new location was already
240
+ * registered separately — a `khb` command was run there before anyone repaired the move —
241
+ * the two entries are the same hub, so fold the older one's history into the survivor
242
+ * rather than leaving a duplicate behind.
243
+ */
244
+ export function relocateHub(oldPath: string, newPath: string): HubEntry {
245
+ const from = canonical(oldPath);
246
+ const to = canonical(newPath);
247
+ const cfg = loadConfig();
248
+ const stale = cfg.hubs.find((h) => samePath(resolve(h.path), from));
249
+ const already = cfg.hubs.find((h) => samePath(resolve(h.path), to));
250
+
251
+ const survivor = stale ?? already ?? {
252
+ name: uniqueName(cfg, to),
253
+ description: describeHub(to),
254
+ path: to,
255
+ added: new Date().toISOString(),
256
+ };
257
+ if (stale && already && stale !== already) {
258
+ survivor.added = [stale.added, already.added].filter(Boolean).sort()[0];
259
+ survivor.lastUsed = [stale.lastUsed, already.lastUsed].filter(Boolean).sort().pop();
260
+ cfg.hubs.splice(cfg.hubs.indexOf(already), 1);
261
+ }
262
+ if (!cfg.hubs.includes(survivor)) cfg.hubs.push(survivor);
263
+
264
+ survivor.path = to;
265
+ const own = markerFields(to);
266
+ // A name the marker states is the hub's own and survives the move. A name that was only
267
+ // ever the old folder's would otherwise outlive the folder — re-derive it from the new
268
+ // location, so the listing does not go on calling a hub after a directory that is gone.
269
+ if (own.name) survivor.name = own.name;
270
+ else if (survivor.name === basename(from)) {
271
+ const taken = cfg.hubs.filter((h) => h !== survivor);
272
+ survivor.name = uniqueName({ ...cfg, hubs: taken }, to);
273
+ }
274
+ survivor.description = describeHub(to);
275
+ survivor.created = own.created;
276
+ saveConfig(cfg);
277
+ return survivor;
278
+ }
279
+
280
+ /** Registered hubs, live ones first, each group most-recently-used first. */
281
+ export function listHubs(): HubEntry[] {
282
+ const recency = (h: HubEntry) => Date.parse(h.lastUsed ?? h.added) || 0;
283
+ return loadConfig().hubs.slice().sort((a, b) => {
284
+ if (isAlive(a) !== isAlive(b)) return isAlive(a) ? -1 : 1;
285
+ return recency(b) - recency(a);
286
+ });
287
+ }
288
+
289
+ /** Resolve `khb go <what>` — a registry name, a 1-based list position, or a path. */
290
+ export function findHubEntry(what: string): HubEntry | undefined {
291
+ const hubs = listHubs();
292
+ const byName = hubs.find((h) => h.name === what);
293
+ if (byName) return byName;
294
+ const n = Number(what);
295
+ if (Number.isInteger(n) && n >= 1 && n <= hubs.length) return hubs[n - 1];
296
+ return hubs.find((h) => samePath(resolve(h.path), resolve(what)));
297
+ }
298
+
299
+ export function agentFor(cfg: Config, name?: string): { name: string; spec: AgentSpec } | undefined {
300
+ const key = name ?? cfg.defaultAgent;
301
+ if (!key) return undefined;
302
+ const spec = cfg.agents[key];
303
+ // An unknown name is still usable as a bare command — someone naming an agent khb has
304
+ // never heard of should get their agent, not a lecture about the config file.
305
+ return { name: key, spec: spec ?? { command: key, args: [] } };
306
+ }