facult 1.0.3 → 1.1.0

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/src/doctor.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ensureAiIndexPath, legacyAiIndexPath } from "./ai-state";
5
+ import { repairAutosyncServices } from "./autosync";
6
+ import { facultAiIndexPath, facultConfigPath, facultRootDir } from "./paths";
7
+
8
+ function legacyDefaultRoot(home: string): string {
9
+ return join(home, "agents", ".facult");
10
+ }
11
+
12
+ async function repairLegacyRootConfig(home: string): Promise<boolean> {
13
+ const configPath = facultConfigPath(home);
14
+ const preferredRoot = join(home, ".ai");
15
+ const legacyRoot = legacyDefaultRoot(home);
16
+
17
+ let parsed: Record<string, unknown> | null = null;
18
+ try {
19
+ const text = await Bun.file(configPath).text();
20
+ const value = JSON.parse(text) as unknown;
21
+ if (value && typeof value === "object" && !Array.isArray(value)) {
22
+ parsed = value as Record<string, unknown>;
23
+ }
24
+ } catch {
25
+ return false;
26
+ }
27
+
28
+ if (parsed?.rootDir !== legacyRoot) {
29
+ return false;
30
+ }
31
+
32
+ try {
33
+ const stat = await Bun.file(preferredRoot).stat();
34
+ if (!stat.isDirectory()) {
35
+ return false;
36
+ }
37
+ } catch {
38
+ return false;
39
+ }
40
+
41
+ const next = {
42
+ ...parsed,
43
+ rootDir: preferredRoot,
44
+ };
45
+ await mkdir(join(home, ".facult"), { recursive: true });
46
+ await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
47
+ return true;
48
+ }
49
+
50
+ function printHelp() {
51
+ console.log(`facult doctor — inspect and repair local facult state
52
+
53
+ Usage:
54
+ facult doctor [--repair]
55
+
56
+ Options:
57
+ --repair Reconcile legacy AI state, canonical root config, and autosync service config when needed
58
+ `);
59
+ }
60
+
61
+ export async function doctorCommand(argv: string[]) {
62
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
63
+ printHelp();
64
+ return;
65
+ }
66
+
67
+ const repair = argv.includes("--repair");
68
+ const home = process.env.HOME?.trim() || homedir();
69
+
70
+ try {
71
+ let rootConfigRepaired = false;
72
+ let autosyncRepaired = false;
73
+ if (repair) {
74
+ rootConfigRepaired = await repairLegacyRootConfig(home);
75
+ autosyncRepaired = await repairAutosyncServices(home);
76
+ }
77
+ const rootDir = facultRootDir(home);
78
+ const generated = facultAiIndexPath(home);
79
+ const legacy = legacyAiIndexPath(rootDir);
80
+ const result = await ensureAiIndexPath({ homeDir: home, rootDir, repair });
81
+
82
+ console.log(`Canonical root: ${rootDir}`);
83
+ console.log(`Generated AI index: ${generated}`);
84
+ console.log(`Legacy root index: ${legacy}`);
85
+
86
+ if (rootConfigRepaired) {
87
+ console.log(`Updated facult root config to ${join(home, ".ai")}`);
88
+ }
89
+ if (autosyncRepaired) {
90
+ console.log("Repaired autosync launch agent configuration.");
91
+ }
92
+
93
+ if (result.source === "generated") {
94
+ console.log("AI index is healthy.");
95
+ return;
96
+ }
97
+
98
+ if (repair && result.source === "legacy") {
99
+ console.log(
100
+ `Repaired generated AI index from legacy root index: ${generated}`
101
+ );
102
+ return;
103
+ }
104
+
105
+ if (repair && result.source === "rebuilt") {
106
+ console.log(
107
+ `Rebuilt generated AI index from canonical source: ${generated}`
108
+ );
109
+ return;
110
+ }
111
+
112
+ if (result.source === "legacy") {
113
+ console.log(
114
+ "Legacy root index detected. Run `facult doctor --repair` to reconcile it."
115
+ );
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+
120
+ console.log(
121
+ "Generated AI index is missing. Run `facult doctor --repair` or `facult index`."
122
+ );
123
+ process.exitCode = 1;
124
+ } catch (err) {
125
+ console.error(err instanceof Error ? err.message : String(err));
126
+ process.exitCode = 1;
127
+ }
128
+ }
@@ -1,8 +1,9 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
+ import { ensureAiIndexPath } from "./ai-state";
3
4
  import type { FacultIndex } from "./index-builder";
4
5
  import { loadManagedState, syncManagedTools } from "./manage";
5
- import { facultRootDir } from "./paths";
6
+ import { facultAiIndexPath, facultRootDir } from "./paths";
6
7
 
7
8
  type EntryKind = "skills" | "mcp";
8
9
 
@@ -61,8 +62,12 @@ function computeNextEnabledFor({
61
62
  return uniqueSorted(base.filter((tool) => !targetTools.includes(tool)));
62
63
  }
63
64
 
64
- async function loadIndex(rootDir: string): Promise<FacultIndex> {
65
- const indexPath = join(rootDir, "index.json");
65
+ async function loadIndex(homeDir: string): Promise<FacultIndex> {
66
+ const { path: indexPath } = await ensureAiIndexPath({
67
+ homeDir,
68
+ rootDir: facultRootDir(homeDir),
69
+ repair: true,
70
+ });
66
71
  const file = Bun.file(indexPath);
67
72
  if (!(await file.exists())) {
68
73
  throw new Error(`Index not found at ${indexPath}. Run "facult index".`);
@@ -71,8 +76,8 @@ async function loadIndex(rootDir: string): Promise<FacultIndex> {
71
76
  return JSON.parse(raw) as FacultIndex;
72
77
  }
73
78
 
74
- async function writeIndex(rootDir: string, index: FacultIndex) {
75
- const indexPath = join(rootDir, "index.json");
79
+ async function writeIndex(homeDir: string, index: FacultIndex) {
80
+ const indexPath = facultAiIndexPath(homeDir);
76
81
  await Bun.write(indexPath, `${JSON.stringify(index, null, 2)}\n`);
77
82
  }
78
83
 
@@ -200,7 +205,7 @@ export async function applyEnableDisable({
200
205
 
201
206
  const allTools = managedTools.length ? managedTools : targetTools;
202
207
 
203
- const index = ensureIndexStructure(await loadIndex(root));
208
+ const index = ensureIndexStructure(await loadIndex(home));
204
209
  const missing: string[] = [];
205
210
  const mcpUpdates: string[] = [];
206
211
 
@@ -241,7 +246,7 @@ export async function applyEnableDisable({
241
246
  }
242
247
 
243
248
  index.updatedAt = new Date().toISOString();
244
- await writeIndex(root, index);
249
+ await writeIndex(home, index);
245
250
 
246
251
  await updateCanonicalServers({
247
252
  rootDir: root,
@@ -0,0 +1,461 @@
1
+ import { mkdir, readdir, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { renderCanonicalText } from "./agents";
4
+ import { renderSnippetText } from "./snippets";
5
+
6
+ export interface GlobalDocPlan {
7
+ write: string[];
8
+ remove: string[];
9
+ contents: Map<string, string>;
10
+ managedTargets: string[];
11
+ }
12
+
13
+ export interface RulesPlan {
14
+ write: string[];
15
+ remove: string[];
16
+ contents: Map<string, string>;
17
+ managedRulesDir: boolean;
18
+ }
19
+
20
+ export interface ToolConfigPlan {
21
+ targetPath: string;
22
+ write: boolean;
23
+ remove: boolean;
24
+ contents: string | null;
25
+ managedConfig: boolean;
26
+ }
27
+
28
+ interface SourceTarget {
29
+ sourcePath: string;
30
+ targetPath: string;
31
+ }
32
+
33
+ const TOML_BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
34
+
35
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
36
+ return !!value && typeof value === "object" && !Array.isArray(value);
37
+ }
38
+
39
+ async function fileExists(pathValue: string): Promise<boolean> {
40
+ try {
41
+ const stat = await Bun.file(pathValue).stat();
42
+ return stat.isFile();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ async function readTextIfExists(pathValue: string): Promise<string | null> {
49
+ if (!(await fileExists(pathValue))) {
50
+ return null;
51
+ }
52
+ return await Bun.file(pathValue).text();
53
+ }
54
+
55
+ async function readTomlFile(
56
+ pathValue: string
57
+ ): Promise<Record<string, unknown> | null> {
58
+ const text = await readTextIfExists(pathValue);
59
+ if (text == null) {
60
+ return null;
61
+ }
62
+ const parsed = Bun.TOML.parse(text);
63
+ return isPlainObject(parsed) ? parsed : null;
64
+ }
65
+
66
+ function mergeTomlObjects(
67
+ base: Record<string, unknown>,
68
+ override: Record<string, unknown>
69
+ ): Record<string, unknown> {
70
+ const merged: Record<string, unknown> = { ...base };
71
+ for (const [key, value] of Object.entries(override)) {
72
+ const current = merged[key];
73
+ if (isPlainObject(current) && isPlainObject(value)) {
74
+ merged[key] = mergeTomlObjects(current, value);
75
+ continue;
76
+ }
77
+ merged[key] = value;
78
+ }
79
+ return merged;
80
+ }
81
+
82
+ function shouldQuoteTomlKey(key: string): boolean {
83
+ return !TOML_BARE_KEY_PATTERN.test(key);
84
+ }
85
+
86
+ function escapeTomlString(value: string): string {
87
+ return value
88
+ .replace(/\\/g, "\\\\")
89
+ .replace(/"/g, '\\"')
90
+ .replace(/\n/g, "\\n");
91
+ }
92
+
93
+ function formatTomlKey(key: string): string {
94
+ return shouldQuoteTomlKey(key) ? `"${escapeTomlString(key)}"` : key;
95
+ }
96
+
97
+ function formatTomlValue(value: unknown): string {
98
+ if (typeof value === "string") {
99
+ return `"${escapeTomlString(value)}"`;
100
+ }
101
+ if (typeof value === "number" || typeof value === "bigint") {
102
+ return String(value);
103
+ }
104
+ if (typeof value === "boolean") {
105
+ return value ? "true" : "false";
106
+ }
107
+ if (Array.isArray(value)) {
108
+ return `[${value.map((entry) => formatTomlValue(entry)).join(", ")}]`;
109
+ }
110
+ throw new Error(`Unsupported TOML value: ${typeof value}`);
111
+ }
112
+
113
+ function stringifyTomlObject(obj: Record<string, unknown>): string {
114
+ const lines: string[] = [];
115
+
116
+ function emitTable(table: Record<string, unknown>, pathParts: string[] = []) {
117
+ const scalars: [string, unknown][] = [];
118
+ const subtables: [string, Record<string, unknown>][] = [];
119
+
120
+ for (const [key, value] of Object.entries(table)) {
121
+ if (isPlainObject(value)) {
122
+ subtables.push([key, value]);
123
+ } else {
124
+ scalars.push([key, value]);
125
+ }
126
+ }
127
+
128
+ if (pathParts.length > 0) {
129
+ if (lines.length > 0) {
130
+ lines.push("");
131
+ }
132
+ lines.push(`[${pathParts.map((part) => formatTomlKey(part)).join(".")}]`);
133
+ }
134
+
135
+ for (const [key, value] of scalars) {
136
+ lines.push(`${formatTomlKey(key)} = ${formatTomlValue(value)}`);
137
+ }
138
+
139
+ for (const [key, subtable] of subtables) {
140
+ emitTable(subtable, [...pathParts, key]);
141
+ }
142
+ }
143
+
144
+ emitTable(obj);
145
+ return lines.join("\n");
146
+ }
147
+
148
+ async function listGlobalDocSources(args: {
149
+ rootDir: string;
150
+ tool: string;
151
+ toolHome: string;
152
+ }): Promise<SourceTarget[]> {
153
+ const { rootDir, tool, toolHome } = args;
154
+ if (tool !== "codex") {
155
+ return [];
156
+ }
157
+
158
+ const candidates: SourceTarget[] = [];
159
+ const base = join(rootDir, "AGENTS.global.md");
160
+ if (await fileExists(base)) {
161
+ candidates.push({
162
+ sourcePath: base,
163
+ targetPath: join(toolHome, "AGENTS.md"),
164
+ });
165
+ }
166
+
167
+ const override = join(rootDir, "AGENTS.override.global.md");
168
+ if (await fileExists(override)) {
169
+ candidates.push({
170
+ sourcePath: override,
171
+ targetPath: join(toolHome, "AGENTS.override.md"),
172
+ });
173
+ }
174
+
175
+ return candidates;
176
+ }
177
+
178
+ async function renderSourceTarget(args: {
179
+ homeDir: string;
180
+ rootDir: string;
181
+ sourcePath: string;
182
+ targetPath: string;
183
+ tool: string;
184
+ }): Promise<string> {
185
+ const raw = await Bun.file(args.sourcePath).text();
186
+ const withSnippets = await renderSnippetText({
187
+ text: raw,
188
+ filePath: args.sourcePath,
189
+ rootDir: args.rootDir,
190
+ });
191
+ if (withSnippets.errors.length) {
192
+ throw new Error(withSnippets.errors.join("\n"));
193
+ }
194
+ return await renderCanonicalText(withSnippets.text, {
195
+ homeDir: args.homeDir,
196
+ rootDir: args.rootDir,
197
+ targetTool: args.tool,
198
+ targetPath: args.targetPath,
199
+ });
200
+ }
201
+
202
+ export async function planToolGlobalDocsSync(args: {
203
+ homeDir: string;
204
+ rootDir: string;
205
+ tool: string;
206
+ toolHome: string;
207
+ previouslyManagedTargets?: string[];
208
+ }): Promise<GlobalDocPlan> {
209
+ const docs = await listGlobalDocSources(args);
210
+ const contents = new Map<string, string>();
211
+ const managedTargets = docs.map((doc) => doc.targetPath).sort();
212
+
213
+ for (const doc of docs) {
214
+ const rendered = await renderSourceTarget({
215
+ homeDir: args.homeDir,
216
+ rootDir: args.rootDir,
217
+ sourcePath: doc.sourcePath,
218
+ targetPath: doc.targetPath,
219
+ tool: args.tool,
220
+ });
221
+ contents.set(doc.targetPath, rendered);
222
+ }
223
+
224
+ const write: string[] = [];
225
+ for (const targetPath of managedTargets) {
226
+ const current = await readTextIfExists(targetPath);
227
+ const desired = contents.get(targetPath);
228
+ if (desired != null && current !== desired) {
229
+ write.push(targetPath);
230
+ }
231
+ }
232
+
233
+ const remove = (args.previouslyManagedTargets ?? [])
234
+ .filter((targetPath) => !contents.has(targetPath))
235
+ .sort();
236
+
237
+ return {
238
+ write: write.sort(),
239
+ remove,
240
+ contents,
241
+ managedTargets,
242
+ };
243
+ }
244
+
245
+ export async function syncToolGlobalDocs(args: {
246
+ homeDir: string;
247
+ rootDir: string;
248
+ tool: string;
249
+ toolHome: string;
250
+ previouslyManagedTargets?: string[];
251
+ dryRun?: boolean;
252
+ }): Promise<GlobalDocPlan> {
253
+ const plan = await planToolGlobalDocsSync(args);
254
+ if (args.dryRun) {
255
+ return plan;
256
+ }
257
+
258
+ for (const pathValue of plan.remove) {
259
+ await rm(pathValue, { force: true });
260
+ }
261
+ for (const pathValue of plan.write) {
262
+ const desired = plan.contents.get(pathValue);
263
+ if (desired != null) {
264
+ await mkdir(dirname(pathValue), { recursive: true });
265
+ await Bun.write(
266
+ pathValue,
267
+ desired.endsWith("\n") ? desired : `${desired}\n`
268
+ );
269
+ }
270
+ }
271
+ return plan;
272
+ }
273
+
274
+ async function listToolRules(args: {
275
+ rootDir: string;
276
+ tool: string;
277
+ }): Promise<{ sourcePath: string; targetPath: string }[]> {
278
+ const sourceRoot = join(args.rootDir, "tools", args.tool, "rules");
279
+ const entries = await readdir(sourceRoot, { withFileTypes: true }).catch(
280
+ () => [] as import("node:fs").Dirent[]
281
+ );
282
+ const out: { sourcePath: string; targetPath: string }[] = [];
283
+ for (const entry of entries) {
284
+ if (!(entry.isFile() && entry.name.endsWith(".rules"))) {
285
+ continue;
286
+ }
287
+ out.push({
288
+ sourcePath: join(sourceRoot, entry.name),
289
+ targetPath: entry.name,
290
+ });
291
+ }
292
+ return out.sort((a, b) => a.targetPath.localeCompare(b.targetPath));
293
+ }
294
+
295
+ export async function planToolRulesSync(args: {
296
+ homeDir: string;
297
+ rootDir: string;
298
+ tool: string;
299
+ rulesDir: string;
300
+ previouslyManaged?: boolean;
301
+ }): Promise<RulesPlan> {
302
+ const rules = await listToolRules(args);
303
+ const contents = new Map<string, string>();
304
+
305
+ for (const rule of rules) {
306
+ const targetPath = join(args.rulesDir, rule.targetPath);
307
+ const raw = await Bun.file(rule.sourcePath).text();
308
+ const rendered = await renderCanonicalText(raw, {
309
+ homeDir: args.homeDir,
310
+ rootDir: args.rootDir,
311
+ targetTool: args.tool,
312
+ targetPath,
313
+ });
314
+ contents.set(targetPath, rendered);
315
+ }
316
+
317
+ const write: string[] = [];
318
+ for (const [targetPath, desired] of contents.entries()) {
319
+ const current = await readTextIfExists(targetPath);
320
+ if (current !== desired) {
321
+ write.push(targetPath);
322
+ }
323
+ }
324
+
325
+ const remove: string[] = [];
326
+ if (args.previouslyManaged) {
327
+ const existing = await readdir(args.rulesDir, {
328
+ withFileTypes: true,
329
+ }).catch(() => [] as import("node:fs").Dirent[]);
330
+ for (const entry of existing) {
331
+ if (!(entry.isFile() && entry.name.endsWith(".rules"))) {
332
+ continue;
333
+ }
334
+ const existingPath = join(args.rulesDir, entry.name);
335
+ if (!contents.has(existingPath)) {
336
+ remove.push(existingPath);
337
+ }
338
+ }
339
+ }
340
+
341
+ return {
342
+ write: write.sort(),
343
+ remove: remove.sort(),
344
+ contents,
345
+ managedRulesDir: rules.length > 0,
346
+ };
347
+ }
348
+
349
+ export async function syncToolRules(args: {
350
+ homeDir: string;
351
+ rootDir: string;
352
+ tool: string;
353
+ rulesDir: string;
354
+ previouslyManaged?: boolean;
355
+ dryRun?: boolean;
356
+ }): Promise<RulesPlan> {
357
+ const plan = await planToolRulesSync(args);
358
+ if (args.dryRun) {
359
+ return plan;
360
+ }
361
+
362
+ for (const pathValue of plan.remove) {
363
+ await rm(pathValue, { force: true });
364
+ }
365
+ for (const pathValue of plan.write) {
366
+ const desired = plan.contents.get(pathValue);
367
+ if (desired != null) {
368
+ await mkdir(dirname(pathValue), { recursive: true });
369
+ await Bun.write(
370
+ pathValue,
371
+ desired.endsWith("\n") ? desired : `${desired}\n`
372
+ );
373
+ }
374
+ }
375
+ return plan;
376
+ }
377
+
378
+ export async function planToolConfigSync(args: {
379
+ homeDir: string;
380
+ rootDir: string;
381
+ tool: string;
382
+ toolConfigPath: string;
383
+ existingConfigPath?: string;
384
+ previouslyManaged?: boolean;
385
+ }): Promise<ToolConfigPlan> {
386
+ const sourcePath = join(args.rootDir, "tools", args.tool, "config.toml");
387
+ if (!(await fileExists(sourcePath))) {
388
+ return {
389
+ targetPath: args.toolConfigPath,
390
+ write: false,
391
+ remove: false,
392
+ contents: null,
393
+ managedConfig: false,
394
+ };
395
+ }
396
+
397
+ const rendered = await renderSourceTarget({
398
+ homeDir: args.homeDir,
399
+ rootDir: args.rootDir,
400
+ sourcePath,
401
+ targetPath: args.toolConfigPath,
402
+ tool: args.tool,
403
+ });
404
+ const canonicalConfig = Bun.TOML.parse(rendered);
405
+ const existingConfig =
406
+ (await readTomlFile(args.toolConfigPath)) ??
407
+ (args.existingConfigPath
408
+ ? await readTomlFile(args.existingConfigPath)
409
+ : null) ??
410
+ ({} as Record<string, unknown>);
411
+ const merged = mergeTomlObjects(
412
+ existingConfig,
413
+ isPlainObject(canonicalConfig) ? canonicalConfig : {}
414
+ );
415
+ const nextContents = stringifyTomlObject(merged);
416
+ const current = await readTextIfExists(args.toolConfigPath);
417
+ return {
418
+ targetPath: args.toolConfigPath,
419
+ write: current !== `${nextContents}\n`,
420
+ remove: false,
421
+ contents: nextContents,
422
+ managedConfig: true,
423
+ };
424
+ }
425
+
426
+ export async function syncToolConfig(args: {
427
+ homeDir: string;
428
+ rootDir: string;
429
+ tool: string;
430
+ toolConfigPath: string;
431
+ existingConfigPath?: string;
432
+ previouslyManaged?: boolean;
433
+ dryRun?: boolean;
434
+ }): Promise<ToolConfigPlan> {
435
+ const plan = await planToolConfigSync({
436
+ homeDir: args.homeDir,
437
+ rootDir: args.rootDir,
438
+ tool: args.tool,
439
+ toolConfigPath: args.toolConfigPath,
440
+ existingConfigPath: args.existingConfigPath,
441
+ previouslyManaged: args.previouslyManaged,
442
+ });
443
+ if (args.dryRun) {
444
+ return plan;
445
+ }
446
+
447
+ if (plan.remove) {
448
+ await rm(plan.targetPath, { force: true });
449
+ return plan;
450
+ }
451
+
452
+ if (plan.write && plan.contents != null) {
453
+ await mkdir(dirname(plan.targetPath), { recursive: true });
454
+ await Bun.write(
455
+ plan.targetPath,
456
+ plan.contents.endsWith("\n") ? plan.contents : `${plan.contents}\n`
457
+ );
458
+ }
459
+
460
+ return plan;
461
+ }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readdir } from "node:fs/promises";
2
- import { basename, join, relative } from "node:path";
3
- import { facultRootDir } from "./paths";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ import { facultAiIndexPath, facultRootDir } from "./paths";
4
4
  import { lastModified } from "./util/skills";
5
5
 
6
6
  export interface SkillEntry {
@@ -472,6 +472,8 @@ export async function buildIndex(opts?: {
472
472
  force?: boolean;
473
473
  /** Override the default canonical root dir (useful for tests). */
474
474
  rootDir?: string;
475
+ /** Override home directory for generated state placement (useful for tests). */
476
+ homeDir?: string;
475
477
  }): Promise<{ index: FacultIndex; outputPath: string }> {
476
478
  const force = Boolean(opts?.force);
477
479
 
@@ -485,7 +487,7 @@ export async function buildIndex(opts?: {
485
487
  ? serversJsonPath
486
488
  : mcpJsonPath;
487
489
 
488
- const outputPath = join(rootDir, "index.json");
490
+ const outputPath = facultAiIndexPath(opts?.homeDir);
489
491
 
490
492
  let previousIndex: Record<string, unknown> | null = null;
491
493
  if (!force) {
@@ -538,7 +540,7 @@ export async function buildIndex(opts?: {
538
540
  snippets,
539
541
  };
540
542
 
541
- await mkdir(rootDir, { recursive: true });
543
+ await mkdir(dirname(outputPath), { recursive: true });
542
544
  await Bun.write(outputPath, `${JSON.stringify(index, null, 2)}\n`);
543
545
 
544
546
  return { index, outputPath };
@@ -546,7 +548,7 @@ export async function buildIndex(opts?: {
546
548
 
547
549
  export async function indexCommand(argv: string[]) {
548
550
  if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
549
- console.log(`facult index — rebuild index.json under the canonical store
551
+ console.log(`facult index — rebuild the generated index for the canonical store
550
552
 
551
553
  Usage:
552
554
  facult index [--force]