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 +20 -0
- package/package.json +2 -2
- package/src/ai.ts +10 -0
- package/src/index.ts +22 -0
- package/src/inventory.ts +886 -0
- package/src/mcp-config.ts +5 -0
- package/src/scan.ts +42 -0
- package/src/status.ts +287 -0
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.
|
|
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": "
|
|
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;
|
package/src/inventory.ts
ADDED
|
@@ -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
|
+
}
|