facult 2.7.7 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,7 @@ Recommended global install:
43
43
  brew tap hack-dance/tap
44
44
  brew install hack-dance/tap/fclt
45
45
  fclt --help
46
+ fclt --version
46
47
  ```
47
48
 
48
49
  Package-manager install:
@@ -89,10 +90,26 @@ fclt self-update --version 0.0.1
89
90
 
90
91
  ```bash
91
92
  fclt scan --show-duplicates
93
+ fclt status
94
+ fclt inventory --json
92
95
  ```
93
96
 
94
97
  `scan` is read-only. It inspects local configs and reports what `fclt` found without changing files.
95
98
 
99
+ `status` reports the active canonical root, managed-tool state, generated index/graph state, writeback/proposal queue state, and high-signal sync risks.
100
+
101
+ `inventory` is the stable machine-readable discovery surface for agent harnesses. It returns a JSON catalog of discovered MCP servers, skills, and instruction/rule assets across known tool configs and configured scan roots. MCP definitions are redacted by default, including env values, inline `KEY=value` args, bearer tokens, and secret-looking URL query params, but include safe auth metadata such as env keys, env references, and whether inline secret values were found.
102
+
103
+ Useful inventory slices:
104
+
105
+ ```bash
106
+ fclt inventory --json --global
107
+ fclt inventory --json --project
108
+ fclt inventory --json --tool codex
109
+ ```
110
+
111
+ Use `mcpCapabilities` for the de-duplicated agent-facing MCP view. Use `mcpServers` when you need raw per-source occurrences for diagnostics.
112
+
96
113
  If you want a repo-local `.ai`:
97
114
 
98
115
  ```bash
@@ -137,6 +154,8 @@ If you run these commands inside a repo that has `<repo>/.ai`, `fclt` targets th
137
154
 
138
155
  ```bash
139
156
  fclt list skills
157
+ fclt inventory --json
158
+ fclt status --json
140
159
  fclt show instruction:WRITING
141
160
  fclt show mcp:github
142
161
  fclt find verification
@@ -514,6 +533,7 @@ Recommended security flow:
514
533
  - Inventory and discovery
515
534
  ```bash
516
535
  fclt scan [--from <path>] [--json] [--show-duplicates]
536
+ fclt inventory [--from <path>] [--json] [--show-secrets]
517
537
  fclt list [skills|mcp|agents|snippets|instructions] [--enabled-for <tool>] [--untrusted] [--flagged] [--pending]
518
538
  fclt show <name>
519
539
  fclt show instruction:<name>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "facult",
3
- "version": "2.7.7",
3
+ "version": "2.8.1",
4
4
  "description": "Manage canonical AI capabilities, sync surfaces, and evolution state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "facult": "bun run ./src/index.ts",
44
44
  "scan": "bun run ./src/index.ts scan",
45
45
  "build": "bun run scripts/build-binary.ts",
46
- "build:verify": "./dist/fclt --help",
46
+ "build:verify": "bun run scripts/verify-binary.ts",
47
47
  "install:dev": "bun run scripts/install-cli.ts --mode=dev --force",
48
48
  "install:bin": "bun run build && bun run scripts/install-cli.ts --mode=bin --force",
49
49
  "install:status": "bun run scripts/install-status.ts",
package/src/ai.ts CHANGED
@@ -1545,6 +1545,11 @@ async function writebackCommand(argv: string[]) {
1545
1545
  return;
1546
1546
  }
1547
1547
 
1548
+ if (parsed.argv.includes("--help") || parsed.argv.includes("-h")) {
1549
+ console.log(writebackHelp());
1550
+ return;
1551
+ }
1552
+
1548
1553
  const rootDir = resolveCliContextRoot({
1549
1554
  rootArg: parsed.rootArg,
1550
1555
  scope: parsed.scope,
@@ -1656,6 +1661,11 @@ async function evolveCommand(argv: string[]) {
1656
1661
  return;
1657
1662
  }
1658
1663
 
1664
+ if (parsed.argv.includes("--help") || parsed.argv.includes("-h")) {
1665
+ console.log(evolveHelp());
1666
+ return;
1667
+ }
1668
+
1659
1669
  const rootDir = resolveCliContextRoot({
1660
1670
  rootArg: parsed.rootArg,
1661
1671
  scope: parsed.scope,
package/src/index.ts CHANGED
@@ -108,6 +108,14 @@ function printHelp() {
108
108
  headers: ["Command", "Purpose"],
109
109
  rows: [
110
110
  ["scan", "Scan local tool configs and discovered assets"],
111
+ [
112
+ "inventory",
113
+ "Print a JSON inventory of usable skills, instructions, and MCP servers",
114
+ ],
115
+ [
116
+ "status",
117
+ "Show active roots, managed tools, graph/index, and sync risks",
118
+ ],
111
119
  [
112
120
  "audit",
113
121
  "Run security audits with interactive or scripted flows",
@@ -1253,6 +1261,12 @@ async function main(argv: string[]) {
1253
1261
  return;
1254
1262
  }
1255
1263
 
1264
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
1265
+ const { packageVersion } = await import("./status");
1266
+ console.log(await packageVersion());
1267
+ return;
1268
+ }
1269
+
1256
1270
  // Convenience: allow `fclt --show-duplicates` as shorthand for `fclt scan --show-duplicates`.
1257
1271
  if (cmd === "--show-duplicates") {
1258
1272
  const { scanCommand } = await import("./scan");
@@ -1264,6 +1278,14 @@ async function main(argv: string[]) {
1264
1278
  case "scan":
1265
1279
  await import("./scan").then(({ scanCommand }) => scanCommand(rest));
1266
1280
  return;
1281
+ case "inventory":
1282
+ await import("./inventory").then(({ inventoryCommand }) =>
1283
+ inventoryCommand(rest)
1284
+ );
1285
+ return;
1286
+ case "status":
1287
+ await import("./status").then(({ statusCommand }) => statusCommand(rest));
1288
+ return;
1267
1289
  case "audit":
1268
1290
  await import("./audit").then(({ auditCommand }) => auditCommand(rest));
1269
1291
  return;
@@ -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
+ }
package/src/mcp-config.ts CHANGED
@@ -32,6 +32,11 @@ export function extractServersObject(
32
32
  return null;
33
33
  }
34
34
  const raw = parsed as Record<string, unknown>;
35
+ for (const [key, value] of Object.entries(raw)) {
36
+ if (key.endsWith(".mcpServers") && isPlainObject(value)) {
37
+ return value as Record<string, unknown>;
38
+ }
39
+ }
35
40
  const servers =
36
41
  (raw.servers as Record<string, unknown> | undefined) ??
37
42
  (raw.mcpServers as Record<string, unknown> | undefined) ??
package/src/scan.ts CHANGED
@@ -1189,6 +1189,44 @@ async function buildFromRootResult(args: {
1189
1189
  }
1190
1190
  };
1191
1191
 
1192
+ const scanAiDir = async (aiDir: string) => {
1193
+ await scanToolDotDir(aiDir);
1194
+
1195
+ for (const name of ["servers.json", "mcp.json"]) {
1196
+ const p = join(aiDir, "mcp", name);
1197
+ if ((await statSafe(p))?.isFile) {
1198
+ if (addResult(1)) {
1199
+ mcpConfigPaths.add(p);
1200
+ } else {
1201
+ return;
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ for (const name of ["AGENTS.global.md", "AGENTS.override.global.md"]) {
1207
+ const p = join(aiDir, name);
1208
+ if ((await statSafe(p))?.isFile) {
1209
+ addAsset("agents-instructions", p);
1210
+ }
1211
+ }
1212
+
1213
+ const instructionsDir = join(aiDir, "instructions");
1214
+ if ((await statSafe(instructionsDir))?.isDir) {
1215
+ const files = await listFilesRecursive(instructionsDir, {
1216
+ ignore: args.opts.ignoreDirNames,
1217
+ maxFiles: 2000,
1218
+ });
1219
+ for (const f of files) {
1220
+ if (f.endsWith(".md")) {
1221
+ addAsset("canonical-instruction", f);
1222
+ }
1223
+ if (truncated) {
1224
+ break;
1225
+ }
1226
+ }
1227
+ }
1228
+ };
1229
+
1192
1230
  const MCP_NAMES = new Set([
1193
1231
  "mcp.json",
1194
1232
  "mcp.config.json",
@@ -1317,6 +1355,10 @@ async function buildFromRootResult(args: {
1317
1355
  await scanToolDotDir(child);
1318
1356
  continue;
1319
1357
  }
1358
+ if (name === ".ai") {
1359
+ await scanAiDir(child);
1360
+ continue;
1361
+ }
1320
1362
 
1321
1363
  // Skills directories are typically called "skills"; scan them and don't descend further.
1322
1364
  if (name === "skills") {
package/src/status.ts ADDED
@@ -0,0 +1,287 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
4
+ import { loadManagedState } from "./manage";
5
+ import {
6
+ facultAiGraphPath,
7
+ facultAiIndexPath,
8
+ facultAiProposalDir,
9
+ facultAiWritebackQueuePath,
10
+ facultMachineStateDir,
11
+ facultRootDir,
12
+ projectRootFromAiRoot,
13
+ } from "./paths";
14
+ import { parseJsonLenient } from "./util/json";
15
+
16
+ declare const FCLT_COMPILED_VERSION: string | undefined;
17
+
18
+ export interface StatusIssue {
19
+ severity: "info" | "warning" | "error";
20
+ code: string;
21
+ message: string;
22
+ }
23
+
24
+ export interface FacultStatus {
25
+ version: 1;
26
+ packageVersion: string;
27
+ cwd: string;
28
+ globalRoot: string;
29
+ contextRoot: string;
30
+ projectRoot: string | null;
31
+ machineStateDir: string;
32
+ managedTools: string[];
33
+ generatedOnlyProjectRoot: boolean;
34
+ index: {
35
+ path: string;
36
+ exists: boolean;
37
+ };
38
+ graph: {
39
+ path: string;
40
+ exists: boolean;
41
+ };
42
+ writeback: {
43
+ queuePath: string;
44
+ pendingCount: number;
45
+ proposalDir: string;
46
+ proposalCount: number;
47
+ };
48
+ issues: StatusIssue[];
49
+ }
50
+
51
+ async function fileExists(pathValue: string): Promise<boolean> {
52
+ try {
53
+ return (await Bun.file(pathValue).stat()).isFile();
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ async function dirHasVisibleEntries(pathValue: string): Promise<boolean> {
60
+ const entries = await readdir(pathValue).catch(() => [] as string[]);
61
+ return entries.some((entry) => !entry.startsWith("."));
62
+ }
63
+
64
+ async function hasCanonicalSource(rootDir: string): Promise<boolean> {
65
+ for (const relPath of [
66
+ "config.toml",
67
+ "config.local.toml",
68
+ "AGENTS.global.md",
69
+ "AGENTS.override.global.md",
70
+ ]) {
71
+ if (await fileExists(join(rootDir, relPath))) {
72
+ return true;
73
+ }
74
+ }
75
+
76
+ for (const relPath of [
77
+ "agents",
78
+ "automations",
79
+ "instructions",
80
+ "mcp",
81
+ "rules",
82
+ "skills",
83
+ "snippets",
84
+ "tools",
85
+ ]) {
86
+ if (await dirHasVisibleEntries(join(rootDir, relPath))) {
87
+ return true;
88
+ }
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ async function countPendingWritebacks(
95
+ homeDir: string,
96
+ rootDir: string
97
+ ): Promise<number> {
98
+ const { listWritebacks } = await import("./ai");
99
+ const rows = await listWritebacks({ homeDir, rootDir }).catch(() => []);
100
+ return rows.filter(
101
+ (row) =>
102
+ row.status !== "dismissed" &&
103
+ row.status !== "promoted" &&
104
+ row.status !== "resolved" &&
105
+ row.status !== "superseded"
106
+ ).length;
107
+ }
108
+
109
+ async function countActiveProposals(
110
+ homeDir: string,
111
+ rootDir: string
112
+ ): Promise<number> {
113
+ const { listProposals } = await import("./ai");
114
+ const rows = await listProposals({ homeDir, rootDir }).catch(() => []);
115
+ return rows.filter(
116
+ (row) =>
117
+ row.status !== "applied" &&
118
+ row.status !== "failed" &&
119
+ row.status !== "rejected" &&
120
+ row.status !== "superseded"
121
+ ).length;
122
+ }
123
+
124
+ export async function packageVersion(): Promise<string> {
125
+ const envVersion =
126
+ process.env.FACULT_NPM_PACKAGE_VERSION ?? process.env.npm_package_version;
127
+ if (envVersion?.trim()) {
128
+ return envVersion.trim();
129
+ }
130
+
131
+ if (
132
+ typeof FCLT_COMPILED_VERSION === "string" &&
133
+ FCLT_COMPILED_VERSION.trim()
134
+ ) {
135
+ return FCLT_COMPILED_VERSION.trim();
136
+ }
137
+
138
+ const packagePath = join(dirname(import.meta.dir), "package.json");
139
+ const parsed = parseJsonLenient(
140
+ await Bun.file(packagePath)
141
+ .text()
142
+ .catch(() => "{}")
143
+ );
144
+ if (
145
+ parsed &&
146
+ typeof parsed === "object" &&
147
+ !Array.isArray(parsed) &&
148
+ typeof (parsed as Record<string, unknown>).version === "string"
149
+ ) {
150
+ const version = (parsed as Record<string, unknown>).version;
151
+ return typeof version === "string" ? version : "unknown";
152
+ }
153
+ return "unknown";
154
+ }
155
+
156
+ export async function buildStatus(opts?: {
157
+ cwd?: string;
158
+ homeDir?: string;
159
+ rootArg?: string;
160
+ scope?: "merged" | "global" | "project";
161
+ }): Promise<FacultStatus> {
162
+ const homeDir = opts?.homeDir ?? process.env.HOME ?? "";
163
+ const cwd = opts?.cwd ?? process.cwd();
164
+ const globalRoot = facultRootDir(homeDir);
165
+ const contextRoot = resolveCliContextRoot({
166
+ homeDir,
167
+ cwd,
168
+ rootArg: opts?.rootArg,
169
+ scope: opts?.scope,
170
+ });
171
+ const projectRoot = projectRootFromAiRoot(contextRoot, homeDir);
172
+ const generatedOnlyProjectRoot =
173
+ projectRoot !== null && !(await hasCanonicalSource(contextRoot));
174
+ const indexPath = facultAiIndexPath(homeDir, contextRoot);
175
+ const graphPath = facultAiGraphPath(homeDir, contextRoot);
176
+ const queuePath = facultAiWritebackQueuePath(homeDir, contextRoot);
177
+ const proposalDir = facultAiProposalDir(homeDir, contextRoot);
178
+ const managed = await loadManagedState(homeDir, contextRoot);
179
+
180
+ const issues: StatusIssue[] = [];
181
+ if (generatedOnlyProjectRoot) {
182
+ issues.push({
183
+ severity: "warning",
184
+ code: "project-generated-only",
185
+ message:
186
+ "Project .ai contains generated state only; managed project sync should stay paused until canonical source is restored or initialized.",
187
+ });
188
+ }
189
+ if (!(await fileExists(indexPath))) {
190
+ issues.push({
191
+ severity: "info",
192
+ code: "missing-index",
193
+ message:
194
+ 'Generated AI index is missing. Run "fclt index" after canonical source changes.',
195
+ });
196
+ }
197
+ if (!(await fileExists(graphPath))) {
198
+ issues.push({
199
+ severity: "info",
200
+ code: "missing-graph",
201
+ message: 'Generated AI graph is missing. Run "fclt index" to rebuild it.',
202
+ });
203
+ }
204
+
205
+ return {
206
+ version: 1,
207
+ packageVersion: await packageVersion(),
208
+ cwd,
209
+ globalRoot,
210
+ contextRoot,
211
+ projectRoot,
212
+ machineStateDir: facultMachineStateDir(homeDir, contextRoot),
213
+ managedTools: Object.keys(managed.tools).sort(),
214
+ generatedOnlyProjectRoot,
215
+ index: {
216
+ path: indexPath,
217
+ exists: await fileExists(indexPath),
218
+ },
219
+ graph: {
220
+ path: graphPath,
221
+ exists: await fileExists(graphPath),
222
+ },
223
+ writeback: {
224
+ queuePath,
225
+ pendingCount: await countPendingWritebacks(homeDir, contextRoot),
226
+ proposalDir,
227
+ proposalCount: await countActiveProposals(homeDir, contextRoot),
228
+ },
229
+ issues,
230
+ };
231
+ }
232
+
233
+ function printStatus(status: FacultStatus) {
234
+ console.log(`fclt ${status.packageVersion}`);
235
+ console.log(`cwd: ${status.cwd}`);
236
+ console.log(`global root: ${status.globalRoot}`);
237
+ console.log(`context root: ${status.contextRoot}`);
238
+ console.log(`project root: ${status.projectRoot ?? "(none)"}`);
239
+ console.log(`machine state: ${status.machineStateDir}`);
240
+ console.log(`managed tools: ${status.managedTools.join(", ") || "(none)"}`);
241
+ console.log(
242
+ `index: ${status.index.exists ? "present" : "missing"} (${status.index.path})`
243
+ );
244
+ console.log(
245
+ `graph: ${status.graph.exists ? "present" : "missing"} (${status.graph.path})`
246
+ );
247
+ console.log(
248
+ `writeback: ${status.writeback.pendingCount} queued, ${status.writeback.proposalCount} proposals`
249
+ );
250
+ if (status.issues.length > 0) {
251
+ console.log("issues:");
252
+ for (const issue of status.issues) {
253
+ console.log(`- [${issue.severity}] ${issue.code}: ${issue.message}`);
254
+ }
255
+ }
256
+ }
257
+
258
+ export async function statusCommand(argv: string[]) {
259
+ const parsed = parseCliContextArgs(argv);
260
+ if (
261
+ parsed.argv.includes("--help") ||
262
+ parsed.argv.includes("-h") ||
263
+ parsed.argv[0] === "help"
264
+ ) {
265
+ console.log(`fclt status
266
+
267
+ Usage:
268
+ fclt status [--json] [--global|--project|--root <path>]
269
+
270
+ Print the active canonical root, managed-tool state, generated index/graph state,
271
+ writeback counts, and high-signal sync risks.
272
+ `);
273
+ return;
274
+ }
275
+
276
+ const status = await buildStatus({
277
+ rootArg: parsed.rootArg,
278
+ scope: parsed.scope,
279
+ cwd: process.cwd(),
280
+ });
281
+
282
+ if (parsed.argv.includes("--json")) {
283
+ console.log(JSON.stringify(status, null, 2));
284
+ return;
285
+ }
286
+ printStatus(status);
287
+ }