facult 2.7.4 → 2.8.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.
@@ -0,0 +1,886 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { extractServersObject } from "./mcp-config";
4
+ import { facultRootDir, readFacultConfig } from "./paths";
5
+ import type { AssetFile, McpConfig, ScanResult, SourceResult } from "./scan";
6
+ import { scan } from "./scan";
7
+ import { parseJsonLenient } from "./util/json";
8
+ import { computeSkillOccurrences } from "./util/skills";
9
+
10
+ export interface InventoryAuthSummary {
11
+ state: "none" | "env" | "inline-secret" | "external";
12
+ envKeys: string[];
13
+ envRefs: string[];
14
+ inlineSecretKeys: string[];
15
+ hasInlineSecrets: boolean;
16
+ notes: string[];
17
+ }
18
+
19
+ export interface InventoryMcpServer {
20
+ name: string;
21
+ sourceId: string;
22
+ sourceName: string;
23
+ configPath: string;
24
+ configFormat: McpConfig["format"];
25
+ transport?: string;
26
+ command?: string;
27
+ args?: string[];
28
+ url?: string;
29
+ auth: InventoryAuthSummary;
30
+ definition: unknown;
31
+ }
32
+
33
+ export interface InventoryMcpCapability {
34
+ name: string;
35
+ occurrences: number;
36
+ sourceIds: string[];
37
+ sourceNames: string[];
38
+ configPaths: string[];
39
+ variants: number;
40
+ authStates: InventoryAuthSummary["state"][];
41
+ hasInlineSecrets: boolean;
42
+ preferred: InventoryMcpServer;
43
+ }
44
+
45
+ export interface InventorySkill {
46
+ name: string;
47
+ path: string;
48
+ sourceIds: string[];
49
+ sourceNames: string[];
50
+ occurrences: number;
51
+ }
52
+
53
+ export interface InventoryInstruction {
54
+ kind: string;
55
+ path: string;
56
+ sourceId: string;
57
+ sourceName: string;
58
+ format: AssetFile["format"];
59
+ summary?: Record<string, unknown>;
60
+ }
61
+
62
+ export interface InventorySource {
63
+ id: string;
64
+ name: string;
65
+ found: boolean;
66
+ roots: string[];
67
+ evidence: string[];
68
+ warnings?: string[];
69
+ truncated?: boolean;
70
+ }
71
+
72
+ export interface AgentInventory {
73
+ version: 1;
74
+ generatedAt: string;
75
+ cwd: string;
76
+ canonicalRoot: string;
77
+ scanFrom: string[];
78
+ sources: InventorySource[];
79
+ mcpCapabilities: InventoryMcpCapability[];
80
+ mcpServers: InventoryMcpServer[];
81
+ skills: InventorySkill[];
82
+ instructions: InventoryInstruction[];
83
+ summary: {
84
+ sourceCount: number;
85
+ mcpCapabilityCount: number;
86
+ mcpServerCount: number;
87
+ skillCount: number;
88
+ instructionCount: number;
89
+ warningCount: number;
90
+ truncatedSourceCount: number;
91
+ };
92
+ }
93
+
94
+ interface InventoryOptions {
95
+ cwd?: string;
96
+ homeDir?: string;
97
+ from?: string[];
98
+ includeConfigFrom?: boolean;
99
+ includeGitHooks?: boolean;
100
+ showSecrets?: boolean;
101
+ sourceMode?: "machine" | "global" | "project";
102
+ tool?: string;
103
+ }
104
+
105
+ const SECRET_KEY_RE = /(TOKEN|KEY|SECRET|PASSWORD|PASS|BEARER|AUTH)/i;
106
+ const ENV_REF_RE = /^\$?\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/;
107
+ const SECRETY_STRING_RE =
108
+ /\b(sk-[A-Za-z0-9]{10,}|ghp_[A-Za-z0-9]{10,}|github_pat_[A-Za-z0-9_]{10,})\b/g;
109
+ const SECRET_ASSIGNMENT_RE =
110
+ /\b([A-Za-z0-9_-]*(?:TOKEN|KEY|SECRET|PASSWORD|PASS|BEARER|AUTH)[A-Za-z0-9_-]*)\s*=\s*("([^"]*)"|'([^']*)'|[^\s"'&]+)/gi;
111
+ const SECRET_URL_PARAM_RE =
112
+ /([?&][A-Za-z0-9_.-]*(?:token|key|secret|password|pass|bearer|auth)[A-Za-z0-9_.-]*=)([^&#\s"']+)/gi;
113
+ const BEARER_RE = /\bBearer\s+([A-Za-z0-9._~+/=-]{10,})\b/gi;
114
+ const URL_QUERY_KEY_PREFIX_RE = /^[?&]/;
115
+ const TRAILING_EQUALS_RE = /=$/;
116
+
117
+ function isPlaceholderSecretValue(value: string): boolean {
118
+ const trimmed = value.trim();
119
+ return (
120
+ !trimmed ||
121
+ trimmed === "<redacted>" ||
122
+ trimmed === "<set-me>" ||
123
+ extractEnvRef(trimmed) !== null
124
+ );
125
+ }
126
+
127
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
128
+ return !!value && typeof value === "object" && !Array.isArray(value);
129
+ }
130
+
131
+ function redactPossibleSecrets(value: string): string {
132
+ return value
133
+ .replace(SECRET_ASSIGNMENT_RE, (_match, key: string, rawValue: string) => {
134
+ const quote =
135
+ rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : "";
136
+ return `${key}=${quote}<redacted>${quote}`;
137
+ })
138
+ .replace(SECRET_URL_PARAM_RE, "$1<redacted>")
139
+ .replace(BEARER_RE, "Bearer <redacted>")
140
+ .replace(SECRETY_STRING_RE, "<redacted>");
141
+ }
142
+
143
+ function sanitizeDefinition(value: unknown): unknown {
144
+ if (typeof value === "string") {
145
+ return redactPossibleSecrets(value);
146
+ }
147
+ if (Array.isArray(value)) {
148
+ return value.map(sanitizeDefinition);
149
+ }
150
+ if (!isPlainObject(value)) {
151
+ return value;
152
+ }
153
+
154
+ const out: Record<string, unknown> = {};
155
+ for (const [key, inner] of Object.entries(value)) {
156
+ if (SECRET_KEY_RE.test(key)) {
157
+ out[key] = "<redacted>";
158
+ continue;
159
+ }
160
+ out[key] = sanitizeDefinition(inner);
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function stringArray(value: unknown): string[] | undefined {
166
+ if (!Array.isArray(value)) {
167
+ return undefined;
168
+ }
169
+ return value.map((entry) => String(entry));
170
+ }
171
+
172
+ function safeStringArray(value: unknown, opts: { showSecrets: boolean }) {
173
+ const values = stringArray(value);
174
+ if (!values) {
175
+ return undefined;
176
+ }
177
+ return opts.showSecrets ? values : values.map(redactPossibleSecrets);
178
+ }
179
+
180
+ function extractEnvRef(value: string): string | null {
181
+ const trimmed = value.trim();
182
+ if (!(trimmed.startsWith("$") || trimmed.startsWith("${"))) {
183
+ return null;
184
+ }
185
+ const match = ENV_REF_RE.exec(trimmed);
186
+ return match?.[1] ?? null;
187
+ }
188
+
189
+ function addStringSecretFindings(args: {
190
+ value: string;
191
+ location: string;
192
+ inlineSecretKeys: Set<string>;
193
+ }) {
194
+ const { value, location, inlineSecretKeys } = args;
195
+ for (const match of value.matchAll(SECRET_ASSIGNMENT_RE)) {
196
+ const key = match[1]?.trim();
197
+ const rawValue = (match[3] ?? match[4] ?? match[2] ?? "").trim();
198
+ if (key && !isPlaceholderSecretValue(rawValue)) {
199
+ inlineSecretKeys.add(`${location}:${key}`);
200
+ }
201
+ }
202
+
203
+ for (const match of value.matchAll(SECRET_URL_PARAM_RE)) {
204
+ const rawKey = match[1] ?? "";
205
+ const rawValue = match[2] ?? "";
206
+ const key = rawKey
207
+ .replace(URL_QUERY_KEY_PREFIX_RE, "")
208
+ .replace(TRAILING_EQUALS_RE, "");
209
+ if (key && !isPlaceholderSecretValue(rawValue)) {
210
+ inlineSecretKeys.add(`${location}:${key}`);
211
+ }
212
+ }
213
+
214
+ if (BEARER_RE.test(value) || SECRETY_STRING_RE.test(value)) {
215
+ inlineSecretKeys.add(location);
216
+ }
217
+ BEARER_RE.lastIndex = 0;
218
+ SECRETY_STRING_RE.lastIndex = 0;
219
+ }
220
+
221
+ function summarizeAuth(definition: unknown): InventoryAuthSummary {
222
+ const envKeys = new Set<string>();
223
+ const envRefs = new Set<string>();
224
+ const inlineSecretKeys = new Set<string>();
225
+ const notes: string[] = [];
226
+
227
+ if (!isPlainObject(definition)) {
228
+ return {
229
+ state: "none",
230
+ envKeys: [],
231
+ envRefs: [],
232
+ inlineSecretKeys: [],
233
+ hasInlineSecrets: false,
234
+ notes,
235
+ };
236
+ }
237
+
238
+ const env = definition.env;
239
+ if (isPlainObject(env)) {
240
+ for (const [key, value] of Object.entries(env)) {
241
+ envKeys.add(key);
242
+ if (typeof value !== "string") {
243
+ continue;
244
+ }
245
+ const ref = extractEnvRef(value);
246
+ if (ref) {
247
+ envRefs.add(ref);
248
+ continue;
249
+ }
250
+ if (SECRET_KEY_RE.test(key) && !isPlaceholderSecretValue(value)) {
251
+ inlineSecretKeys.add(key);
252
+ }
253
+ }
254
+ }
255
+
256
+ const inspectValue = (value: unknown, location: string) => {
257
+ if (typeof value === "string") {
258
+ addStringSecretFindings({ value, location, inlineSecretKeys });
259
+ return;
260
+ }
261
+ if (Array.isArray(value)) {
262
+ for (const [index, entry] of value.entries()) {
263
+ inspectValue(entry, `${location}[${index}]`);
264
+ }
265
+ return;
266
+ }
267
+ if (!isPlainObject(value)) {
268
+ return;
269
+ }
270
+ for (const [key, inner] of Object.entries(value)) {
271
+ const childLocation = location ? `${location}.${key}` : key;
272
+ if (typeof inner === "string") {
273
+ const ref = extractEnvRef(inner);
274
+ if (ref) {
275
+ envRefs.add(ref);
276
+ } else if (
277
+ SECRET_KEY_RE.test(key) &&
278
+ !isPlaceholderSecretValue(inner)
279
+ ) {
280
+ inlineSecretKeys.add(childLocation);
281
+ }
282
+ }
283
+ inspectValue(inner, childLocation);
284
+ }
285
+ };
286
+ inspectValue(definition, "");
287
+
288
+ const command =
289
+ typeof definition.command === "string" ? definition.command : "";
290
+ if (envKeys.size === 0 && command && inlineSecretKeys.size === 0) {
291
+ notes.push(
292
+ "No explicit MCP env auth found; server may rely on external CLI/session auth."
293
+ );
294
+ }
295
+
296
+ const uniqueEnvKeys = [...envKeys].sort();
297
+ const uniqueEnvRefs = [...envRefs].sort();
298
+ const uniqueInlineSecretKeys = [...inlineSecretKeys].sort();
299
+ const hasInlineSecrets = uniqueInlineSecretKeys.length > 0;
300
+ const state = hasInlineSecrets
301
+ ? "inline-secret"
302
+ : uniqueEnvKeys.length || uniqueEnvRefs.length
303
+ ? "env"
304
+ : command
305
+ ? "external"
306
+ : "none";
307
+
308
+ return {
309
+ state,
310
+ envKeys: uniqueEnvKeys,
311
+ envRefs: uniqueEnvRefs,
312
+ inlineSecretKeys: uniqueInlineSecretKeys,
313
+ hasInlineSecrets,
314
+ notes,
315
+ };
316
+ }
317
+
318
+ async function loadMcpServerDefinitions(
319
+ config: McpConfig
320
+ ): Promise<Record<string, unknown>> {
321
+ try {
322
+ const raw = await Bun.file(config.path).text();
323
+ if (config.format === "toml") {
324
+ const parsed = Bun.TOML.parse(raw) as Record<string, unknown>;
325
+ const servers = parsed.mcp_servers;
326
+ return isPlainObject(servers) ? servers : {};
327
+ }
328
+ const parsed = parseJsonLenient(raw);
329
+ return extractServersObject(parsed) ?? {};
330
+ } catch {
331
+ return {};
332
+ }
333
+ }
334
+
335
+ async function inventoryMcpServers(
336
+ result: ScanResult,
337
+ opts: { showSecrets: boolean }
338
+ ): Promise<InventoryMcpServer[]> {
339
+ const out: InventoryMcpServer[] = [];
340
+ for (const source of result.sources) {
341
+ for (const config of source.mcp.configs) {
342
+ const definitions = await loadMcpServerDefinitions(config);
343
+ for (const name of Object.keys(definitions).sort()) {
344
+ const rawDefinition = definitions[name];
345
+ const definition = opts.showSecrets
346
+ ? rawDefinition
347
+ : sanitizeDefinition(rawDefinition);
348
+ const obj = isPlainObject(rawDefinition) ? rawDefinition : {};
349
+ out.push({
350
+ name,
351
+ sourceId: source.id,
352
+ sourceName: source.name,
353
+ configPath: config.path,
354
+ configFormat: config.format,
355
+ transport:
356
+ typeof obj.transport === "string" ? obj.transport : undefined,
357
+ command:
358
+ typeof obj.command === "string"
359
+ ? opts.showSecrets
360
+ ? obj.command
361
+ : redactPossibleSecrets(obj.command)
362
+ : undefined,
363
+ args: safeStringArray(obj.args, opts),
364
+ url:
365
+ typeof obj.url === "string"
366
+ ? opts.showSecrets
367
+ ? obj.url
368
+ : redactPossibleSecrets(obj.url)
369
+ : undefined,
370
+ auth: summarizeAuth(rawDefinition),
371
+ definition,
372
+ });
373
+ }
374
+ }
375
+ }
376
+ return out.sort(
377
+ (a, b) =>
378
+ a.name.localeCompare(b.name) ||
379
+ a.sourceId.localeCompare(b.sourceId) ||
380
+ a.configPath.localeCompare(b.configPath)
381
+ );
382
+ }
383
+
384
+ function inventorySourceRank(sourceId: string): number {
385
+ if (sourceId === "facult") {
386
+ return 0;
387
+ }
388
+ if (sourceId === "codex") {
389
+ return 1;
390
+ }
391
+ if (sourceId.endsWith("-project")) {
392
+ return 2;
393
+ }
394
+ if (sourceId === "claude" || sourceId === "factory") {
395
+ return 3;
396
+ }
397
+ if (sourceId.startsWith("from-")) {
398
+ return 9;
399
+ }
400
+ return 5;
401
+ }
402
+
403
+ function stableJson(value: unknown): string {
404
+ if (Array.isArray(value)) {
405
+ return `[${value.map(stableJson).join(",")}]`;
406
+ }
407
+ if (!isPlainObject(value)) {
408
+ return JSON.stringify(value);
409
+ }
410
+ return `{${Object.keys(value)
411
+ .sort()
412
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
413
+ .join(",")}}`;
414
+ }
415
+
416
+ function preferredMcpServer(servers: InventoryMcpServer[]): InventoryMcpServer {
417
+ return [...servers].sort((a, b) => {
418
+ const sourceDiff =
419
+ inventorySourceRank(a.sourceId) - inventorySourceRank(b.sourceId);
420
+ if (sourceDiff !== 0) {
421
+ return sourceDiff;
422
+ }
423
+ return a.configPath.localeCompare(b.configPath);
424
+ })[0]!;
425
+ }
426
+
427
+ function inventoryMcpCapabilities(
428
+ servers: InventoryMcpServer[]
429
+ ): InventoryMcpCapability[] {
430
+ const byName = new Map<string, InventoryMcpServer[]>();
431
+ for (const server of servers) {
432
+ byName.set(server.name, [...(byName.get(server.name) ?? []), server]);
433
+ }
434
+
435
+ return [...byName.entries()]
436
+ .map(([name, entries]) => ({
437
+ name,
438
+ occurrences: entries.length,
439
+ sourceIds: [...new Set(entries.map((entry) => entry.sourceId))].sort(),
440
+ sourceNames: [
441
+ ...new Set(entries.map((entry) => entry.sourceName)),
442
+ ].sort(),
443
+ configPaths: [
444
+ ...new Set(entries.map((entry) => entry.configPath)),
445
+ ].sort(),
446
+ variants: new Set(entries.map((entry) => stableJson(entry.definition)))
447
+ .size,
448
+ authStates: [...new Set(entries.map((entry) => entry.auth.state))].sort(),
449
+ hasInlineSecrets: entries.some((entry) => entry.auth.hasInlineSecrets),
450
+ preferred: preferredMcpServer(entries),
451
+ }))
452
+ .sort((a, b) => a.name.localeCompare(b.name));
453
+ }
454
+
455
+ function sourceMatchesInventoryOptions(
456
+ sourceId: string,
457
+ opts: Pick<InventoryOptions, "sourceMode" | "tool">
458
+ ): boolean {
459
+ if (opts.tool) {
460
+ return sourceId === opts.tool || sourceId === `${opts.tool}-project`;
461
+ }
462
+ if (opts.sourceMode === "global") {
463
+ return !(sourceId.endsWith("-project") || sourceId.startsWith("from-"));
464
+ }
465
+ if (opts.sourceMode === "project") {
466
+ return sourceId.endsWith("-project") || sourceId.startsWith("from-");
467
+ }
468
+ return true;
469
+ }
470
+
471
+ function filterInventoryBySource(
472
+ inventory: AgentInventory,
473
+ opts: Pick<InventoryOptions, "sourceMode" | "tool">
474
+ ): AgentInventory {
475
+ if (!((opts.sourceMode && opts.sourceMode !== "machine") || opts.tool)) {
476
+ return inventory;
477
+ }
478
+
479
+ const sources = inventory.sources.filter((source) =>
480
+ sourceMatchesInventoryOptions(source.id, opts)
481
+ );
482
+ const sourceIds = new Set(sources.map((source) => source.id));
483
+ const mcpServers = inventory.mcpServers.filter((server) =>
484
+ sourceIds.has(server.sourceId)
485
+ );
486
+ const skills = inventory.skills
487
+ .map((skill) => {
488
+ const keptSourceIds = skill.sourceIds.filter((id) => sourceIds.has(id));
489
+ if (keptSourceIds.length === 0) {
490
+ return null;
491
+ }
492
+ return {
493
+ ...skill,
494
+ sourceIds: keptSourceIds,
495
+ sourceNames: skill.sourceNames.filter((name) =>
496
+ sources.some((source) => source.name === name)
497
+ ),
498
+ occurrences: keptSourceIds.length,
499
+ };
500
+ })
501
+ .filter((skill): skill is InventorySkill => skill !== null);
502
+ const instructions = inventory.instructions.filter((instruction) =>
503
+ sourceIds.has(instruction.sourceId)
504
+ );
505
+ const mcpCapabilities = inventoryMcpCapabilities(mcpServers);
506
+ const warningCount = sources.reduce(
507
+ (count, source) => count + (source.warnings?.length ?? 0),
508
+ 0
509
+ );
510
+
511
+ return {
512
+ ...inventory,
513
+ sources,
514
+ mcpCapabilities,
515
+ mcpServers,
516
+ skills,
517
+ instructions,
518
+ summary: {
519
+ sourceCount: sources.filter((source) => source.found).length,
520
+ mcpCapabilityCount: mcpCapabilities.length,
521
+ mcpServerCount: mcpServers.length,
522
+ skillCount: skills.length,
523
+ instructionCount: instructions.length,
524
+ warningCount,
525
+ truncatedSourceCount: sources.filter((source) => source.truncated).length,
526
+ },
527
+ };
528
+ }
529
+
530
+ function inventorySkills(result: ScanResult): InventorySkill[] {
531
+ const occurrences = computeSkillOccurrences(result);
532
+ return occurrences
533
+ .map((entry) => {
534
+ const sourceIds = new Set<string>();
535
+ const sourceNames = new Set<string>();
536
+ for (const location of entry.locations) {
537
+ const i = location.indexOf(":");
538
+ if (i <= 0) {
539
+ continue;
540
+ }
541
+ const sourceId = location.slice(0, i);
542
+ sourceIds.add(sourceId);
543
+ const source = result.sources.find(
544
+ (candidate) => candidate.id === sourceId
545
+ );
546
+ if (source) {
547
+ sourceNames.add(source.name);
548
+ }
549
+ }
550
+ return {
551
+ name: entry.name,
552
+ path:
553
+ entry.locations[0]?.slice(entry.locations[0].indexOf(":") + 1) ?? "",
554
+ sourceIds: [...sourceIds].sort(),
555
+ sourceNames: [...sourceNames].sort(),
556
+ occurrences: entry.count,
557
+ };
558
+ })
559
+ .sort(
560
+ (a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)
561
+ );
562
+ }
563
+
564
+ function isInstructionAsset(kind: string): boolean {
565
+ return (
566
+ kind.includes("instruction") ||
567
+ kind.includes("rule") ||
568
+ kind === "claude-settings" ||
569
+ kind === "cursor-hook"
570
+ );
571
+ }
572
+
573
+ async function listMarkdownFiles(root: string): Promise<string[]> {
574
+ try {
575
+ const stat = await Bun.file(root).stat();
576
+ if (!stat.isDirectory()) {
577
+ return [];
578
+ }
579
+ } catch {
580
+ return [];
581
+ }
582
+
583
+ const out: string[] = [];
584
+ const glob = new Bun.Glob("**/*.md");
585
+ for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
586
+ out.push(join(root, rel));
587
+ }
588
+ return out.sort();
589
+ }
590
+
591
+ async function canonicalInstructionAssets(
592
+ source: SourceResult
593
+ ): Promise<InventoryInstruction[]> {
594
+ const out: InventoryInstruction[] = [];
595
+ for (const root of source.roots) {
596
+ const candidates = [
597
+ ...(await listMarkdownFiles(join(root, "instructions"))).map((path) => ({
598
+ kind: "canonical-instruction",
599
+ path,
600
+ })),
601
+ { kind: "agents-instructions", path: join(root, "AGENTS.global.md") },
602
+ {
603
+ kind: "agents-instructions",
604
+ path: join(root, "AGENTS.override.global.md"),
605
+ },
606
+ ];
607
+ for (const candidate of candidates) {
608
+ try {
609
+ const stat = await Bun.file(candidate.path).stat();
610
+ if (!stat.isFile()) {
611
+ continue;
612
+ }
613
+ } catch {
614
+ continue;
615
+ }
616
+ out.push({
617
+ kind: candidate.kind,
618
+ path: candidate.path,
619
+ sourceId: source.id,
620
+ sourceName: source.name,
621
+ format: "markdown",
622
+ });
623
+ }
624
+ }
625
+ return out;
626
+ }
627
+
628
+ async function inventoryInstructions(
629
+ result: ScanResult
630
+ ): Promise<InventoryInstruction[]> {
631
+ const out: InventoryInstruction[] = [];
632
+ for (const source of result.sources) {
633
+ for (const asset of source.assets.files) {
634
+ if (!isInstructionAsset(asset.kind)) {
635
+ continue;
636
+ }
637
+ out.push({
638
+ kind: asset.kind,
639
+ path: asset.path,
640
+ sourceId: source.id,
641
+ sourceName: source.name,
642
+ format: asset.format,
643
+ summary: asset.summary,
644
+ });
645
+ }
646
+ if (source.id === "facult" || source.id.endsWith("-project")) {
647
+ out.push(...(await canonicalInstructionAssets(source)));
648
+ }
649
+ }
650
+
651
+ const seen = new Set<string>();
652
+ return out
653
+ .filter((entry) => {
654
+ const key = `${entry.kind}\0${entry.path}\0${entry.sourceId}`;
655
+ if (seen.has(key)) {
656
+ return false;
657
+ }
658
+ seen.add(key);
659
+ return true;
660
+ })
661
+ .sort(
662
+ (a, b) =>
663
+ a.kind.localeCompare(b.kind) ||
664
+ a.sourceId.localeCompare(b.sourceId) ||
665
+ a.path.localeCompare(b.path)
666
+ );
667
+ }
668
+
669
+ function configuredScanFrom(homeDir: string): string[] {
670
+ const config = readFacultConfig(homeDir);
671
+ return [...(config?.scanFrom ?? [])].sort();
672
+ }
673
+
674
+ export async function buildAgentInventory(
675
+ opts?: InventoryOptions
676
+ ): Promise<AgentInventory> {
677
+ const homeDir = opts?.homeDir ?? homedir();
678
+ const cwd = opts?.cwd ?? process.cwd();
679
+ const includeConfigFrom = opts?.includeConfigFrom ?? true;
680
+ const configuredFrom = includeConfigFrom ? configuredScanFrom(homeDir) : [];
681
+ const explicitFrom = opts?.from ?? [];
682
+ const effectiveFrom =
683
+ configuredFrom.length === 0 && explicitFrom.length === 0
684
+ ? opts?.sourceMode === "project"
685
+ ? [cwd]
686
+ : ["~"]
687
+ : explicitFrom;
688
+ const scanResult = await scan([], {
689
+ cwd,
690
+ homeDir,
691
+ includeConfigFrom,
692
+ includeGitHooks: opts?.includeGitHooks,
693
+ from: effectiveFrom,
694
+ });
695
+ const [mcpServers, skills, instructions] = await Promise.all([
696
+ inventoryMcpServers(scanResult, {
697
+ showSecrets: opts?.showSecrets ?? false,
698
+ }),
699
+ Promise.resolve(inventorySkills(scanResult)),
700
+ inventoryInstructions(scanResult),
701
+ ]);
702
+ const mcpCapabilities = inventoryMcpCapabilities(mcpServers);
703
+ const sources = scanResult.sources.map((source) => ({
704
+ id: source.id,
705
+ name: source.name,
706
+ found: source.found,
707
+ roots: source.roots,
708
+ evidence: source.evidence,
709
+ warnings: source.warnings,
710
+ truncated: source.truncated,
711
+ }));
712
+ const warningCount = sources.reduce(
713
+ (count, source) => count + (source.warnings?.length ?? 0),
714
+ 0
715
+ );
716
+
717
+ const inventory: AgentInventory = {
718
+ version: 1,
719
+ generatedAt: new Date().toISOString(),
720
+ cwd,
721
+ canonicalRoot: facultRootDir(homeDir),
722
+ scanFrom: [...new Set([...configuredFrom, ...effectiveFrom])].sort(),
723
+ sources,
724
+ mcpCapabilities,
725
+ mcpServers,
726
+ skills,
727
+ instructions,
728
+ summary: {
729
+ sourceCount: sources.filter((source) => source.found).length,
730
+ mcpCapabilityCount: mcpCapabilities.length,
731
+ mcpServerCount: mcpServers.length,
732
+ skillCount: skills.length,
733
+ instructionCount: instructions.length,
734
+ warningCount,
735
+ truncatedSourceCount: sources.filter((source) => source.truncated).length,
736
+ },
737
+ };
738
+ return filterInventoryBySource(inventory, {
739
+ sourceMode: opts?.sourceMode,
740
+ tool: opts?.tool,
741
+ });
742
+ }
743
+
744
+ function printHelp() {
745
+ console.log(`fclt inventory — machine-readable agent capability inventory
746
+
747
+ Usage:
748
+ fclt inventory --json
749
+ fclt inventory --from <path> --json
750
+
751
+ Options:
752
+ --json Print JSON. This command is JSON-first.
753
+ --from Add one or more scan roots (repeatable)
754
+ --show-secrets Include raw MCP definitions instead of redacted definitions
755
+ --include-git-hooks Include git hooks and Husky hooks in instruction assets
756
+ --no-config-from Disable scanFrom roots from ~/.ai/.facult/config.json
757
+ --global Show global/non-project sources only
758
+ --project Show project-local sources only
759
+ --tool <name> Show sources for one tool id, such as codex or claude
760
+ `);
761
+ }
762
+
763
+ export function parseInventoryArgs(argv: string[]): {
764
+ json: boolean;
765
+ showSecrets: boolean;
766
+ includeGitHooks: boolean;
767
+ includeConfigFrom: boolean;
768
+ sourceMode: "machine" | "global" | "project";
769
+ tool?: string;
770
+ from: string[];
771
+ } {
772
+ let json = false;
773
+ let showSecrets = false;
774
+ let includeGitHooks = false;
775
+ let includeConfigFrom = true;
776
+ let sourceMode: "machine" | "global" | "project" = "machine";
777
+ let tool: string | undefined;
778
+ const from: string[] = [];
779
+ for (let i = 0; i < argv.length; i += 1) {
780
+ const arg = argv[i];
781
+ if (!arg) {
782
+ continue;
783
+ }
784
+ if (arg === "--json") {
785
+ json = true;
786
+ continue;
787
+ }
788
+ if (arg === "--show-secrets") {
789
+ showSecrets = true;
790
+ continue;
791
+ }
792
+ if (arg === "--include-git-hooks") {
793
+ includeGitHooks = true;
794
+ continue;
795
+ }
796
+ if (arg === "--no-config-from") {
797
+ includeConfigFrom = false;
798
+ continue;
799
+ }
800
+ if (arg === "--global") {
801
+ if (sourceMode === "project") {
802
+ throw new Error("Conflicting scope flags");
803
+ }
804
+ sourceMode = "global";
805
+ continue;
806
+ }
807
+ if (arg === "--project") {
808
+ if (sourceMode === "global") {
809
+ throw new Error("Conflicting scope flags");
810
+ }
811
+ sourceMode = "project";
812
+ continue;
813
+ }
814
+ if (arg === "--tool") {
815
+ const next = argv[i + 1];
816
+ if (!next) {
817
+ throw new Error("--tool requires a name");
818
+ }
819
+ tool = next;
820
+ i += 1;
821
+ continue;
822
+ }
823
+ if (arg.startsWith("--tool=")) {
824
+ const value = arg.slice("--tool=".length);
825
+ if (!value) {
826
+ throw new Error("--tool requires a name");
827
+ }
828
+ tool = value;
829
+ continue;
830
+ }
831
+ if (arg === "--from") {
832
+ const next = argv[i + 1];
833
+ if (!next) {
834
+ throw new Error("--from requires a path");
835
+ }
836
+ from.push(next);
837
+ i += 1;
838
+ continue;
839
+ }
840
+ if (arg.startsWith("--from=")) {
841
+ const value = arg.slice("--from=".length);
842
+ if (!value) {
843
+ throw new Error("--from requires a path");
844
+ }
845
+ from.push(value);
846
+ continue;
847
+ }
848
+ throw new Error(`Unknown option: ${arg}`);
849
+ }
850
+ return {
851
+ json,
852
+ showSecrets,
853
+ includeGitHooks,
854
+ includeConfigFrom,
855
+ sourceMode,
856
+ tool,
857
+ from,
858
+ };
859
+ }
860
+
861
+ export async function inventoryCommand(argv: string[]) {
862
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
863
+ printHelp();
864
+ return;
865
+ }
866
+
867
+ let opts: ReturnType<typeof parseInventoryArgs>;
868
+ try {
869
+ opts = parseInventoryArgs(argv);
870
+ } catch (err) {
871
+ console.error(err instanceof Error ? err.message : String(err));
872
+ process.exitCode = 2;
873
+ return;
874
+ }
875
+
876
+ const inventory = await buildAgentInventory({
877
+ from: opts.from,
878
+ showSecrets: opts.showSecrets,
879
+ includeGitHooks: opts.includeGitHooks,
880
+ includeConfigFrom: opts.includeConfigFrom,
881
+ sourceMode: opts.sourceMode,
882
+ tool: opts.tool,
883
+ });
884
+
885
+ console.log(`${JSON.stringify(inventory, null, 2)}\n`);
886
+ }