facult 1.0.1
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/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/facult.cjs +302 -0
- package/package.json +78 -0
- package/src/adapters/claude-cli.ts +18 -0
- package/src/adapters/claude-desktop.ts +15 -0
- package/src/adapters/clawdbot.ts +18 -0
- package/src/adapters/codex.ts +19 -0
- package/src/adapters/cursor.ts +18 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.ts +270 -0
- package/src/adapters/reference.ts +9 -0
- package/src/adapters/skills.ts +47 -0
- package/src/adapters/types.ts +42 -0
- package/src/adapters/version.ts +18 -0
- package/src/audit/agent.ts +1071 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/static.ts +1130 -0
- package/src/audit/tui.ts +704 -0
- package/src/audit/types.ts +68 -0
- package/src/audit/update-index.ts +115 -0
- package/src/conflicts.ts +135 -0
- package/src/consolidate-conflict-action.ts +57 -0
- package/src/consolidate.ts +1637 -0
- package/src/enable-disable.ts +349 -0
- package/src/index-builder.ts +562 -0
- package/src/index.ts +589 -0
- package/src/manage.ts +894 -0
- package/src/migrate.ts +272 -0
- package/src/paths.ts +238 -0
- package/src/quarantine.ts +217 -0
- package/src/query.ts +186 -0
- package/src/remote-manifest-integrity.ts +367 -0
- package/src/remote-providers.ts +905 -0
- package/src/remote-source-policy.ts +237 -0
- package/src/remote-sources.ts +162 -0
- package/src/remote-types.ts +136 -0
- package/src/remote.ts +1970 -0
- package/src/scan.ts +2427 -0
- package/src/schema.ts +39 -0
- package/src/self-update.ts +408 -0
- package/src/snippets-cli.ts +293 -0
- package/src/snippets.ts +706 -0
- package/src/source-trust.ts +203 -0
- package/src/trust-list.ts +232 -0
- package/src/trust.ts +170 -0
- package/src/tui.ts +118 -0
- package/src/util/codex-toml.ts +126 -0
- package/src/util/json.ts +32 -0
- package/src/util/skills.ts +55 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
|
+
import { facultRootDir } from "./paths";
|
|
4
|
+
import { lastModified } from "./util/skills";
|
|
5
|
+
|
|
6
|
+
export interface SkillEntry {
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
description: string;
|
|
10
|
+
tags: string[];
|
|
11
|
+
lastModifiedAt?: string;
|
|
12
|
+
enabledFor?: string[];
|
|
13
|
+
trusted?: boolean;
|
|
14
|
+
trustedAt?: string;
|
|
15
|
+
trustedBy?: string;
|
|
16
|
+
auditStatus?: "pending" | "passed" | "flagged";
|
|
17
|
+
lastAuditAt?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface McpEntry {
|
|
21
|
+
name: string;
|
|
22
|
+
path: string;
|
|
23
|
+
lastModifiedAt?: string;
|
|
24
|
+
/** The raw server definition from servers.json (lossless). */
|
|
25
|
+
definition: unknown;
|
|
26
|
+
enabledFor?: string[];
|
|
27
|
+
trusted?: boolean;
|
|
28
|
+
trustedAt?: string;
|
|
29
|
+
trustedBy?: string;
|
|
30
|
+
auditStatus?: "pending" | "passed" | "flagged";
|
|
31
|
+
lastAuditAt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AgentEntry {
|
|
35
|
+
name: string;
|
|
36
|
+
path: string;
|
|
37
|
+
lastModifiedAt?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SnippetEntry {
|
|
41
|
+
name: string;
|
|
42
|
+
path: string;
|
|
43
|
+
lastModifiedAt?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FacultIndex {
|
|
47
|
+
version: number;
|
|
48
|
+
updatedAt: string;
|
|
49
|
+
skills: Record<string, SkillEntry>;
|
|
50
|
+
mcp: { servers: Record<string, McpEntry> };
|
|
51
|
+
agents: Record<string, AgentEntry>;
|
|
52
|
+
snippets: Record<string, SnippetEntry>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isSafePathString(p: string): boolean {
|
|
56
|
+
return !p.includes("\0");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
60
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseAuditStatus(
|
|
64
|
+
raw: unknown
|
|
65
|
+
): "pending" | "passed" | "flagged" | null {
|
|
66
|
+
if (typeof raw !== "string") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const v = raw.trim().toLowerCase();
|
|
70
|
+
if (v === "pending" || v === "passed" || v === "flagged") {
|
|
71
|
+
return v;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractIndexMeta(entry: unknown): {
|
|
77
|
+
enabledFor?: string[];
|
|
78
|
+
trusted?: boolean;
|
|
79
|
+
trustedAt?: string;
|
|
80
|
+
trustedBy?: string;
|
|
81
|
+
auditStatus?: "pending" | "passed" | "flagged";
|
|
82
|
+
lastAuditAt?: string;
|
|
83
|
+
} {
|
|
84
|
+
if (!isPlainObject(entry)) {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
const obj = entry as Record<string, unknown>;
|
|
88
|
+
const enabledFor = Array.isArray(obj.enabledFor)
|
|
89
|
+
? obj.enabledFor.map((t) => String(t))
|
|
90
|
+
: undefined;
|
|
91
|
+
const trusted = typeof obj.trusted === "boolean" ? obj.trusted : undefined;
|
|
92
|
+
const trustedAt =
|
|
93
|
+
typeof obj.trustedAt === "string" ? obj.trustedAt : undefined;
|
|
94
|
+
const trustedBy =
|
|
95
|
+
typeof obj.trustedBy === "string" ? obj.trustedBy : undefined;
|
|
96
|
+
const auditStatus = parseAuditStatus(obj.auditStatus) ?? undefined;
|
|
97
|
+
const lastAuditAt =
|
|
98
|
+
typeof obj.lastAuditAt === "string" ? obj.lastAuditAt : undefined;
|
|
99
|
+
return {
|
|
100
|
+
enabledFor,
|
|
101
|
+
trusted,
|
|
102
|
+
trustedAt,
|
|
103
|
+
trustedBy,
|
|
104
|
+
auditStatus,
|
|
105
|
+
lastAuditAt,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stripQuotes(s: string): string {
|
|
110
|
+
const t = s.trim();
|
|
111
|
+
if (
|
|
112
|
+
(t.startsWith('"') && t.endsWith('"')) ||
|
|
113
|
+
(t.startsWith("'") && t.endsWith("'"))
|
|
114
|
+
) {
|
|
115
|
+
return t.slice(1, -1);
|
|
116
|
+
}
|
|
117
|
+
return t;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const NEWLINE_RE = /\r?\n/;
|
|
121
|
+
const FRONTMATTER_KEY_RE = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/;
|
|
122
|
+
const FRONTMATTER_LIST_ITEM_RE = /^\s*-\s*(.+)$/;
|
|
123
|
+
|
|
124
|
+
function normalizeTags(tags: string[]): string[] {
|
|
125
|
+
return [...new Set(tags.map((t) => t.trim()).filter(Boolean))].sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function firstParagraphDescription(mdBody: string): string {
|
|
129
|
+
const lines = mdBody.split(NEWLINE_RE);
|
|
130
|
+
|
|
131
|
+
// Find first paragraph of non-empty lines, skipping headings.
|
|
132
|
+
const para: string[] = [];
|
|
133
|
+
for (const raw of lines) {
|
|
134
|
+
const line = raw.trim();
|
|
135
|
+
|
|
136
|
+
if (!line) {
|
|
137
|
+
if (para.length) {
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Skip headings and separators; they aren't a description.
|
|
144
|
+
if (line.startsWith("#") || line === "---") {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
para.push(line);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return para.join(" ").trim();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseTagsValue(value: string, followingLines: string[]): string[] {
|
|
155
|
+
const out: string[] = [];
|
|
156
|
+
const v = value.trim();
|
|
157
|
+
|
|
158
|
+
if (!v) {
|
|
159
|
+
// Parse list-style tags:
|
|
160
|
+
// tags:\n - a\n - b
|
|
161
|
+
for (const l of followingLines) {
|
|
162
|
+
const li = FRONTMATTER_LIST_ITEM_RE.exec(l);
|
|
163
|
+
if (!li) {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const tag = li[1];
|
|
167
|
+
if (tag) {
|
|
168
|
+
out.push(stripQuotes(tag));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Inline styles.
|
|
175
|
+
if (v.startsWith("[") && v.endsWith("]")) {
|
|
176
|
+
const inner = v.slice(1, -1);
|
|
177
|
+
for (const part of inner.split(",")) {
|
|
178
|
+
out.push(stripQuotes(part));
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Comma-separated or single.
|
|
184
|
+
if (v.includes(",")) {
|
|
185
|
+
for (const part of v.split(",")) {
|
|
186
|
+
out.push(stripQuotes(part));
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return [stripQuotes(v)];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function extractFrontmatterBlock(md: string): {
|
|
195
|
+
fmLines: string[];
|
|
196
|
+
body: string;
|
|
197
|
+
} | null {
|
|
198
|
+
if (!(md.startsWith("---\n") || md.startsWith("---\r\n"))) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const lines = md.split(NEWLINE_RE);
|
|
203
|
+
|
|
204
|
+
// lines[0] is ---
|
|
205
|
+
let i = 1;
|
|
206
|
+
const fmLines: string[] = [];
|
|
207
|
+
for (; i < lines.length; i++) {
|
|
208
|
+
const line = lines[i];
|
|
209
|
+
if (line === undefined) {
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (line === "---") {
|
|
213
|
+
i += 1;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
fmLines.push(line);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { fmLines, body: lines.slice(i).join("\n") };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function countListItems(lines: string[]): number {
|
|
223
|
+
let n = 0;
|
|
224
|
+
for (const l of lines) {
|
|
225
|
+
if (!FRONTMATTER_LIST_ITEM_RE.test(l)) {
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
n++;
|
|
229
|
+
}
|
|
230
|
+
return n;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseFrontmatter(md: string): {
|
|
234
|
+
description?: string;
|
|
235
|
+
tags: string[];
|
|
236
|
+
body: string;
|
|
237
|
+
} {
|
|
238
|
+
const block = extractFrontmatterBlock(md);
|
|
239
|
+
if (!block) {
|
|
240
|
+
return { tags: [], body: md };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const tags: string[] = [];
|
|
244
|
+
let description: string | undefined;
|
|
245
|
+
|
|
246
|
+
const fmLines = block.fmLines;
|
|
247
|
+
for (let j = 0; j < fmLines.length; j++) {
|
|
248
|
+
const line = fmLines[j];
|
|
249
|
+
if (line === undefined) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const m = FRONTMATTER_KEY_RE.exec(line);
|
|
253
|
+
if (!m) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const key = m[1];
|
|
258
|
+
const value = m[2] ?? "";
|
|
259
|
+
|
|
260
|
+
if (key === "description") {
|
|
261
|
+
description = stripQuotes(value);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (key === "tags") {
|
|
266
|
+
const rest = fmLines.slice(j + 1);
|
|
267
|
+
tags.push(...parseTagsValue(value, rest));
|
|
268
|
+
|
|
269
|
+
if (!value.trim()) {
|
|
270
|
+
j += countListItems(rest);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { description, tags, body: block.body };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function parseSkillMarkdown(md: string): {
|
|
279
|
+
description: string;
|
|
280
|
+
tags: string[];
|
|
281
|
+
} {
|
|
282
|
+
const fm = parseFrontmatter(md);
|
|
283
|
+
|
|
284
|
+
const description =
|
|
285
|
+
fm.description?.trim() || firstParagraphDescription(fm.body) || "";
|
|
286
|
+
|
|
287
|
+
return { description, tags: normalizeTags(fm.tags) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function statIsoTime(p: string): Promise<string | undefined> {
|
|
291
|
+
const lm = await lastModified(p);
|
|
292
|
+
return lm ? lm.toISOString() : undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function readJsonSafe(p: string): Promise<unknown> {
|
|
296
|
+
const txt = await Bun.file(p).text();
|
|
297
|
+
return JSON.parse(txt);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function listDirFiles(dir: string): Promise<string[]> {
|
|
301
|
+
try {
|
|
302
|
+
const ents = await readdir(dir, { withFileTypes: true });
|
|
303
|
+
return ents
|
|
304
|
+
.filter((e) => e.isFile())
|
|
305
|
+
.map((e) => join(dir, e.name))
|
|
306
|
+
.filter(isSafePathString)
|
|
307
|
+
.sort();
|
|
308
|
+
} catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function listSubdirs(dir: string): Promise<string[]> {
|
|
314
|
+
try {
|
|
315
|
+
const ents = await readdir(dir, { withFileTypes: true });
|
|
316
|
+
return ents
|
|
317
|
+
.filter((e) => e.isDirectory())
|
|
318
|
+
.map((e) => join(dir, e.name))
|
|
319
|
+
.filter(isSafePathString)
|
|
320
|
+
.sort();
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function indexSkills(
|
|
327
|
+
skillsDir: string,
|
|
328
|
+
previous?: Record<string, unknown>
|
|
329
|
+
): Promise<Record<string, SkillEntry>> {
|
|
330
|
+
const out: Record<string, SkillEntry> = {};
|
|
331
|
+
const dirs = await listSubdirs(skillsDir);
|
|
332
|
+
for (const d of dirs) {
|
|
333
|
+
const skillMd = join(d, "SKILL.md");
|
|
334
|
+
try {
|
|
335
|
+
const st = await Bun.file(skillMd).stat();
|
|
336
|
+
if (!st.isFile()) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const md = await Bun.file(skillMd).text();
|
|
341
|
+
const { description, tags } = parseSkillMarkdown(md);
|
|
342
|
+
const name = basename(d);
|
|
343
|
+
|
|
344
|
+
const prev = previous?.[name];
|
|
345
|
+
const meta = extractIndexMeta(prev);
|
|
346
|
+
|
|
347
|
+
out[name] = {
|
|
348
|
+
name,
|
|
349
|
+
path: d,
|
|
350
|
+
description,
|
|
351
|
+
tags,
|
|
352
|
+
lastModifiedAt: await statIsoTime(skillMd),
|
|
353
|
+
enabledFor: meta.enabledFor,
|
|
354
|
+
trusted: meta.trusted ?? false,
|
|
355
|
+
trustedAt: meta.trustedAt,
|
|
356
|
+
trustedBy: meta.trustedBy,
|
|
357
|
+
auditStatus: meta.auditStatus ?? "pending",
|
|
358
|
+
lastAuditAt: meta.lastAuditAt,
|
|
359
|
+
};
|
|
360
|
+
} catch {
|
|
361
|
+
// Ignore missing/invalid skill entries.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function indexMcpServers(
|
|
368
|
+
mcpConfigPath: string,
|
|
369
|
+
previous?: Record<string, unknown>
|
|
370
|
+
): Promise<Record<string, McpEntry>> {
|
|
371
|
+
const out: Record<string, McpEntry> = {};
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const st = await Bun.file(mcpConfigPath).stat();
|
|
375
|
+
if (!st.isFile()) {
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const data = (await readJsonSafe(mcpConfigPath)) as Record<
|
|
380
|
+
string,
|
|
381
|
+
unknown
|
|
382
|
+
> | null;
|
|
383
|
+
|
|
384
|
+
// Accept a few shapes:
|
|
385
|
+
// 1) { servers: { name: {...} } }
|
|
386
|
+
// 2) { mcp: { servers: {...} } }
|
|
387
|
+
// 3) { mcpServers: {...} }
|
|
388
|
+
const serversObj =
|
|
389
|
+
(data?.servers as Record<string, unknown> | undefined) ??
|
|
390
|
+
((data?.mcp as Record<string, unknown> | undefined)?.servers as
|
|
391
|
+
| Record<string, unknown>
|
|
392
|
+
| undefined) ??
|
|
393
|
+
(data?.mcpServers as Record<string, unknown> | undefined);
|
|
394
|
+
|
|
395
|
+
if (!serversObj || typeof serversObj !== "object") {
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const lm = await statIsoTime(mcpConfigPath);
|
|
400
|
+
for (const name of Object.keys(serversObj).sort()) {
|
|
401
|
+
const prev = previous?.[name];
|
|
402
|
+
const meta = extractIndexMeta(prev);
|
|
403
|
+
out[name] = {
|
|
404
|
+
name,
|
|
405
|
+
path: mcpConfigPath,
|
|
406
|
+
lastModifiedAt: lm,
|
|
407
|
+
definition: serversObj[name],
|
|
408
|
+
enabledFor: meta.enabledFor,
|
|
409
|
+
trusted: meta.trusted ?? false,
|
|
410
|
+
trustedAt: meta.trustedAt,
|
|
411
|
+
trustedBy: meta.trustedBy,
|
|
412
|
+
auditStatus: meta.auditStatus ?? "pending",
|
|
413
|
+
lastAuditAt: meta.lastAuditAt,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function indexAgents(
|
|
424
|
+
agentsDir: string
|
|
425
|
+
): Promise<Record<string, AgentEntry>> {
|
|
426
|
+
const out: Record<string, AgentEntry> = {};
|
|
427
|
+
const files = await listDirFiles(agentsDir);
|
|
428
|
+
for (const p of files) {
|
|
429
|
+
const name = basename(p);
|
|
430
|
+
out[name] = {
|
|
431
|
+
name,
|
|
432
|
+
path: p,
|
|
433
|
+
lastModifiedAt: await statIsoTime(p),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function indexSnippets(
|
|
440
|
+
snippetsDir: string
|
|
441
|
+
): Promise<Record<string, SnippetEntry>> {
|
|
442
|
+
const out: Record<string, SnippetEntry> = {};
|
|
443
|
+
try {
|
|
444
|
+
const st = await Bun.file(snippetsDir).stat();
|
|
445
|
+
if (!st.isDirectory()) {
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
} catch {
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
// Snippets live under snippets/global/** and snippets/projects/**.
|
|
452
|
+
// Index all files under snippets/ so names don't collide across scopes.
|
|
453
|
+
const glob = new Bun.Glob("**/*");
|
|
454
|
+
const files: string[] = [];
|
|
455
|
+
for await (const rel of glob.scan({ cwd: snippetsDir, onlyFiles: true })) {
|
|
456
|
+
files.push(join(snippetsDir, rel));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
for (const p of files.sort()) {
|
|
460
|
+
const rel = relative(snippetsDir, p);
|
|
461
|
+
const name = rel || basename(p);
|
|
462
|
+
out[name] = {
|
|
463
|
+
name,
|
|
464
|
+
path: p,
|
|
465
|
+
lastModifiedAt: await statIsoTime(p),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export async function buildIndex(opts?: {
|
|
472
|
+
force?: boolean;
|
|
473
|
+
/** Override the default canonical root dir (useful for tests). */
|
|
474
|
+
rootDir?: string;
|
|
475
|
+
}): Promise<{ index: FacultIndex; outputPath: string }> {
|
|
476
|
+
const force = Boolean(opts?.force);
|
|
477
|
+
|
|
478
|
+
const rootDir = opts?.rootDir ?? facultRootDir();
|
|
479
|
+
const skillsDir = join(rootDir, "skills");
|
|
480
|
+
const agentsDir = join(rootDir, "agents");
|
|
481
|
+
const snippetsDir = join(rootDir, "snippets");
|
|
482
|
+
const serversJsonPath = join(rootDir, "mcp", "servers.json");
|
|
483
|
+
const mcpJsonPath = join(rootDir, "mcp", "mcp.json");
|
|
484
|
+
const canonicalMcpPath = (await Bun.file(serversJsonPath).exists())
|
|
485
|
+
? serversJsonPath
|
|
486
|
+
: mcpJsonPath;
|
|
487
|
+
|
|
488
|
+
const outputPath = join(rootDir, "index.json");
|
|
489
|
+
|
|
490
|
+
let previousIndex: Record<string, unknown> | null = null;
|
|
491
|
+
if (!force) {
|
|
492
|
+
try {
|
|
493
|
+
const existing = Bun.file(outputPath);
|
|
494
|
+
if (await existing.exists()) {
|
|
495
|
+
previousIndex = JSON.parse(await existing.text()) as Record<
|
|
496
|
+
string,
|
|
497
|
+
unknown
|
|
498
|
+
>;
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
previousIndex = null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (force) {
|
|
506
|
+
try {
|
|
507
|
+
await Bun.write(outputPath, "");
|
|
508
|
+
} catch {
|
|
509
|
+
// ignore
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const prevSkills = isPlainObject(previousIndex?.skills)
|
|
514
|
+
? (previousIndex?.skills as Record<string, unknown>)
|
|
515
|
+
: undefined;
|
|
516
|
+
const prevMcpMap =
|
|
517
|
+
isPlainObject(previousIndex?.mcp) &&
|
|
518
|
+
isPlainObject((previousIndex.mcp as Record<string, unknown>).servers)
|
|
519
|
+
? ((previousIndex.mcp as Record<string, unknown>).servers as Record<
|
|
520
|
+
string,
|
|
521
|
+
unknown
|
|
522
|
+
>)
|
|
523
|
+
: undefined;
|
|
524
|
+
|
|
525
|
+
const [skills, servers, agents, snippets] = await Promise.all([
|
|
526
|
+
indexSkills(skillsDir, prevSkills),
|
|
527
|
+
indexMcpServers(canonicalMcpPath, prevMcpMap),
|
|
528
|
+
indexAgents(agentsDir),
|
|
529
|
+
indexSnippets(snippetsDir),
|
|
530
|
+
]);
|
|
531
|
+
|
|
532
|
+
const index: FacultIndex = {
|
|
533
|
+
version: 1,
|
|
534
|
+
updatedAt: new Date().toISOString(),
|
|
535
|
+
skills,
|
|
536
|
+
mcp: { servers },
|
|
537
|
+
agents,
|
|
538
|
+
snippets,
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
await mkdir(rootDir, { recursive: true });
|
|
542
|
+
await Bun.write(outputPath, `${JSON.stringify(index, null, 2)}\n`);
|
|
543
|
+
|
|
544
|
+
return { index, outputPath };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function indexCommand(argv: string[]) {
|
|
548
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
549
|
+
console.log(`facult index — rebuild index.json under the canonical store
|
|
550
|
+
|
|
551
|
+
Usage:
|
|
552
|
+
facult index [--force]
|
|
553
|
+
|
|
554
|
+
Options:
|
|
555
|
+
--force Rebuild index from scratch (ignore existing metadata)
|
|
556
|
+
`);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const force = argv.includes("--force");
|
|
560
|
+
const { outputPath } = await buildIndex({ force });
|
|
561
|
+
console.log(`Index written to ${outputPath}`);
|
|
562
|
+
}
|