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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/bin/facult.cjs +302 -0
  4. package/package.json +78 -0
  5. package/src/adapters/claude-cli.ts +18 -0
  6. package/src/adapters/claude-desktop.ts +15 -0
  7. package/src/adapters/clawdbot.ts +18 -0
  8. package/src/adapters/codex.ts +19 -0
  9. package/src/adapters/cursor.ts +18 -0
  10. package/src/adapters/index.ts +69 -0
  11. package/src/adapters/mcp.ts +270 -0
  12. package/src/adapters/reference.ts +9 -0
  13. package/src/adapters/skills.ts +47 -0
  14. package/src/adapters/types.ts +42 -0
  15. package/src/adapters/version.ts +18 -0
  16. package/src/audit/agent.ts +1071 -0
  17. package/src/audit/index.ts +74 -0
  18. package/src/audit/static.ts +1130 -0
  19. package/src/audit/tui.ts +704 -0
  20. package/src/audit/types.ts +68 -0
  21. package/src/audit/update-index.ts +115 -0
  22. package/src/conflicts.ts +135 -0
  23. package/src/consolidate-conflict-action.ts +57 -0
  24. package/src/consolidate.ts +1637 -0
  25. package/src/enable-disable.ts +349 -0
  26. package/src/index-builder.ts +562 -0
  27. package/src/index.ts +589 -0
  28. package/src/manage.ts +894 -0
  29. package/src/migrate.ts +272 -0
  30. package/src/paths.ts +238 -0
  31. package/src/quarantine.ts +217 -0
  32. package/src/query.ts +186 -0
  33. package/src/remote-manifest-integrity.ts +367 -0
  34. package/src/remote-providers.ts +905 -0
  35. package/src/remote-source-policy.ts +237 -0
  36. package/src/remote-sources.ts +162 -0
  37. package/src/remote-types.ts +136 -0
  38. package/src/remote.ts +1970 -0
  39. package/src/scan.ts +2427 -0
  40. package/src/schema.ts +39 -0
  41. package/src/self-update.ts +408 -0
  42. package/src/snippets-cli.ts +293 -0
  43. package/src/snippets.ts +706 -0
  44. package/src/source-trust.ts +203 -0
  45. package/src/trust-list.ts +232 -0
  46. package/src/trust.ts +170 -0
  47. package/src/tui.ts +118 -0
  48. package/src/util/codex-toml.ts +126 -0
  49. package/src/util/json.ts +32 -0
  50. package/src/util/skills.ts +55 -0
package/src/scan.ts ADDED
@@ -0,0 +1,2427 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve, sep } from "node:path";
5
+ import { facultRootDir, readFacultConfig } from "./paths";
6
+ import {
7
+ extractCodexTomlMcpServerBlocks,
8
+ extractCodexTomlMcpServerNames,
9
+ sanitizeCodexTomlMcpText,
10
+ } from "./util/codex-toml";
11
+ import { parseJsonLenient } from "./util/json";
12
+ import { computeSkillOccurrences } from "./util/skills";
13
+
14
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
15
+ return !!v && typeof v === "object" && !Array.isArray(v);
16
+ }
17
+
18
+ export interface ScanResult {
19
+ version: 6;
20
+ scannedAt: string;
21
+ cwd: string;
22
+ sources: SourceResult[];
23
+ }
24
+
25
+ export interface AssetFile {
26
+ kind: string;
27
+ path: string;
28
+ format: "json" | "markdown" | "shell" | "unknown";
29
+ error?: string;
30
+ /**
31
+ * Small, safe-to-store summary (avoid persisting raw configs).
32
+ * Intended for auditing + quick inspection.
33
+ */
34
+ summary?: Record<string, unknown>;
35
+ }
36
+
37
+ export interface SourceResult {
38
+ id: string;
39
+ name: string;
40
+ found: boolean;
41
+ roots: string[];
42
+ evidence: string[];
43
+ truncated?: boolean;
44
+ warnings?: string[];
45
+ assets: {
46
+ files: AssetFile[];
47
+ };
48
+ mcp: {
49
+ configs: McpConfig[];
50
+ };
51
+ skills: {
52
+ roots: string[];
53
+ entries: string[]; // skill directories (parent dirs of SKILL.md)
54
+ };
55
+ }
56
+
57
+ export interface McpConfig {
58
+ path: string;
59
+ format: "json" | "toml" | "unknown";
60
+ servers?: string[];
61
+ error?: string;
62
+ }
63
+
64
+ interface SourceSpec {
65
+ id: string;
66
+ name: string;
67
+ candidates: string[]; // files/dirs to check
68
+ skillDirs?: string[];
69
+ configFiles?: string[];
70
+ assets?: { kind: string; patterns: string[] }[];
71
+ }
72
+
73
+ const GLOB_CHARS_REGEX = /[*?[]/;
74
+ const SECRETY_STRING_RE =
75
+ /\b(sk-[A-Za-z0-9]{10,}|ghp_[A-Za-z0-9]{10,}|github_pat_[A-Za-z0-9_]{10,})\b/g;
76
+ const FIRST_LINE_SPLIT_RE = /\r?\n/;
77
+ const GITDIR_LINE_RE = /^gitdir:\s*(.+)\s*$/i;
78
+ const SECRET_ENV_KEY_RE = /(TOKEN|KEY|SECRET|PASSWORD|PASS|BEARER)/i;
79
+
80
+ function redactPossibleSecrets(value: string): string {
81
+ return value.replace(SECRETY_STRING_RE, "<redacted>");
82
+ }
83
+
84
+ function expandTilde(p: string, home: string): string {
85
+ if (p === "~") {
86
+ return home;
87
+ }
88
+ if (p.startsWith("~/")) {
89
+ return join(home, p.slice(2));
90
+ }
91
+ return p;
92
+ }
93
+
94
+ function hasGlobChars(p: string): boolean {
95
+ return GLOB_CHARS_REGEX.test(p);
96
+ }
97
+
98
+ function firstGlobIndex(p: string): number {
99
+ return p.search(GLOB_CHARS_REGEX);
100
+ }
101
+
102
+ function isSafePathString(p: string): boolean {
103
+ // Protect filesystem APIs from null-byte paths.
104
+ return !p.includes("\0");
105
+ }
106
+
107
+ function globBaseDir(absPattern: string): string {
108
+ const i = firstGlobIndex(absPattern);
109
+ if (i < 0) {
110
+ return dirname(absPattern);
111
+ }
112
+ // The non-glob prefix can end mid-segment (e.g. antigravity*), so stat the parent dir.
113
+ const prefix = absPattern.slice(0, i);
114
+ const dir = dirname(prefix);
115
+ return dir === "." ? "/" : dir;
116
+ }
117
+
118
+ async function expandPathPatterns(
119
+ patterns: string[],
120
+ home: string
121
+ ): Promise<string[]> {
122
+ const out: string[] = [];
123
+ for (const pat of patterns) {
124
+ const expanded = expandTilde(pat, home);
125
+ const abs = expanded.startsWith("/") ? expanded : resolve(expanded);
126
+
127
+ if (!isSafePathString(abs)) {
128
+ continue;
129
+ }
130
+
131
+ if (!hasGlobChars(abs)) {
132
+ out.push(abs);
133
+ continue;
134
+ }
135
+
136
+ const baseDir = globBaseDir(abs);
137
+ const baseSt = await statSafe(baseDir);
138
+ if (!baseSt?.isDir) {
139
+ continue;
140
+ }
141
+
142
+ try {
143
+ const glob = new Bun.Glob(abs);
144
+ for await (const m of glob.scan({ cwd: "/", onlyFiles: false })) {
145
+ if (isSafePathString(m)) {
146
+ out.push(m);
147
+ }
148
+ }
149
+ } catch {
150
+ // If the glob can't be scanned (e.g. missing base dir), treat as no matches.
151
+ }
152
+ }
153
+ return uniqueSorted(out);
154
+ }
155
+
156
+ async function statSafe(
157
+ p: string
158
+ ): Promise<{ isFile: boolean; isDir: boolean } | null> {
159
+ try {
160
+ const s = await Bun.file(p).stat();
161
+ return { isFile: s.isFile(), isDir: s.isDirectory() };
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ async function readJsonSafe(p: string): Promise<unknown> {
168
+ const f = Bun.file(p);
169
+ const txt = await f.text();
170
+ return parseJsonLenient(txt);
171
+ }
172
+
173
+ function uniqueSorted(xs: string[]): string[] {
174
+ return [...new Set(xs)].sort();
175
+ }
176
+
177
+ function isNodeModulesLikeDirName(name: string): boolean {
178
+ // Besides the canonical `node_modules/`, local container volume directories often embed it
179
+ // as a suffix (e.g. `project_node_modules`). Those trees are dependency content and noisy.
180
+ return name === "node_modules" || name.endsWith("_node_modules");
181
+ }
182
+
183
+ function pathHasNodeModulesLikeSegment(p: string): boolean {
184
+ return p.split(sep).some((seg) => isNodeModulesLikeDirName(seg));
185
+ }
186
+
187
+ async function listSkillEntries(skillRoot: string): Promise<string[]> {
188
+ const st = await statSafe(skillRoot);
189
+ if (!st?.isDir) {
190
+ return [];
191
+ }
192
+
193
+ // We treat any directory that contains a SKILL.md as a single skill entry.
194
+ // This prevents noisy output like package.json/README.md under skills.
195
+ const out: string[] = [];
196
+ const glob = new Bun.Glob("**/SKILL.md");
197
+ for await (const rel of glob.scan({ cwd: skillRoot, onlyFiles: true })) {
198
+ // Avoid scanning/including dependencies vendored under skills.
199
+ if (rel.split(sep).includes("node_modules")) {
200
+ continue;
201
+ }
202
+ out.push(join(skillRoot, dirname(rel)));
203
+ }
204
+
205
+ return uniqueSorted(out);
206
+ }
207
+
208
+ async function discoverMcpConfig(p: string): Promise<McpConfig | null> {
209
+ const st = await statSafe(p);
210
+ if (!st?.isFile) {
211
+ return null;
212
+ }
213
+
214
+ const cfg: McpConfig = { path: p, format: "unknown" };
215
+
216
+ if (p.endsWith(".json")) {
217
+ cfg.format = "json";
218
+ try {
219
+ const parsed = await readJsonSafe(p);
220
+ const serversObj = extractMcpServersObject(parsed);
221
+ if (serversObj) {
222
+ cfg.servers = uniqueSorted(Object.keys(serversObj));
223
+ }
224
+ } catch (e: unknown) {
225
+ const err = e as { message?: string } | null;
226
+ cfg.error = String(err?.message ?? e);
227
+ }
228
+ }
229
+
230
+ if (p.endsWith(".toml")) {
231
+ cfg.format = "toml";
232
+ try {
233
+ const txt = await Bun.file(p).text();
234
+ cfg.servers = extractCodexTomlMcpServerNames(txt);
235
+ } catch (e: unknown) {
236
+ const err = e as { message?: string } | null;
237
+ cfg.error = String(err?.message ?? e);
238
+ }
239
+ }
240
+
241
+ return cfg;
242
+ }
243
+
244
+ function detectAssetFormat(p: string): AssetFile["format"] {
245
+ if (p.endsWith(".json")) {
246
+ return "json";
247
+ }
248
+ if (p.endsWith(".md") || p.endsWith(".mdc")) {
249
+ return "markdown";
250
+ }
251
+ if (
252
+ p.endsWith(".sh") ||
253
+ p.endsWith(".bash") ||
254
+ p.endsWith(".zsh") ||
255
+ p.endsWith(".fish")
256
+ ) {
257
+ return "shell";
258
+ }
259
+ // Husky hooks often have no extension.
260
+ if (
261
+ p.includes(`${sep}.husky${sep}`) ||
262
+ p.includes(`${sep}.git${sep}hooks${sep}`)
263
+ ) {
264
+ return "shell";
265
+ }
266
+ return "unknown";
267
+ }
268
+
269
+ async function discoverAssetFile(p: string): Promise<AssetFile | null> {
270
+ const st = await statSafe(p);
271
+ if (!st?.isFile) {
272
+ return null;
273
+ }
274
+
275
+ const format = detectAssetFormat(p);
276
+ const asset: AssetFile = { kind: "unknown", path: p, format };
277
+
278
+ if (format === "json") {
279
+ try {
280
+ const parsed = await readJsonSafe(p);
281
+ // Summary is derived later once we know "kind" (see discoverAssetsFromSpecs).
282
+ // Avoid storing parsed content here to prevent persisting secrets.
283
+ asset.summary = isPlainObject(parsed)
284
+ ? { keys: Object.keys(parsed).sort().slice(0, 30) }
285
+ : undefined;
286
+ } catch (e: unknown) {
287
+ const err = e as { message?: string } | null;
288
+ asset.error = String(err?.message ?? e);
289
+ }
290
+ }
291
+
292
+ return asset;
293
+ }
294
+
295
+ function extractClaudeHooksSummary(parsed: unknown): {
296
+ hookEvents: string[];
297
+ hookCommands: string[];
298
+ hookTypes: string[];
299
+ } {
300
+ const hookEvents: string[] = [];
301
+ const hookCommands: string[] = [];
302
+ const hookTypes: string[] = [];
303
+
304
+ if (!isPlainObject(parsed)) {
305
+ return { hookEvents, hookCommands, hookTypes };
306
+ }
307
+
308
+ const hooks = (parsed as Record<string, unknown>).hooks;
309
+ if (!isPlainObject(hooks)) {
310
+ return { hookEvents, hookCommands, hookTypes };
311
+ }
312
+
313
+ for (const [event, rules] of Object.entries(hooks)) {
314
+ hookEvents.push(event);
315
+ if (!Array.isArray(rules)) {
316
+ continue;
317
+ }
318
+ for (const rule of rules) {
319
+ if (!isPlainObject(rule)) {
320
+ continue;
321
+ }
322
+ const inner = (rule as Record<string, unknown>).hooks;
323
+ if (!Array.isArray(inner)) {
324
+ continue;
325
+ }
326
+ for (const h of inner) {
327
+ if (!isPlainObject(h)) {
328
+ continue;
329
+ }
330
+ const type = (h as Record<string, unknown>).type;
331
+ if (typeof type === "string") {
332
+ hookTypes.push(type);
333
+ }
334
+ const cmd = (h as Record<string, unknown>).command;
335
+ if (typeof cmd === "string") {
336
+ hookCommands.push(redactPossibleSecrets(cmd));
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ return {
343
+ hookEvents: uniqueSorted(hookEvents),
344
+ hookCommands: uniqueSorted(hookCommands),
345
+ hookTypes: uniqueSorted(hookTypes),
346
+ };
347
+ }
348
+
349
+ function extractClaudePermissionsSummary(parsed: unknown): {
350
+ permissionsAllowCount: number;
351
+ permissionsAllowSample: string[];
352
+ permissionsAllowTruncated: boolean;
353
+ } {
354
+ const allow: string[] = [];
355
+ if (isPlainObject(parsed)) {
356
+ const perm = (parsed as Record<string, unknown>).permissions;
357
+ if (isPlainObject(perm)) {
358
+ const rawAllow = (perm as Record<string, unknown>).allow;
359
+ if (Array.isArray(rawAllow)) {
360
+ for (const v of rawAllow) {
361
+ if (typeof v === "string") {
362
+ allow.push(redactPossibleSecrets(v));
363
+ }
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ const unique = uniqueSorted(allow);
370
+ const sampleLimit = 25;
371
+ return {
372
+ permissionsAllowCount: unique.length,
373
+ permissionsAllowSample: unique.slice(0, sampleLimit),
374
+ permissionsAllowTruncated: unique.length > sampleLimit,
375
+ };
376
+ }
377
+
378
+ function extractCursorHooksSummary(parsed: unknown): {
379
+ hookEvents: string[];
380
+ hookCommands: string[];
381
+ } {
382
+ const hookEvents: string[] = [];
383
+ const hookCommands: string[] = [];
384
+ if (!isPlainObject(parsed)) {
385
+ return { hookEvents, hookCommands };
386
+ }
387
+
388
+ const hooks = (parsed as Record<string, unknown>).hooks;
389
+ if (!isPlainObject(hooks)) {
390
+ return { hookEvents, hookCommands };
391
+ }
392
+
393
+ for (const [event, entries] of Object.entries(hooks)) {
394
+ hookEvents.push(event);
395
+ if (!Array.isArray(entries)) {
396
+ continue;
397
+ }
398
+ for (const e of entries) {
399
+ if (!isPlainObject(e)) {
400
+ continue;
401
+ }
402
+ const cmd = (e as Record<string, unknown>).command;
403
+ if (typeof cmd === "string") {
404
+ hookCommands.push(redactPossibleSecrets(cmd));
405
+ }
406
+ }
407
+ }
408
+
409
+ return {
410
+ hookEvents: uniqueSorted(hookEvents),
411
+ hookCommands: uniqueSorted(hookCommands),
412
+ };
413
+ }
414
+
415
+ function summarizeAsset(
416
+ kind: string,
417
+ parsed: unknown
418
+ ): Record<string, unknown> | undefined {
419
+ if (kind === "claude-settings") {
420
+ const hooks = extractClaudeHooksSummary(parsed);
421
+ const perms = extractClaudePermissionsSummary(parsed);
422
+ return { ...hooks, ...perms };
423
+ }
424
+ if (kind === "claude-plugin-hooks") {
425
+ // Plugin hooks.json uses the same shape as Claude settings hooks.
426
+ return extractClaudeHooksSummary(parsed);
427
+ }
428
+ if (kind === "claude-plugins") {
429
+ if (!isPlainObject(parsed)) {
430
+ return undefined;
431
+ }
432
+ const plugins = (parsed as Record<string, unknown>).plugins;
433
+ if (!isPlainObject(plugins)) {
434
+ return undefined;
435
+ }
436
+ const ids = Object.keys(plugins).sort();
437
+ return {
438
+ pluginCount: ids.length,
439
+ pluginsSample: ids.slice(0, 25),
440
+ pluginsTruncated: ids.length > 25,
441
+ };
442
+ }
443
+ if (kind === "cursor-hook") {
444
+ return extractCursorHooksSummary(parsed);
445
+ }
446
+ return undefined;
447
+ }
448
+
449
+ function uniqueSortedAssets(files: AssetFile[]): AssetFile[] {
450
+ const seen = new Set<string>();
451
+ const out: AssetFile[] = [];
452
+ for (const f of files) {
453
+ const key = `${f.kind}\0${f.path}`;
454
+ if (seen.has(key)) {
455
+ continue;
456
+ }
457
+ seen.add(key);
458
+ out.push(f);
459
+ }
460
+ return out.sort(
461
+ (a, b) => a.kind.localeCompare(b.kind) || a.path.localeCompare(b.path)
462
+ );
463
+ }
464
+
465
+ async function discoverAssetsFromSpecs(
466
+ assets: SourceSpec["assets"],
467
+ home: string
468
+ ): Promise<AssetFile[]> {
469
+ if (!assets || assets.length === 0) {
470
+ return [];
471
+ }
472
+
473
+ const out: AssetFile[] = [];
474
+ for (const spec of assets) {
475
+ const paths = await expandPathPatterns(spec.patterns, home);
476
+ for (const p of paths) {
477
+ // Skip default sample hooks; they are not active hook scripts.
478
+ if (spec.kind === "git-hook" && p.endsWith(".sample")) {
479
+ continue;
480
+ }
481
+ const asset = await discoverAssetFile(p);
482
+ if (asset) {
483
+ // Re-parse (leniently) for known kinds so we can emit a safe summary.
484
+ let summary: Record<string, unknown> | undefined;
485
+ if (asset.format === "json" && !asset.error) {
486
+ try {
487
+ const parsed = await readJsonSafe(p);
488
+ summary = summarizeAsset(spec.kind, parsed);
489
+ } catch {
490
+ // ignore summary errors; keep the file listed.
491
+ }
492
+ }
493
+ out.push({
494
+ ...asset,
495
+ kind: spec.kind,
496
+ summary: summary ?? asset.summary,
497
+ });
498
+ }
499
+ }
500
+ }
501
+ return uniqueSortedAssets(out);
502
+ }
503
+
504
+ function defaultSourceSpecs(
505
+ cwd: string,
506
+ home: string,
507
+ opts?: { includeGitHooks?: boolean }
508
+ ): SourceSpec[] {
509
+ const canonicalRoot = facultRootDir(home);
510
+ const includeGitHooks = opts?.includeGitHooks ?? false;
511
+
512
+ const specs: SourceSpec[] = [
513
+ {
514
+ id: "facult",
515
+ name: "facult (canonical)",
516
+ candidates: [canonicalRoot],
517
+ skillDirs: [join(canonicalRoot, "skills")],
518
+ configFiles: [
519
+ join(canonicalRoot, "mcp", "servers.json"),
520
+ join(canonicalRoot, "mcp", "mcp.json"),
521
+ ],
522
+ },
523
+ {
524
+ id: "cursor",
525
+ name: "Cursor",
526
+ candidates: [
527
+ "~/.cursor",
528
+ "~/.cursr", // common typo; include if it exists
529
+ "~/Library/Application Support/Cursor/User/settings.json",
530
+ "~/AppData/Roaming/Cursor/User/settings.json",
531
+ "~/AppData/Roaming/Cursor/mcp.json",
532
+ "~/AppData/Roaming/Cursor",
533
+ "~/.cursor/mcp.json",
534
+ "~/.cursr/mcp.json",
535
+ ],
536
+ skillDirs: [
537
+ "~/.cursor/skills",
538
+ "~/.cursr/skills",
539
+ "~/AppData/Roaming/Cursor/skills",
540
+ ],
541
+ configFiles: [
542
+ "~/.cursor/mcp.json",
543
+ "~/.cursr/mcp.json",
544
+ "~/Library/Application Support/Cursor/User/settings.json",
545
+ "~/AppData/Roaming/Cursor/User/settings.json",
546
+ "~/AppData/Roaming/Cursor/mcp.json",
547
+ ],
548
+ },
549
+ {
550
+ id: "windsurf",
551
+ name: "Windsurf",
552
+ candidates: [
553
+ "~/Library/Application Support/Windsurf/User/settings.json",
554
+ "~/AppData/Roaming/Windsurf/User/settings.json",
555
+ "~/Library/Application Support/Windsurf",
556
+ "~/AppData/Roaming/Windsurf",
557
+ "~/.windsurf",
558
+ ],
559
+ // Windsurf is VS Code-like; settings.json may contain mcpServers.
560
+ configFiles: [
561
+ "~/Library/Application Support/Windsurf/User/settings.json",
562
+ "~/AppData/Roaming/Windsurf/User/settings.json",
563
+ ],
564
+ },
565
+ {
566
+ id: "vscode",
567
+ name: "VS Code / VSCodium",
568
+ candidates: [
569
+ "~/Library/Application Support/Code/User/settings.json",
570
+ "~/Library/Application Support/VSCodium/User/settings.json",
571
+ "~/AppData/Roaming/Code/User/settings.json",
572
+ "~/AppData/Roaming/VSCodium/User/settings.json",
573
+ ],
574
+ configFiles: [
575
+ "~/Library/Application Support/Code/User/settings.json",
576
+ "~/Library/Application Support/VSCodium/User/settings.json",
577
+ "~/AppData/Roaming/Code/User/settings.json",
578
+ "~/AppData/Roaming/VSCodium/User/settings.json",
579
+ ],
580
+ },
581
+ {
582
+ id: "codex",
583
+ name: "Codex",
584
+ candidates: [
585
+ "~/.codex",
586
+ "~/.config/openai",
587
+ "~/.config/openai/codex.json",
588
+ "~/AppData/Roaming/openai/codex.json",
589
+ "~/AppData/Roaming/OpenAI/codex.json",
590
+ "~/.codex/config.json",
591
+ "~/.codex/config.toml",
592
+ "~/.codex/mcp.json",
593
+ ],
594
+ skillDirs: ["~/.codex/skills"],
595
+ configFiles: [
596
+ "~/.config/openai/codex.json",
597
+ "~/AppData/Roaming/openai/codex.json",
598
+ "~/AppData/Roaming/OpenAI/codex.json",
599
+ "~/.codex/config.json",
600
+ "~/.codex/config.toml",
601
+ "~/.codex/mcp.json",
602
+ ],
603
+ },
604
+ {
605
+ id: "claude",
606
+ name: "Claude (CLI)",
607
+ candidates: ["~/.claude", "~/.claude.json", "~/.config/claude"],
608
+ skillDirs: ["~/.claude/skills", "~/.config/claude/skills"],
609
+ configFiles: ["~/.claude.json"],
610
+ assets: [
611
+ {
612
+ kind: "claude-settings",
613
+ patterns: [
614
+ "~/.claude/settings.json",
615
+ "~/.claude/settings.local.json",
616
+ "~/.config/claude/settings.json",
617
+ "~/.config/claude/settings.local.json",
618
+ ],
619
+ },
620
+ {
621
+ kind: "claude-instructions",
622
+ patterns: ["~/.claude/CLAUDE.md", "~/.config/claude/CLAUDE.md"],
623
+ },
624
+ ],
625
+ },
626
+ {
627
+ id: "claude-plugins",
628
+ name: "Claude plugins",
629
+ candidates: [
630
+ "~/.claude/plugins",
631
+ "~/.claude/plugins/installed_plugins.json",
632
+ ],
633
+ assets: [
634
+ {
635
+ kind: "claude-plugins",
636
+ patterns: ["~/.claude/plugins/installed_plugins.json"],
637
+ },
638
+ ],
639
+ },
640
+ {
641
+ id: "claude-desktop",
642
+ name: "Claude Desktop",
643
+ candidates: [
644
+ "~/Library/Application Support/Claude/claude_desktop_config.json",
645
+ "~/Library/Application Support/Claude",
646
+ "~/AppData/Roaming/Claude/claude_desktop_config.json",
647
+ "~/AppData/Roaming/Claude",
648
+ ],
649
+ configFiles: [
650
+ "~/Library/Application Support/Claude/claude_desktop_config.json",
651
+ "~/AppData/Roaming/Claude/claude_desktop_config.json",
652
+ ],
653
+ },
654
+ {
655
+ id: "gemini",
656
+ name: "Gemini",
657
+ candidates: ["~/.config/gemini", "~/.gemini", "~/.gemini/antigravity*"],
658
+ skillDirs: [
659
+ "~/.config/gemini/skills",
660
+ "~/.gemini/skills",
661
+ "~/.gemini/antigravity*/skills",
662
+ ],
663
+ configFiles: [
664
+ "~/.config/gemini/mcp.json",
665
+ "~/.gemini/mcp.json",
666
+ "~/.gemini/antigravity*/mcp.json",
667
+ ],
668
+ },
669
+ {
670
+ id: "antigravity",
671
+ name: "Antigravity",
672
+ candidates: ["~/.antigravity", "~/.config/antigravity"],
673
+ skillDirs: ["~/.antigravity/skills", "~/.config/antigravity/skills"],
674
+ configFiles: [
675
+ "~/.antigravity/mcp.json",
676
+ "~/.config/antigravity/mcp.json",
677
+ ],
678
+ },
679
+ {
680
+ id: "clawdbot",
681
+ name: "Clawdbot",
682
+ candidates: [
683
+ "~/.clawdbot",
684
+ "~/.config/clawdbot",
685
+ "~/clawdbot",
686
+ "~/clawdbot/agents",
687
+ "~/clawd/agents",
688
+ "~/clawd",
689
+ ],
690
+ skillDirs: [
691
+ "~/.clawdbot/skills",
692
+ "~/.config/clawdbot/skills",
693
+ "~/clawdbot/agents/**/skills",
694
+ "~/clawd/agents/**/skills",
695
+ "~/clawd/skills",
696
+ ],
697
+ configFiles: [
698
+ "~/.clawdbot/mcp.json",
699
+ "~/.config/clawdbot/mcp.json",
700
+ "~/clawdbot/mcp.json",
701
+ ],
702
+ },
703
+ {
704
+ id: "agents",
705
+ name: "Agents / Skills (generic)",
706
+ candidates: [
707
+ "~/.agents",
708
+ "~/agents",
709
+ "~/clawdbot/agents",
710
+ "~/clawd/agents",
711
+ ],
712
+ skillDirs: [
713
+ "~/.agents/skills",
714
+ "~/agents",
715
+ "~/agents/skills",
716
+ "~/clawdbot/agents/**/skills",
717
+ "~/clawd/agents/**/skills",
718
+ ],
719
+ configFiles: [
720
+ // Common ad-hoc MCP config locations.
721
+ "~/.agents/mcp.json",
722
+ "~/agents/mcp.json",
723
+ ],
724
+ },
725
+ {
726
+ id: "dot-clawdbot",
727
+ name: ".clawdbot (project)",
728
+ candidates: [join(cwd, ".clawdbot")],
729
+ skillDirs: [join(cwd, ".clawdbot", "skills"), join(cwd, "skills")],
730
+ configFiles: [
731
+ join(cwd, ".clawdbot", "mcp.json"),
732
+ join(cwd, ".clawdbot", "config.json"),
733
+ ],
734
+ },
735
+ {
736
+ id: "codex-project",
737
+ name: "Codex (project)",
738
+ candidates: [join(cwd, ".codex")],
739
+ skillDirs: [join(cwd, ".codex", "skills")],
740
+ configFiles: [
741
+ join(cwd, ".codex", "mcp.json"),
742
+ join(cwd, ".codex", "mcp.config.json"),
743
+ join(cwd, ".codex", "config.json"),
744
+ join(cwd, ".codex", "config.toml"),
745
+ ],
746
+ },
747
+ {
748
+ id: "agents-project",
749
+ name: "Agents (project)",
750
+ candidates: [join(cwd, ".agents")],
751
+ skillDirs: [join(cwd, ".agents", "skills")],
752
+ configFiles: [
753
+ join(cwd, ".agents", "mcp.json"),
754
+ join(cwd, ".agents", "mcp.config.json"),
755
+ ],
756
+ },
757
+ {
758
+ id: "cursor-project",
759
+ name: "Cursor (project)",
760
+ candidates: [join(cwd, ".cursor")],
761
+ assets: [
762
+ { kind: "cursor-hook", patterns: [join(cwd, ".cursor", "hooks.json")] },
763
+ {
764
+ kind: "cursor-rule",
765
+ patterns: [join(cwd, ".cursor", "rules", "**")],
766
+ },
767
+ ],
768
+ },
769
+ {
770
+ id: "claude-project",
771
+ name: "Claude (project)",
772
+ candidates: [join(cwd, ".claude")],
773
+ assets: [
774
+ {
775
+ kind: "claude-settings",
776
+ patterns: [
777
+ join(cwd, ".claude", "settings.json"),
778
+ join(cwd, ".claude", "settings.local.json"),
779
+ ],
780
+ },
781
+ {
782
+ kind: "claude-instructions",
783
+ patterns: [join(cwd, ".claude", "CLAUDE.md")],
784
+ },
785
+ ],
786
+ },
787
+ ];
788
+
789
+ if (includeGitHooks) {
790
+ specs.push({
791
+ id: "git-hooks",
792
+ name: "Git hooks (project)",
793
+ candidates: [join(cwd, ".husky"), join(cwd, ".git", "hooks")],
794
+ assets: [
795
+ { kind: "husky", patterns: [join(cwd, ".husky", "**")] },
796
+ { kind: "git-hook", patterns: [join(cwd, ".git", "hooks", "*")] },
797
+ ],
798
+ });
799
+ }
800
+
801
+ return specs;
802
+ }
803
+
804
+ export interface FromScanOptions {
805
+ /** Directory basenames to skip while scanning `--from` roots. */
806
+ ignoreDirNames: Set<string>;
807
+ /** Max directories visited per `--from` root before truncating. */
808
+ maxVisits: number;
809
+ /** Max discovered paths (skills + configs + assets) per `--from` root before truncating. */
810
+ maxResults: number;
811
+ /** Include git hooks (`.git/hooks`) and Husky hooks (`.husky/**`) under `--from` roots. */
812
+ includeGitHooks: boolean;
813
+ }
814
+
815
+ const DEFAULT_FROM_IGNORE_DIRS = new Set<string>([
816
+ ".git",
817
+ "node_modules",
818
+ ".next",
819
+ ".turbo",
820
+ ".cache",
821
+ "dist",
822
+ "build",
823
+ "coverage",
824
+ "Library", // macOS home dir is huge; default scan already covers tool configs there.
825
+ "AppData", // Windows home dir is huge; default scan already covers tool configs there.
826
+ "OrbStack", // Local container volumes; usually irrelevant and very noisy (e.g. *_node_modules).
827
+ "Applications",
828
+ "Desktop",
829
+ "Documents",
830
+ "Downloads",
831
+ "Movies",
832
+ "Music",
833
+ "Pictures",
834
+ "Public",
835
+ "DerivedData",
836
+ ".pnpm-store",
837
+ ".yarn",
838
+ "Pods",
839
+ // These can be very large and are already covered by the default scan sources.
840
+ "clawd",
841
+ "clawdbot",
842
+ ]);
843
+
844
+ async function listFilesRecursive(
845
+ root: string,
846
+ opts: { ignore: Set<string>; maxFiles: number }
847
+ ): Promise<string[]> {
848
+ const out: string[] = [];
849
+ if (pathHasNodeModulesLikeSegment(root)) {
850
+ return out;
851
+ }
852
+ const stack: string[] = [root];
853
+ while (stack.length) {
854
+ const dir = stack.pop();
855
+ if (!dir) {
856
+ continue;
857
+ }
858
+ if (pathHasNodeModulesLikeSegment(dir)) {
859
+ continue;
860
+ }
861
+ let entries: any[];
862
+ try {
863
+ entries = await readdir(dir, { withFileTypes: true });
864
+ } catch {
865
+ continue;
866
+ }
867
+
868
+ for (const ent of entries) {
869
+ if (!ent) {
870
+ continue;
871
+ }
872
+ const name = String(ent.name ?? "");
873
+ if (!name) {
874
+ continue;
875
+ }
876
+ if (isNodeModulesLikeDirName(name)) {
877
+ continue;
878
+ }
879
+ if (ent.isSymbolicLink?.()) {
880
+ continue;
881
+ }
882
+ const abs = join(dir, name);
883
+ if (ent.isDirectory?.()) {
884
+ if (opts.ignore.has(name)) {
885
+ continue;
886
+ }
887
+ // Avoid deep traversal into nested git dirs.
888
+ if (name === ".git") {
889
+ continue;
890
+ }
891
+ stack.push(abs);
892
+ continue;
893
+ }
894
+ if (ent.isFile?.()) {
895
+ out.push(abs);
896
+ if (out.length >= opts.maxFiles) {
897
+ return out;
898
+ }
899
+ }
900
+ }
901
+ }
902
+ return out;
903
+ }
904
+
905
+ async function buildFromRootResult(args: {
906
+ id: string;
907
+ name: string;
908
+ root: string;
909
+ home: string;
910
+ opts: FromScanOptions;
911
+ }): Promise<SourceResult> {
912
+ const expanded = expandTilde(args.root, args.home);
913
+ const abs = expanded.startsWith("/") ? expanded : resolve(expanded);
914
+
915
+ const warnings: string[] = [];
916
+ let truncated = false;
917
+
918
+ if (!isSafePathString(abs)) {
919
+ return {
920
+ id: args.id,
921
+ name: args.name,
922
+ found: false,
923
+ roots: [],
924
+ evidence: [],
925
+ truncated: false,
926
+ warnings: [`Ignored unsafe path: ${args.root}`],
927
+ assets: { files: [] },
928
+ mcp: { configs: [] },
929
+ skills: { roots: [], entries: [] },
930
+ };
931
+ }
932
+
933
+ const st = await statSafe(abs);
934
+ if (!st?.isDir) {
935
+ return {
936
+ id: args.id,
937
+ name: args.name,
938
+ found: false,
939
+ roots: [],
940
+ evidence: [],
941
+ assets: { files: [] },
942
+ mcp: { configs: [] },
943
+ skills: { roots: [], entries: [] },
944
+ };
945
+ }
946
+
947
+ const skillDirs = new Set<string>();
948
+ const mcpConfigPaths = new Set<string>();
949
+ const assetPaths: { kind: string; path: string }[] = [];
950
+
951
+ const addResult = (n = 1) => {
952
+ const total = skillDirs.size + mcpConfigPaths.size + assetPaths.length + n;
953
+ if (total > args.opts.maxResults) {
954
+ if (!truncated) {
955
+ warnings.push(
956
+ `Truncated scan for ${args.root}: exceeded maxResults=${args.opts.maxResults}`
957
+ );
958
+ }
959
+ truncated = true;
960
+ return false;
961
+ }
962
+ return true;
963
+ };
964
+
965
+ const addAsset = (kind: string, p: string) => {
966
+ if (truncated) {
967
+ return;
968
+ }
969
+ if (!addResult(1)) {
970
+ return;
971
+ }
972
+ assetPaths.push({ kind, path: p });
973
+ };
974
+
975
+ const scanCursorDir = async (cursorDir: string) => {
976
+ for (const name of ["mcp.json", "mcp.config.json"]) {
977
+ const p = join(cursorDir, name);
978
+ if ((await statSafe(p))?.isFile) {
979
+ if (addResult(1)) {
980
+ mcpConfigPaths.add(p);
981
+ } else {
982
+ return;
983
+ }
984
+ }
985
+ }
986
+
987
+ const hooksPath = join(cursorDir, "hooks.json");
988
+ const hooksStat = await statSafe(hooksPath);
989
+ if (hooksStat?.isFile) {
990
+ addAsset("cursor-hook", hooksPath);
991
+ }
992
+ const rulesDir = join(cursorDir, "rules");
993
+ const rulesStat = await statSafe(rulesDir);
994
+ if (rulesStat?.isDir) {
995
+ const files = await listFilesRecursive(rulesDir, {
996
+ ignore: args.opts.ignoreDirNames,
997
+ maxFiles: 2000,
998
+ });
999
+ for (const f of files) {
1000
+ addAsset("cursor-rule", f);
1001
+ if (truncated) {
1002
+ break;
1003
+ }
1004
+ }
1005
+ }
1006
+ };
1007
+
1008
+ const scanClaudeDir = async (claudeDir: string) => {
1009
+ for (const name of ["settings.json", "settings.local.json"]) {
1010
+ const p = join(claudeDir, name);
1011
+ const s = await statSafe(p);
1012
+ if (s?.isFile) {
1013
+ addAsset("claude-settings", p);
1014
+ }
1015
+ }
1016
+ const md = join(claudeDir, "CLAUDE.md");
1017
+ if ((await statSafe(md))?.isFile) {
1018
+ addAsset("claude-instructions", md);
1019
+ }
1020
+ };
1021
+
1022
+ const scanHuskyDir = async (huskyDir: string) => {
1023
+ const files = await listFilesRecursive(huskyDir, {
1024
+ ignore: args.opts.ignoreDirNames,
1025
+ maxFiles: 5000,
1026
+ });
1027
+ for (const f of files) {
1028
+ addAsset("husky", f);
1029
+ if (truncated) {
1030
+ break;
1031
+ }
1032
+ }
1033
+ };
1034
+
1035
+ const scanGitHooksDir = async (gitDir: string) => {
1036
+ const hooksDir = join(gitDir, "hooks");
1037
+ const s = await statSafe(hooksDir);
1038
+ if (!s?.isDir) {
1039
+ return;
1040
+ }
1041
+ let entries: any[];
1042
+ try {
1043
+ entries = await readdir(hooksDir, { withFileTypes: true });
1044
+ } catch {
1045
+ return;
1046
+ }
1047
+ for (const ent of entries) {
1048
+ if (!ent?.isFile?.()) {
1049
+ continue;
1050
+ }
1051
+ const name = String(ent.name ?? "");
1052
+ if (!name || name.endsWith(".sample")) {
1053
+ continue;
1054
+ }
1055
+ addAsset("git-hook", join(hooksDir, name));
1056
+ if (truncated) {
1057
+ break;
1058
+ }
1059
+ }
1060
+ };
1061
+
1062
+ const scanGitHooksFile = async (gitFile: string) => {
1063
+ const s = await statSafe(gitFile);
1064
+ if (!s?.isFile) {
1065
+ return;
1066
+ }
1067
+
1068
+ let txt = "";
1069
+ try {
1070
+ txt = await Bun.file(gitFile).text();
1071
+ } catch {
1072
+ return;
1073
+ }
1074
+
1075
+ const firstLine = (txt.split(FIRST_LINE_SPLIT_RE, 1)[0] ?? "").trim();
1076
+ const m = firstLine.match(GITDIR_LINE_RE);
1077
+ if (!m) {
1078
+ return;
1079
+ }
1080
+
1081
+ const raw = (m[1] ?? "").trim();
1082
+ if (!raw) {
1083
+ return;
1084
+ }
1085
+
1086
+ const gitDir = raw.startsWith("/") ? raw : resolve(dirname(gitFile), raw);
1087
+ if (!isSafePathString(gitDir)) {
1088
+ return;
1089
+ }
1090
+
1091
+ if (!args.opts.includeGitHooks) {
1092
+ return;
1093
+ }
1094
+
1095
+ // Worktrees may store hooks in the referenced gitdir or its commondir.
1096
+ await scanGitHooksDir(gitDir);
1097
+
1098
+ const commonDirFile = join(gitDir, "commondir");
1099
+ const cs = await statSafe(commonDirFile);
1100
+ if (cs?.isFile) {
1101
+ let commonTxt = "";
1102
+ try {
1103
+ commonTxt = await Bun.file(commonDirFile).text();
1104
+ } catch {
1105
+ return;
1106
+ }
1107
+ const commonRel = (
1108
+ commonTxt.split(FIRST_LINE_SPLIT_RE, 1)[0] ?? ""
1109
+ ).trim();
1110
+ if (!commonRel) {
1111
+ return;
1112
+ }
1113
+ const commonDir = commonRel.startsWith("/")
1114
+ ? commonRel
1115
+ : resolve(gitDir, commonRel);
1116
+ if (!isSafePathString(commonDir)) {
1117
+ return;
1118
+ }
1119
+ await scanGitHooksDir(commonDir);
1120
+ }
1121
+ };
1122
+
1123
+ const scanToolDotDir = async (toolDir: string) => {
1124
+ // These dot-directories can be very large (sessions/history/caches). We only care
1125
+ // about the standard config + skill entry locations inside them.
1126
+ for (const name of [
1127
+ "mcp.json",
1128
+ "mcp.config.json",
1129
+ "config.json",
1130
+ "config.toml",
1131
+ ".claude.json",
1132
+ ]) {
1133
+ const p = join(toolDir, name);
1134
+ if ((await statSafe(p))?.isFile) {
1135
+ if (addResult(1)) {
1136
+ mcpConfigPaths.add(p);
1137
+ } else {
1138
+ return;
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ // Common instruction/rules files sometimes live inside tool dot-dirs too.
1144
+ const agentsMd = join(toolDir, "AGENTS.md");
1145
+ const agentsMdLower = join(toolDir, "agents.md");
1146
+ const claudeMd = join(toolDir, "CLAUDE.md");
1147
+ const cursorRules = join(toolDir, ".cursorrules");
1148
+ if ((await statSafe(agentsMd))?.isFile) {
1149
+ addAsset("agents-instructions", agentsMd);
1150
+ } else if ((await statSafe(agentsMdLower))?.isFile) {
1151
+ addAsset("agents-instructions", agentsMdLower);
1152
+ }
1153
+ if ((await statSafe(claudeMd))?.isFile) {
1154
+ addAsset("claude-instructions", claudeMd);
1155
+ }
1156
+ if ((await statSafe(cursorRules))?.isFile) {
1157
+ addAsset("cursor-rules-file", cursorRules);
1158
+ }
1159
+
1160
+ const skillsDir = join(toolDir, "skills");
1161
+ if ((await statSafe(skillsDir))?.isDir) {
1162
+ const entries = await listSkillEntries(skillsDir);
1163
+ for (const skillDir of entries) {
1164
+ if (!addResult(1)) {
1165
+ return;
1166
+ }
1167
+ skillDirs.add(skillDir);
1168
+ }
1169
+ }
1170
+ };
1171
+
1172
+ const MCP_NAMES = new Set([
1173
+ "mcp.json",
1174
+ "mcp.config.json",
1175
+ "claude_desktop_config.json",
1176
+ ".claude.json",
1177
+ ]);
1178
+
1179
+ let visits = 0;
1180
+ const walk = async (dir: string) => {
1181
+ if (truncated) {
1182
+ return;
1183
+ }
1184
+ visits += 1;
1185
+ if (visits > args.opts.maxVisits) {
1186
+ truncated = true;
1187
+ warnings.push(
1188
+ `Truncated scan for ${args.root}: exceeded maxVisits=${args.opts.maxVisits}`
1189
+ );
1190
+ return;
1191
+ }
1192
+
1193
+ let entries: any[];
1194
+ try {
1195
+ entries = await readdir(dir, { withFileTypes: true });
1196
+ } catch {
1197
+ return;
1198
+ }
1199
+
1200
+ const hasGit = entries.some(
1201
+ (e) =>
1202
+ (e?.isDirectory?.() || e?.isFile?.()) &&
1203
+ String(e?.name ?? "") === ".git"
1204
+ );
1205
+
1206
+ // Fast file checks in this directory.
1207
+ for (const ent of entries) {
1208
+ if (!ent?.isFile?.()) {
1209
+ continue;
1210
+ }
1211
+ const name = String(ent.name ?? "");
1212
+ if (name === ".git" && args.opts.includeGitHooks) {
1213
+ await scanGitHooksFile(join(dir, name));
1214
+ }
1215
+ if (name === "SKILL.md") {
1216
+ if (addResult(1)) {
1217
+ skillDirs.add(dir);
1218
+ } else {
1219
+ return;
1220
+ }
1221
+ }
1222
+ if (name === "AGENTS.md" || name === "agents.md") {
1223
+ addAsset("agents-instructions", join(dir, name));
1224
+ }
1225
+ if (name === "CLAUDE.md") {
1226
+ addAsset("claude-instructions", join(dir, name));
1227
+ }
1228
+ if (name === ".cursorrules") {
1229
+ addAsset("cursor-rules-file", join(dir, name));
1230
+ }
1231
+ if (MCP_NAMES.has(name)) {
1232
+ if (addResult(1)) {
1233
+ mcpConfigPaths.add(join(dir, name));
1234
+ } else {
1235
+ return;
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ // Handle special directories we care about.
1241
+ for (const ent of entries) {
1242
+ if (!ent?.isDirectory?.()) {
1243
+ continue;
1244
+ }
1245
+ const name = String(ent.name ?? "");
1246
+ if (!name) {
1247
+ continue;
1248
+ }
1249
+ if (ent.isSymbolicLink?.()) {
1250
+ continue;
1251
+ }
1252
+
1253
+ if (isNodeModulesLikeDirName(name)) {
1254
+ continue;
1255
+ }
1256
+
1257
+ const child = join(dir, name);
1258
+
1259
+ if (name === ".git") {
1260
+ if (args.opts.includeGitHooks) {
1261
+ await scanGitHooksDir(child);
1262
+ }
1263
+ continue;
1264
+ }
1265
+ if (name === ".cursor") {
1266
+ await scanCursorDir(child);
1267
+ continue;
1268
+ }
1269
+ if (name === ".claude") {
1270
+ await scanClaudeDir(child);
1271
+ continue;
1272
+ }
1273
+ if (name === ".vscode") {
1274
+ // VS Code-like settings are commonly JSONC and may contain per-project MCP servers.
1275
+ const settings = join(child, "settings.json");
1276
+ if ((await statSafe(settings))?.isFile) {
1277
+ if (addResult(1)) {
1278
+ mcpConfigPaths.add(settings);
1279
+ } else {
1280
+ return;
1281
+ }
1282
+ }
1283
+ continue;
1284
+ }
1285
+ if (name === ".husky") {
1286
+ if (args.opts.includeGitHooks) {
1287
+ await scanHuskyDir(child);
1288
+ }
1289
+ continue;
1290
+ }
1291
+ if (name === ".codex" || name === ".agents" || name === ".clawdbot") {
1292
+ await scanToolDotDir(child);
1293
+ continue;
1294
+ }
1295
+
1296
+ // Skills directories are typically called "skills"; scan them and don't descend further.
1297
+ if (name === "skills") {
1298
+ if (addResult(1)) {
1299
+ const entries = await listSkillEntries(child);
1300
+ for (const skillDir of entries) {
1301
+ if (!addResult(1)) {
1302
+ return;
1303
+ }
1304
+ skillDirs.add(skillDir);
1305
+ }
1306
+ }
1307
+ continue;
1308
+ }
1309
+
1310
+ // Avoid traversing arbitrary hidden directories while scanning broad roots like `--from ~`.
1311
+ // We only descend into dot-dirs we explicitly understand (handled above).
1312
+ if (name.startsWith(".")) {
1313
+ continue;
1314
+ }
1315
+
1316
+ // Respect ignore list under --from scans.
1317
+ if (args.opts.ignoreDirNames.has(name)) {
1318
+ continue;
1319
+ }
1320
+
1321
+ // If this directory is a git repo root, don't recurse into its whole tree. We'll still
1322
+ // catch root-level agent configs via the special-directory checks above, plus skill dirs.
1323
+ if (hasGit) {
1324
+ continue;
1325
+ }
1326
+
1327
+ await walk(child);
1328
+ if (truncated) {
1329
+ return;
1330
+ }
1331
+ }
1332
+ };
1333
+
1334
+ await walk(abs);
1335
+
1336
+ // Build normalized scan output using the existing safe parsers/summarizers.
1337
+ const skillsEntries = uniqueSorted([...skillDirs]);
1338
+
1339
+ const mcpConfigs: McpConfig[] = [];
1340
+ for (const p of uniqueSorted([...mcpConfigPaths])) {
1341
+ const cfg = await discoverMcpConfig(p);
1342
+ if (cfg) {
1343
+ mcpConfigs.push(cfg);
1344
+ }
1345
+ }
1346
+
1347
+ const discoveredAssets: AssetFile[] = [];
1348
+ for (const a of assetPaths) {
1349
+ const asset = await discoverAssetFile(a.path);
1350
+ if (!asset) {
1351
+ continue;
1352
+ }
1353
+ // Re-parse for known kinds so we can emit a safe summary.
1354
+ let summary: Record<string, unknown> | undefined;
1355
+ if (asset.format === "json" && !asset.error) {
1356
+ try {
1357
+ const parsed = await readJsonSafe(a.path);
1358
+ summary = summarizeAsset(a.kind, parsed);
1359
+ } catch {
1360
+ // ignore summary errors; keep the file listed.
1361
+ }
1362
+ }
1363
+ discoveredAssets.push({
1364
+ ...asset,
1365
+ kind: a.kind,
1366
+ summary: summary ?? asset.summary,
1367
+ });
1368
+ }
1369
+
1370
+ const found =
1371
+ skillsEntries.length > 0 ||
1372
+ mcpConfigs.length > 0 ||
1373
+ discoveredAssets.length > 0;
1374
+
1375
+ return {
1376
+ id: args.id,
1377
+ name: args.name,
1378
+ found,
1379
+ roots: [abs],
1380
+ evidence: [abs],
1381
+ truncated: truncated || undefined,
1382
+ warnings: warnings.length ? warnings : undefined,
1383
+ assets: { files: uniqueSortedAssets(discoveredAssets) },
1384
+ mcp: {
1385
+ configs: uniqueSorted(mcpConfigs.map((c) => JSON.stringify(c))).map((s) =>
1386
+ JSON.parse(s)
1387
+ ),
1388
+ },
1389
+ skills: {
1390
+ roots: [abs],
1391
+ entries: skillsEntries,
1392
+ },
1393
+ };
1394
+ }
1395
+
1396
+ async function discoverRootsAndEvidence(
1397
+ candidates: string[],
1398
+ home: string
1399
+ ): Promise<{ roots: string[]; evidence: string[] }> {
1400
+ const roots: string[] = [];
1401
+ const evidence: string[] = [];
1402
+ const candidatePaths = await expandPathPatterns(candidates, home);
1403
+ for (const p of candidatePaths) {
1404
+ const st = await statSafe(p);
1405
+ if (st) {
1406
+ evidence.push(p);
1407
+ roots.push(st.isDir ? p : dirname(p));
1408
+ }
1409
+ }
1410
+ return { roots, evidence };
1411
+ }
1412
+
1413
+ async function discoverSkillsFromDirs(
1414
+ skillDirs: string[],
1415
+ home: string
1416
+ ): Promise<{ roots: string[]; entries: string[] }> {
1417
+ const skillRoots = await expandPathPatterns(skillDirs, home);
1418
+ const entries: string[] = [];
1419
+ const existingRoots: string[] = [];
1420
+ for (const sr of skillRoots) {
1421
+ const st = await statSafe(sr);
1422
+ if (st?.isDir) {
1423
+ existingRoots.push(sr);
1424
+ entries.push(...(await listSkillEntries(sr)));
1425
+ }
1426
+ }
1427
+ return { roots: existingRoots, entries };
1428
+ }
1429
+
1430
+ const COMMON_MCP_FILENAMES = [
1431
+ "mcp.json",
1432
+ "mcp.config.json",
1433
+ "claude_desktop_config.json",
1434
+ ];
1435
+
1436
+ async function discoverMcpConfigsFromRoots(
1437
+ roots: string[]
1438
+ ): Promise<McpConfig[]> {
1439
+ const configs: McpConfig[] = [];
1440
+ for (const r of roots) {
1441
+ const st = await statSafe(r);
1442
+ if (!st?.isDir) {
1443
+ continue;
1444
+ }
1445
+ for (const name of COMMON_MCP_FILENAMES) {
1446
+ const cfg = await discoverMcpConfig(join(r, name));
1447
+ if (cfg) {
1448
+ configs.push(cfg);
1449
+ }
1450
+ }
1451
+ }
1452
+ return configs;
1453
+ }
1454
+
1455
+ async function buildSourceResult(
1456
+ spec: SourceSpec,
1457
+ home: string
1458
+ ): Promise<SourceResult> {
1459
+ const { roots, evidence } = await discoverRootsAndEvidence(
1460
+ spec.candidates,
1461
+ home
1462
+ );
1463
+ const skills = await discoverSkillsFromDirs(spec.skillDirs ?? [], home);
1464
+ const assets = await discoverAssetsFromSpecs(spec.assets, home);
1465
+
1466
+ const configs: McpConfig[] = [];
1467
+ const configPaths = await expandPathPatterns(spec.configFiles ?? [], home);
1468
+ for (const p of configPaths) {
1469
+ const cfg = await discoverMcpConfig(p);
1470
+ if (cfg) {
1471
+ configs.push(cfg);
1472
+ }
1473
+ }
1474
+
1475
+ // Also opportunistically detect common MCP filenames under any discovered roots.
1476
+ configs.push(...(await discoverMcpConfigsFromRoots(uniqueSorted(roots))));
1477
+
1478
+ // Claude plugins are stored under ~/.claude/plugins/cache/... and can include skills and hooks.
1479
+ // To avoid scanning the whole cache, use installed_plugins.json to find active install paths.
1480
+ if (spec.id === "claude-plugins") {
1481
+ const installedPath = join(
1482
+ home,
1483
+ ".claude",
1484
+ "plugins",
1485
+ "installed_plugins.json"
1486
+ );
1487
+ const st = await statSafe(installedPath);
1488
+ if (st?.isFile) {
1489
+ try {
1490
+ const parsed = await readJsonSafe(installedPath);
1491
+ const plugins = isPlainObject(parsed)
1492
+ ? ((parsed as Record<string, unknown>).plugins as unknown)
1493
+ : null;
1494
+ const installPaths = new Set<string>();
1495
+ if (isPlainObject(plugins)) {
1496
+ for (const entries of Object.values(plugins)) {
1497
+ if (!Array.isArray(entries)) {
1498
+ continue;
1499
+ }
1500
+ for (const ent of entries) {
1501
+ if (!isPlainObject(ent)) {
1502
+ continue;
1503
+ }
1504
+ const installPath = (ent as Record<string, unknown>).installPath;
1505
+ if (typeof installPath === "string" && installPath) {
1506
+ installPaths.add(installPath);
1507
+ }
1508
+ }
1509
+ }
1510
+ }
1511
+
1512
+ const extraSkillRoots: string[] = [];
1513
+ const extraSkillEntries: string[] = [];
1514
+ const extraAssets: AssetFile[] = [];
1515
+
1516
+ const addAsset = async (kind: string, p: string) => {
1517
+ const asset = await discoverAssetFile(p);
1518
+ if (!asset) {
1519
+ return;
1520
+ }
1521
+ let summary: Record<string, unknown> | undefined;
1522
+ if (asset.format === "json" && !asset.error) {
1523
+ try {
1524
+ const parsed = await readJsonSafe(p);
1525
+ summary = summarizeAsset(kind, parsed);
1526
+ } catch {
1527
+ // ignore summary errors
1528
+ }
1529
+ }
1530
+ extraAssets.push({
1531
+ ...asset,
1532
+ kind,
1533
+ summary: summary ?? asset.summary,
1534
+ });
1535
+ };
1536
+
1537
+ for (const installPath of [...installPaths].sort()) {
1538
+ const skillsDir = join(installPath, "skills");
1539
+ if ((await statSafe(skillsDir))?.isDir) {
1540
+ extraSkillRoots.push(skillsDir);
1541
+ extraSkillEntries.push(...(await listSkillEntries(skillsDir)));
1542
+ }
1543
+
1544
+ // Add hooks config and scripts (if any).
1545
+ const hooksDir = join(installPath, "hooks");
1546
+ if ((await statSafe(hooksDir))?.isDir) {
1547
+ const glob = new Bun.Glob("hooks/**/*");
1548
+ let n = 0;
1549
+ for await (const rel of glob.scan({
1550
+ cwd: installPath,
1551
+ onlyFiles: true,
1552
+ })) {
1553
+ // Prevent pathological caches from exploding scan size.
1554
+ n += 1;
1555
+ if (n > 500) {
1556
+ break;
1557
+ }
1558
+ const abs = join(installPath, rel);
1559
+ const kind =
1560
+ rel === "hooks/hooks.json"
1561
+ ? "claude-plugin-hooks"
1562
+ : "claude-plugin-hook";
1563
+ await addAsset(kind, abs);
1564
+ }
1565
+ }
1566
+ }
1567
+
1568
+ skills.roots.push(...extraSkillRoots);
1569
+ skills.entries.push(...extraSkillEntries);
1570
+ assets.push(...extraAssets);
1571
+ } catch {
1572
+ // ignore parse errors; installed_plugins.json is already listed as an asset for inspection.
1573
+ }
1574
+ }
1575
+ }
1576
+
1577
+ const found =
1578
+ evidence.length > 0 ||
1579
+ configs.length > 0 ||
1580
+ skills.entries.length > 0 ||
1581
+ assets.length > 0;
1582
+
1583
+ return {
1584
+ id: spec.id,
1585
+ name: spec.name,
1586
+ found,
1587
+ roots: uniqueSorted(roots),
1588
+ evidence: uniqueSorted(evidence),
1589
+ assets: { files: uniqueSortedAssets(assets) },
1590
+ mcp: {
1591
+ configs: uniqueSorted(configs.map((c) => JSON.stringify(c))).map((s) =>
1592
+ JSON.parse(s)
1593
+ ),
1594
+ },
1595
+ skills: {
1596
+ roots: uniqueSorted(skills.roots),
1597
+ entries: uniqueSorted(skills.entries),
1598
+ },
1599
+ };
1600
+ }
1601
+
1602
+ function formatServers(servers?: string[]): string {
1603
+ if (!servers?.length) {
1604
+ return "";
1605
+ }
1606
+ return ` (servers: ${servers.join(", ")})`;
1607
+ }
1608
+
1609
+ function printSourceMcpConfigs(configs: McpConfig[]) {
1610
+ if (configs.length) {
1611
+ console.log(" MCP configs:");
1612
+ for (const c of configs) {
1613
+ const err = c.error ? ` (error: ${c.error})` : "";
1614
+ console.log(` - ${c.path}${formatServers(c.servers)}${err}`);
1615
+ }
1616
+ } else {
1617
+ console.log(" MCP configs: (none)");
1618
+ }
1619
+ }
1620
+
1621
+ function printSourceSkills(skills: SourceResult["skills"]) {
1622
+ if (skills.entries.length) {
1623
+ console.log(" Skills:");
1624
+ for (const p of skills.entries) {
1625
+ console.log(` - ${p}`);
1626
+ }
1627
+ } else if (skills.roots.length) {
1628
+ console.log(
1629
+ ` Skills: (no SKILL.md found under ${skills.roots.join(", ")})`
1630
+ );
1631
+ } else {
1632
+ console.log(" Skills: (none)");
1633
+ }
1634
+ }
1635
+
1636
+ function printSourceAssets(assets: SourceResult["assets"]) {
1637
+ const files = assets.files;
1638
+ if (files.length) {
1639
+ console.log(" Assets:");
1640
+ for (const f of files) {
1641
+ const err = f.error ? ` (error: ${f.error})` : "";
1642
+ let summary = "";
1643
+ const hookEvents = Array.isArray(f.summary?.hookEvents)
1644
+ ? (f.summary?.hookEvents as unknown[]).map(String)
1645
+ : [];
1646
+ const hookCommands = Array.isArray(f.summary?.hookCommands)
1647
+ ? (f.summary?.hookCommands as unknown[]).map(String)
1648
+ : [];
1649
+ const allowCount =
1650
+ typeof f.summary?.permissionsAllowCount === "number"
1651
+ ? (f.summary.permissionsAllowCount as number)
1652
+ : null;
1653
+
1654
+ if (hookEvents.length || hookCommands.length || allowCount !== null) {
1655
+ const parts: string[] = [];
1656
+ if (hookEvents.length) {
1657
+ parts.push(
1658
+ `hooks=${hookEvents.slice(0, 6).join(", ")}${hookEvents.length > 6 ? ", ..." : ""}`
1659
+ );
1660
+ }
1661
+ if (hookCommands.length) {
1662
+ parts.push(
1663
+ `commands=${hookCommands.slice(0, 3).join(" | ")}${hookCommands.length > 3 ? " | ..." : ""}`
1664
+ );
1665
+ }
1666
+ if (allowCount !== null) {
1667
+ parts.push(`permissions.allow=${allowCount}`);
1668
+ }
1669
+ summary = parts.length ? ` (${parts.join("; ")})` : "";
1670
+ }
1671
+
1672
+ console.log(` - ${f.kind}: ${f.path}${summary}${err}`);
1673
+ }
1674
+ } else {
1675
+ console.log(" Assets: (none)");
1676
+ }
1677
+ }
1678
+
1679
+ function printHuman(res: ScanResult) {
1680
+ console.log(`facult scan — ${res.scannedAt}`);
1681
+ console.log("");
1682
+
1683
+ const foundSources = res.sources.filter((s) => s.found);
1684
+ if (foundSources.length === 0) {
1685
+ console.log("No known sources found.");
1686
+ return;
1687
+ }
1688
+
1689
+ console.log("Discovered sources:");
1690
+ for (const s of foundSources) {
1691
+ const roots = s.roots.length ? s.roots : s.evidence;
1692
+ const trunc = s.truncated ? " (truncated)" : "";
1693
+ console.log(
1694
+ `- ${s.name}${trunc}${roots.length ? `: ${roots.join(", ")}` : ""}`
1695
+ );
1696
+ }
1697
+
1698
+ console.log("");
1699
+
1700
+ for (const s of foundSources) {
1701
+ console.log(`${s.name}`);
1702
+ printSourceMcpConfigs(s.mcp.configs);
1703
+ printSourceSkills(s.skills);
1704
+ printSourceAssets(s.assets);
1705
+ if (s.warnings?.length) {
1706
+ for (const w of s.warnings) {
1707
+ console.log(` Warning: ${w}`);
1708
+ }
1709
+ }
1710
+ console.log("");
1711
+ }
1712
+ }
1713
+
1714
+ function sourcesFromLocations(locations: string[]): string[] {
1715
+ const out = new Set<string>();
1716
+ for (const loc of locations) {
1717
+ const i = loc.indexOf(":");
1718
+ if (i > 0) {
1719
+ out.add(loc.slice(0, i));
1720
+ }
1721
+ }
1722
+ return [...out].sort();
1723
+ }
1724
+
1725
+ interface McpServerOccurrence {
1726
+ name: string;
1727
+ count: number;
1728
+ locations: string[];
1729
+ /** Number of distinct sanitized definitions observed across configs (best-effort). */
1730
+ variants?: number;
1731
+ }
1732
+
1733
+ function computeMcpServerOccurrences(res: ScanResult): McpServerOccurrence[] {
1734
+ const byName = new Map<
1735
+ string,
1736
+ { count: number; locations: Set<string>; variants?: number }
1737
+ >();
1738
+
1739
+ for (const src of res.sources) {
1740
+ for (const cfg of src.mcp.configs) {
1741
+ for (const name of cfg.servers ?? []) {
1742
+ const cur = byName.get(name) ?? {
1743
+ count: 0,
1744
+ locations: new Set<string>(),
1745
+ };
1746
+ cur.count += 1;
1747
+ cur.locations.add(`${src.id}:${cfg.path}`);
1748
+ byName.set(name, cur);
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ return [...byName.entries()]
1754
+ .map(([name, v]) => ({
1755
+ name,
1756
+ count: v.count,
1757
+ locations: [...v.locations].sort(),
1758
+ variants: v.variants,
1759
+ }))
1760
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
1761
+ }
1762
+
1763
+ function sha256Hex(text: string): string {
1764
+ return createHash("sha256").update(text).digest("hex");
1765
+ }
1766
+
1767
+ function extractMcpServersObject(
1768
+ parsed: unknown
1769
+ ): Record<string, unknown> | null {
1770
+ if (!isPlainObject(parsed)) {
1771
+ return null;
1772
+ }
1773
+ const obj = parsed as Record<string, unknown>;
1774
+ if (isPlainObject(obj.mcpServers)) {
1775
+ return obj.mcpServers as Record<string, unknown>;
1776
+ }
1777
+ // Some VS Code-like settings store this under a tool-prefixed key.
1778
+ for (const [k, v] of Object.entries(obj)) {
1779
+ if (k.endsWith(".mcpServers") && isPlainObject(v)) {
1780
+ return v as Record<string, unknown>;
1781
+ }
1782
+ }
1783
+ if (isPlainObject(obj["mcp.servers"])) {
1784
+ return obj["mcp.servers"] as Record<string, unknown>;
1785
+ }
1786
+ if (isPlainObject(obj.servers)) {
1787
+ return obj.servers as Record<string, unknown>;
1788
+ }
1789
+ if (isPlainObject(obj.mcp)) {
1790
+ const mcp = obj.mcp as Record<string, unknown>;
1791
+ if (isPlainObject(mcp.servers)) {
1792
+ return mcp.servers as Record<string, unknown>;
1793
+ }
1794
+ }
1795
+ return null;
1796
+ }
1797
+
1798
+ function mcpSafeDefinitionText(definition: unknown): string {
1799
+ // Best-effort sanitization: include structural fields and env keys, not env values.
1800
+ if (!isPlainObject(definition)) {
1801
+ return String(definition);
1802
+ }
1803
+
1804
+ const obj = definition as Record<string, unknown>;
1805
+ const out: Record<string, unknown> = {};
1806
+ if (typeof obj.transport === "string") {
1807
+ out.transport = obj.transport;
1808
+ }
1809
+ if (typeof obj.command === "string") {
1810
+ out.command = redactPossibleSecrets(obj.command);
1811
+ }
1812
+ if (Array.isArray(obj.args)) {
1813
+ out.args = obj.args.map((v) => redactPossibleSecrets(String(v)));
1814
+ }
1815
+ if (typeof obj.url === "string") {
1816
+ out.url = redactPossibleSecrets(obj.url);
1817
+ }
1818
+ if (isPlainObject(obj.env)) {
1819
+ out.envKeys = Object.keys(obj.env as Record<string, unknown>).sort();
1820
+ }
1821
+ if (isPlainObject(obj.vendorExtensions)) {
1822
+ out.vendorKeys = Object.keys(
1823
+ obj.vendorExtensions as Record<string, unknown>
1824
+ ).sort();
1825
+ }
1826
+ return JSON.stringify(out, null, 2);
1827
+ }
1828
+
1829
+ async function computeMcpDefinitionVariantCounts(
1830
+ res: ScanResult
1831
+ ): Promise<Map<string, number>> {
1832
+ const byServer = new Map<string, Set<string>>();
1833
+
1834
+ for (const src of res.sources) {
1835
+ for (const cfg of src.mcp.configs) {
1836
+ if (cfg.format === "toml") {
1837
+ let txt: string;
1838
+ try {
1839
+ txt = await Bun.file(cfg.path).text();
1840
+ } catch {
1841
+ continue;
1842
+ }
1843
+ const blocks = extractCodexTomlMcpServerBlocks(txt);
1844
+ for (const [serverName, blockText] of Object.entries(blocks)) {
1845
+ const safe = sanitizeCodexTomlMcpText(blockText);
1846
+ const hash = sha256Hex(safe);
1847
+ const set = byServer.get(serverName) ?? new Set<string>();
1848
+ set.add(hash);
1849
+ byServer.set(serverName, set);
1850
+ }
1851
+ continue;
1852
+ }
1853
+
1854
+ if (cfg.format !== "json") {
1855
+ continue;
1856
+ }
1857
+ let parsed: unknown;
1858
+ try {
1859
+ const txt = await Bun.file(cfg.path).text();
1860
+ parsed = parseJsonLenient(txt);
1861
+ } catch {
1862
+ continue;
1863
+ }
1864
+
1865
+ const serversObj = extractMcpServersObject(parsed);
1866
+ if (!serversObj) {
1867
+ continue;
1868
+ }
1869
+
1870
+ for (const [serverName, definition] of Object.entries(serversObj)) {
1871
+ const safe = mcpSafeDefinitionText(definition);
1872
+ const hash = sha256Hex(safe);
1873
+ const set = byServer.get(serverName) ?? new Set<string>();
1874
+ set.add(hash);
1875
+ byServer.set(serverName, set);
1876
+ }
1877
+ }
1878
+ }
1879
+
1880
+ const out = new Map<string, number>();
1881
+ for (const [name, hashes] of byServer.entries()) {
1882
+ out.set(name, hashes.size);
1883
+ }
1884
+ return out;
1885
+ }
1886
+
1887
+ async function computeAssetContentDuplicates(
1888
+ res: ScanResult
1889
+ ): Promise<
1890
+ { kind: string; hash: string; count: number; locations: string[] }[]
1891
+ > {
1892
+ const sanitizeJson = (value: unknown): unknown => {
1893
+ if (typeof value === "string") {
1894
+ return redactPossibleSecrets(value);
1895
+ }
1896
+ if (Array.isArray(value)) {
1897
+ return value.slice(0, 500).map(sanitizeJson);
1898
+ }
1899
+ if (!isPlainObject(value)) {
1900
+ return value;
1901
+ }
1902
+ const out: Record<string, unknown> = {};
1903
+ for (const k of Object.keys(value).sort()) {
1904
+ const v = (value as Record<string, unknown>)[k];
1905
+ if (SECRET_ENV_KEY_RE.test(k)) {
1906
+ out[k] = "<redacted>";
1907
+ } else {
1908
+ out[k] = sanitizeJson(v);
1909
+ }
1910
+ }
1911
+ return out;
1912
+ };
1913
+
1914
+ const groups = new Map<
1915
+ string,
1916
+ { kind: string; hash: string; locations: Set<string> }
1917
+ >();
1918
+
1919
+ for (const src of res.sources) {
1920
+ for (const f of src.assets.files) {
1921
+ const file = Bun.file(f.path);
1922
+ if (!(await file.exists())) {
1923
+ continue;
1924
+ }
1925
+ let text: string;
1926
+ try {
1927
+ text = await file.text();
1928
+ } catch {
1929
+ continue;
1930
+ }
1931
+
1932
+ // Avoid hashing arbitrarily large blobs.
1933
+ const MAX_CHARS = 200_000;
1934
+ if (text.length > MAX_CHARS) {
1935
+ text = text.slice(0, MAX_CHARS);
1936
+ }
1937
+
1938
+ let safeText = redactPossibleSecrets(text);
1939
+ if (f.format === "json") {
1940
+ try {
1941
+ const parsed = parseJsonLenient(text);
1942
+ safeText = JSON.stringify(sanitizeJson(parsed), null, 2);
1943
+ } catch {
1944
+ // keep redacted raw text
1945
+ }
1946
+ }
1947
+
1948
+ const hash = sha256Hex(safeText);
1949
+ const key = `${f.kind}\0${hash}`;
1950
+ const cur = groups.get(key) ?? {
1951
+ kind: f.kind,
1952
+ hash,
1953
+ locations: new Set<string>(),
1954
+ };
1955
+ cur.locations.add(`${src.id}:${f.path}`);
1956
+ groups.set(key, cur);
1957
+ }
1958
+ }
1959
+
1960
+ return [...groups.values()]
1961
+ .map((v) => ({
1962
+ kind: v.kind,
1963
+ hash: v.hash,
1964
+ count: v.locations.size,
1965
+ locations: [...v.locations].sort(),
1966
+ }))
1967
+ .filter((v) => v.count > 1)
1968
+ .sort(
1969
+ (a, b) =>
1970
+ b.count - a.count ||
1971
+ a.kind.localeCompare(b.kind) ||
1972
+ a.hash.localeCompare(b.hash)
1973
+ );
1974
+ }
1975
+
1976
+ function _printSkillsTable(res: ScanResult) {
1977
+ const all = computeSkillOccurrences(res);
1978
+
1979
+ console.log(`facult scan — ${res.scannedAt}`);
1980
+ console.log("Skills (deduplicated by SKILL.md parent directory name):");
1981
+
1982
+ if (all.length === 0) {
1983
+ console.log("(none)");
1984
+ return;
1985
+ }
1986
+
1987
+ const rows = all.map((d) => ({
1988
+ skill: d.name,
1989
+ count: String(d.count),
1990
+ sources: sourcesFromLocations(d.locations).join(", "),
1991
+ }));
1992
+
1993
+ const wSkill = Math.max("SKILL".length, ...rows.map((r) => r.skill.length));
1994
+ const wCount = Math.max("COUNT".length, ...rows.map((r) => r.count.length));
1995
+
1996
+ console.log(
1997
+ `${"SKILL".padEnd(wSkill)} ${"COUNT".padStart(wCount)} SOURCES`
1998
+ );
1999
+ console.log(
2000
+ `${"-".repeat(wSkill)} ${"-".repeat(wCount)} ${"-".repeat("SOURCES".length)}`
2001
+ );
2002
+ for (const r of rows) {
2003
+ console.log(
2004
+ `${r.skill.padEnd(wSkill)} ${r.count.padStart(wCount)} ${r.sources}`
2005
+ );
2006
+ }
2007
+ }
2008
+
2009
+ function printSkillDuplicatesTable(res: ScanResult) {
2010
+ const all = computeSkillOccurrences(res).filter((d) => d.count > 1);
2011
+
2012
+ console.log(`facult scan — ${res.scannedAt}`);
2013
+ console.log("Duplicate skills (same skill name appears in multiple places):");
2014
+
2015
+ if (all.length === 0) {
2016
+ console.log("(none)");
2017
+ return;
2018
+ }
2019
+
2020
+ const rows = all.map((d) => ({
2021
+ skill: d.name,
2022
+ count: String(d.count),
2023
+ sources: sourcesFromLocations(d.locations).join(", "),
2024
+ }));
2025
+
2026
+ const wSkill = Math.max("SKILL".length, ...rows.map((r) => r.skill.length));
2027
+ const wCount = Math.max("COUNT".length, ...rows.map((r) => r.count.length));
2028
+
2029
+ console.log(
2030
+ `${"SKILL".padEnd(wSkill)} ${"COUNT".padStart(wCount)} SOURCES`
2031
+ );
2032
+ console.log(
2033
+ `${"-".repeat(wSkill)} ${"-".repeat(wCount)} ${"-".repeat("SOURCES".length)}`
2034
+ );
2035
+ for (const r of rows) {
2036
+ console.log(
2037
+ `${r.skill.padEnd(wSkill)} ${r.count.padStart(wCount)} ${r.sources}`
2038
+ );
2039
+ }
2040
+ }
2041
+
2042
+ async function printMcpDuplicatesTable(res: ScanResult) {
2043
+ const all = computeMcpServerOccurrences(res).filter((d) => d.count > 1);
2044
+ const variants = await computeMcpDefinitionVariantCounts(res);
2045
+
2046
+ console.log(
2047
+ "Duplicate MCP servers (same server name appears in multiple config files):"
2048
+ );
2049
+
2050
+ if (all.length === 0) {
2051
+ console.log("(none)");
2052
+ return;
2053
+ }
2054
+
2055
+ const rows = all.map((d) => ({
2056
+ name: d.name,
2057
+ count: String(d.count),
2058
+ variants: String(variants.get(d.name) ?? 0),
2059
+ sources: sourcesFromLocations(d.locations).join(", "),
2060
+ }));
2061
+
2062
+ const wName = Math.max("SERVER".length, ...rows.map((r) => r.name.length));
2063
+ const wCount = Math.max("COUNT".length, ...rows.map((r) => r.count.length));
2064
+ const wVar = Math.max("VAR".length, ...rows.map((r) => r.variants.length));
2065
+
2066
+ console.log(
2067
+ `${"SERVER".padEnd(wName)} ${"COUNT".padStart(wCount)} ${"VAR".padStart(wVar)} SOURCES`
2068
+ );
2069
+ console.log(
2070
+ `${"-".repeat(wName)} ${"-".repeat(wCount)} ${"-".repeat(wVar)} ${"-".repeat("SOURCES".length)}`
2071
+ );
2072
+
2073
+ for (const r of rows) {
2074
+ console.log(
2075
+ `${r.name.padEnd(wName)} ${r.count.padStart(wCount)} ${r.variants.padStart(wVar)} ${r.sources}`
2076
+ );
2077
+ }
2078
+ }
2079
+
2080
+ async function printAssetDuplicatesTable(res: ScanResult) {
2081
+ const all = await computeAssetContentDuplicates(res);
2082
+
2083
+ console.log(
2084
+ "Duplicate assets (same kind + same sanitized content appears in multiple places):"
2085
+ );
2086
+
2087
+ if (all.length === 0) {
2088
+ console.log("(none)");
2089
+ return;
2090
+ }
2091
+
2092
+ const rows = all.map((d) => ({
2093
+ kind: d.kind,
2094
+ count: String(d.count),
2095
+ hash: d.hash.slice(0, 10),
2096
+ sources: sourcesFromLocations(d.locations).join(", "),
2097
+ }));
2098
+
2099
+ const wKind = Math.max("KIND".length, ...rows.map((r) => r.kind.length));
2100
+ const wCount = Math.max("COUNT".length, ...rows.map((r) => r.count.length));
2101
+ const wHash = Math.max("HASH".length, ...rows.map((r) => r.hash.length));
2102
+
2103
+ console.log(
2104
+ `${"KIND".padEnd(wKind)} ${"COUNT".padStart(wCount)} ${"HASH".padEnd(wHash)} SOURCES`
2105
+ );
2106
+ console.log(
2107
+ `${"-".repeat(wKind)} ${"-".repeat(wCount)} ${"-".repeat(wHash)} ${"-".repeat("SOURCES".length)}`
2108
+ );
2109
+ for (const r of rows) {
2110
+ console.log(
2111
+ `${r.kind.padEnd(wKind)} ${r.count.padStart(wCount)} ${r.hash.padEnd(wHash)} ${r.sources}`
2112
+ );
2113
+ }
2114
+ }
2115
+
2116
+ async function printDuplicatesReport(res: ScanResult) {
2117
+ printSkillDuplicatesTable(res);
2118
+ console.log("");
2119
+ await printMcpDuplicatesTable(res);
2120
+ console.log("");
2121
+ await printAssetDuplicatesTable(res);
2122
+ }
2123
+
2124
+ async function ensureDir(p: string) {
2125
+ await mkdir(p, { recursive: true });
2126
+ }
2127
+
2128
+ export async function scan(
2129
+ _argv: string[],
2130
+ opts?: {
2131
+ cwd?: string;
2132
+ homeDir?: string;
2133
+ /** Include scan defaults from `~/.facult/config.json` (scanFrom*). */
2134
+ includeConfigFrom?: boolean;
2135
+ /** Include git hooks + Husky hooks in results (can be noisy). Default: false. */
2136
+ includeGitHooks?: boolean;
2137
+ from?: string[];
2138
+ fromOptions?: {
2139
+ /** Disable the default ignore list for `--from` scans. */
2140
+ noDefaultIgnore?: boolean;
2141
+ /** Add directory basenames to ignore for `--from` scans. */
2142
+ ignoreDirNames?: string[];
2143
+ /** Override max directories visited per `--from` root. */
2144
+ maxVisits?: number;
2145
+ /** Override max discovered paths per `--from` root. */
2146
+ maxResults?: number;
2147
+ };
2148
+ }
2149
+ ): Promise<ScanResult> {
2150
+ const cwd = opts?.cwd ?? process.cwd();
2151
+ const home = opts?.homeDir ?? homedir();
2152
+ const includeGitHooks = opts?.includeGitHooks ?? false;
2153
+
2154
+ const cfg = opts?.includeConfigFrom ? readFacultConfig(home) : null;
2155
+
2156
+ const noDefaultIgnore =
2157
+ opts?.fromOptions?.noDefaultIgnore ?? cfg?.scanFromNoDefaultIgnore ?? false;
2158
+
2159
+ const ignore = new Set<string>(
2160
+ noDefaultIgnore ? [] : [...DEFAULT_FROM_IGNORE_DIRS]
2161
+ );
2162
+ for (const name of cfg?.scanFromIgnore ?? []) {
2163
+ if (name) {
2164
+ ignore.add(name);
2165
+ }
2166
+ }
2167
+ for (const name of opts?.fromOptions?.ignoreDirNames ?? []) {
2168
+ if (name) {
2169
+ ignore.add(name);
2170
+ }
2171
+ }
2172
+
2173
+ const fromOpts: FromScanOptions = {
2174
+ ignoreDirNames: ignore,
2175
+ // Keep `--from ~` usable by default; users can still tune this down/up via flags.
2176
+ maxVisits:
2177
+ opts?.fromOptions?.maxVisits ?? cfg?.scanFromMaxVisits ?? 200_000,
2178
+ maxResults:
2179
+ opts?.fromOptions?.maxResults ?? cfg?.scanFromMaxResults ?? 20_000,
2180
+ includeGitHooks,
2181
+ };
2182
+
2183
+ const specs = [...defaultSourceSpecs(cwd, home, { includeGitHooks })];
2184
+ const sources: SourceResult[] = [];
2185
+ for (const spec of specs) {
2186
+ sources.push(await buildSourceResult(spec, home));
2187
+ }
2188
+
2189
+ const fromRootsInput = [...(cfg?.scanFrom ?? []), ...(opts?.from ?? [])];
2190
+ const fromRoots: string[] = [];
2191
+ const seenAbs = new Set<string>();
2192
+ for (const root of fromRootsInput) {
2193
+ const expanded = expandTilde(root, home);
2194
+ const abs = expanded.startsWith("/") ? expanded : resolve(expanded);
2195
+ if (!isSafePathString(abs)) {
2196
+ continue;
2197
+ }
2198
+ if (seenAbs.has(abs)) {
2199
+ continue;
2200
+ }
2201
+ seenAbs.add(abs);
2202
+ fromRoots.push(root);
2203
+ }
2204
+
2205
+ for (let i = 0; i < fromRoots.length; i += 1) {
2206
+ const root = fromRoots[i]!;
2207
+ const id = `from-${i + 1}`;
2208
+ sources.push(
2209
+ await buildFromRootResult({
2210
+ id,
2211
+ name: `From: ${root}`,
2212
+ root,
2213
+ home,
2214
+ opts: fromOpts,
2215
+ })
2216
+ );
2217
+ }
2218
+
2219
+ return {
2220
+ version: 6,
2221
+ scannedAt: new Date().toISOString(),
2222
+ cwd,
2223
+ sources,
2224
+ };
2225
+ }
2226
+
2227
+ export async function writeState(res: ScanResult) {
2228
+ const stateDir = join(homedir(), ".facult");
2229
+ await ensureDir(stateDir);
2230
+ const outPath = join(stateDir, "sources.json");
2231
+ await Bun.write(outPath, `${JSON.stringify(res, null, 2)}\n`);
2232
+ }
2233
+
2234
+ function printScanHelp() {
2235
+ console.log(`facult scan — inventory local agent configs across tools
2236
+
2237
+ Usage:
2238
+ facult scan [--json] [--show-duplicates] [--tui]
2239
+ facult scan --from <path> [--from <path> ...]
2240
+
2241
+ Notes:
2242
+ - If no --from roots are provided and no scanFrom is configured, facult defaults to scanning ~.
2243
+
2244
+ Options:
2245
+ --json Print full JSON (ScanResult)
2246
+ --show-duplicates Print duplicates for skills, MCP servers, and hook assets
2247
+ --tui Render scan output in an interactive TUI (skills list)
2248
+ --no-config-from Disable default scan roots from ~/.facult/config.json (scanFrom)
2249
+ --from Add one or more additional scan roots (repeatable): --from ~/dev
2250
+ --include-git-hooks Include git hooks (.git/hooks) and husky hooks (.husky/**) (noisy)
2251
+ --from-ignore (scan) Ignore directories by basename under --from roots (repeatable)
2252
+ --from-no-default-ignore (scan) Disable the default ignore list for --from scans
2253
+ --from-max-visits (scan) Max directories visited per --from root before truncating
2254
+ --from-max-results (scan) Max discovered paths per --from root before truncating
2255
+ `);
2256
+ }
2257
+
2258
+ export async function scanCommand(argv: string[]) {
2259
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
2260
+ printScanHelp();
2261
+ return;
2262
+ }
2263
+
2264
+ const json = argv.includes("--json");
2265
+ const showDuplicates = argv.includes("--show-duplicates");
2266
+ const tui = argv.includes("--tui");
2267
+ const noConfigFrom = argv.includes("--no-config-from");
2268
+ const includeGitHooks = argv.includes("--include-git-hooks");
2269
+
2270
+ const from: string[] = [];
2271
+ const fromIgnore: string[] = [];
2272
+ let fromNoDefaultIgnore = false;
2273
+ let fromMaxVisits: number | undefined;
2274
+ let fromMaxResults: number | undefined;
2275
+ for (let i = 0; i < argv.length; i += 1) {
2276
+ const arg = argv[i];
2277
+ if (!arg) {
2278
+ continue;
2279
+ }
2280
+ if (arg === "--from") {
2281
+ const next = argv[i + 1];
2282
+ if (!next) {
2283
+ console.error("--from requires a path");
2284
+ process.exitCode = 2;
2285
+ return;
2286
+ }
2287
+ from.push(next);
2288
+ i += 1;
2289
+ continue;
2290
+ }
2291
+ if (arg.startsWith("--from=")) {
2292
+ const value = arg.slice("--from=".length);
2293
+ if (!value) {
2294
+ console.error("--from requires a path");
2295
+ process.exitCode = 2;
2296
+ return;
2297
+ }
2298
+ from.push(value);
2299
+ continue;
2300
+ }
2301
+
2302
+ if (arg === "--from-ignore") {
2303
+ const next = argv[i + 1];
2304
+ if (!next) {
2305
+ console.error("--from-ignore requires a directory name");
2306
+ process.exitCode = 2;
2307
+ return;
2308
+ }
2309
+ fromIgnore.push(next);
2310
+ i += 1;
2311
+ continue;
2312
+ }
2313
+ if (arg.startsWith("--from-ignore=")) {
2314
+ const value = arg.slice("--from-ignore=".length);
2315
+ if (!value) {
2316
+ console.error("--from-ignore requires a directory name");
2317
+ process.exitCode = 2;
2318
+ return;
2319
+ }
2320
+ fromIgnore.push(value);
2321
+ continue;
2322
+ }
2323
+ if (arg === "--from-no-default-ignore") {
2324
+ fromNoDefaultIgnore = true;
2325
+ continue;
2326
+ }
2327
+ if (arg === "--from-max-visits") {
2328
+ const next = argv[i + 1];
2329
+ if (!next) {
2330
+ console.error("--from-max-visits requires a number");
2331
+ process.exitCode = 2;
2332
+ return;
2333
+ }
2334
+ const n = Number(next);
2335
+ if (!Number.isFinite(n) || n <= 0) {
2336
+ console.error(`Invalid --from-max-visits value: ${next}`);
2337
+ process.exitCode = 2;
2338
+ return;
2339
+ }
2340
+ fromMaxVisits = Math.floor(n);
2341
+ i += 1;
2342
+ continue;
2343
+ }
2344
+ if (arg.startsWith("--from-max-visits=")) {
2345
+ const raw = arg.slice("--from-max-visits=".length);
2346
+ const n = Number(raw);
2347
+ if (!Number.isFinite(n) || n <= 0) {
2348
+ console.error(`Invalid --from-max-visits value: ${raw}`);
2349
+ process.exitCode = 2;
2350
+ return;
2351
+ }
2352
+ fromMaxVisits = Math.floor(n);
2353
+ continue;
2354
+ }
2355
+ if (arg === "--from-max-results") {
2356
+ const next = argv[i + 1];
2357
+ if (!next) {
2358
+ console.error("--from-max-results requires a number");
2359
+ process.exitCode = 2;
2360
+ return;
2361
+ }
2362
+ const n = Number(next);
2363
+ if (!Number.isFinite(n) || n <= 0) {
2364
+ console.error(`Invalid --from-max-results value: ${next}`);
2365
+ process.exitCode = 2;
2366
+ return;
2367
+ }
2368
+ fromMaxResults = Math.floor(n);
2369
+ i += 1;
2370
+ continue;
2371
+ }
2372
+ if (arg.startsWith("--from-max-results=")) {
2373
+ const raw = arg.slice("--from-max-results=".length);
2374
+ const n = Number(raw);
2375
+ if (!Number.isFinite(n) || n <= 0) {
2376
+ console.error(`Invalid --from-max-results value: ${raw}`);
2377
+ process.exitCode = 2;
2378
+ return;
2379
+ }
2380
+ fromMaxResults = Math.floor(n);
2381
+ }
2382
+ }
2383
+
2384
+ // For universal inventory, default to scanning the home directory when the user
2385
+ // didn't specify any `--from` roots and there isn't a configured default set.
2386
+ // Users can disable this with `--no-config-from`.
2387
+ if (!noConfigFrom && from.length === 0) {
2388
+ const cfg = readFacultConfig();
2389
+ if (!(cfg?.scanFrom && cfg.scanFrom.length > 0)) {
2390
+ from.push("~");
2391
+ }
2392
+ }
2393
+
2394
+ const res = await scan(argv, {
2395
+ includeConfigFrom: !noConfigFrom,
2396
+ includeGitHooks,
2397
+ from,
2398
+ fromOptions: {
2399
+ ignoreDirNames: fromIgnore,
2400
+ noDefaultIgnore: fromNoDefaultIgnore,
2401
+ maxVisits: fromMaxVisits,
2402
+ maxResults: fromMaxResults,
2403
+ },
2404
+ });
2405
+ await writeState(res);
2406
+
2407
+ if (json) {
2408
+ if (tui) {
2409
+ console.error("--json and --tui are mutually exclusive");
2410
+ process.exitCode = 2;
2411
+ return;
2412
+ }
2413
+ console.log(JSON.stringify(res, null, 2));
2414
+ return;
2415
+ }
2416
+
2417
+ if (tui) {
2418
+ const { runSkillsTui } = await import("./tui");
2419
+ await runSkillsTui(res);
2420
+ } else if (showDuplicates) {
2421
+ await printDuplicatesReport(res);
2422
+ } else {
2423
+ printHuman(res);
2424
+ }
2425
+
2426
+ console.log(`State written to ${join(homedir(), ".facult", "sources.json")}`);
2427
+ }