facult 2.7.4 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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) ??
@@ -2,12 +2,17 @@ import { mkdir } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { projectRootFromAiRoot } from "./paths";
4
4
 
5
- type ProjectSyncNamedSurface = "skills" | "agents" | "mcpServers";
5
+ type ProjectSyncNamedSurface =
6
+ | "skills"
7
+ | "agents"
8
+ | "automations"
9
+ | "mcpServers";
6
10
  type ProjectSyncToolSurface = "globalDocs" | "toolRules" | "toolConfig";
7
11
 
8
12
  interface ProjectSyncToolPolicy {
9
13
  skills: string[];
10
14
  agents: string[];
15
+ automations: string[];
11
16
  mcpServers: string[];
12
17
  globalDocs: boolean;
13
18
  toolRules: boolean;
@@ -69,6 +74,7 @@ function projectSyncToolPolicyFromObject(
69
74
  return {
70
75
  skills: parseStringList(table.skills),
71
76
  agents: parseStringList(table.agents),
77
+ automations: parseStringList(table.automations ?? table.automation),
72
78
  mcpServers: parseStringList(table.mcp_servers ?? table.mcp),
73
79
  globalDocs: parseBoolean(table.global_docs ?? table.docs),
74
80
  toolRules: parseBoolean(table.tool_rules ?? table.rules),
@@ -104,6 +110,7 @@ function emptyPolicy(): ProjectSyncToolPolicy {
104
110
  return {
105
111
  skills: [],
106
112
  agents: [],
113
+ automations: [],
107
114
  mcpServers: [],
108
115
  globalDocs: false,
109
116
  toolRules: false,
@@ -150,7 +157,9 @@ export async function projectSyncAllowsNamedAsset(args: {
150
157
  ? policy.skills
151
158
  : args.surface === "agents"
152
159
  ? policy.agents
153
- : policy.mcpServers;
160
+ : args.surface === "automations"
161
+ ? policy.automations
162
+ : policy.mcpServers;
154
163
  return includesExplicitName(allowed, args.name);
155
164
  }
156
165
 
@@ -256,6 +265,9 @@ export async function writeProjectSyncPolicy(args: {
256
265
  const mergedPolicy: ProjectSyncToolPolicy = {
257
266
  skills: parseStringList(partialPolicy.skills ?? previousPolicy.skills),
258
267
  agents: parseStringList(partialPolicy.agents ?? previousPolicy.agents),
268
+ automations: parseStringList(
269
+ partialPolicy.automations ?? previousPolicy.automations
270
+ ),
259
271
  mcpServers: parseStringList(
260
272
  partialPolicy.mcpServers ?? previousPolicy.mcpServers
261
273
  ),
@@ -267,6 +279,7 @@ export async function writeProjectSyncPolicy(args: {
267
279
  projectSync[tool] = {
268
280
  skills: mergedPolicy.skills,
269
281
  agents: mergedPolicy.agents,
282
+ automations: mergedPolicy.automations,
270
283
  mcp_servers: mergedPolicy.mcpServers,
271
284
  global_docs: mergedPolicy.globalDocs,
272
285
  tool_rules: mergedPolicy.toolRules,
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") {
@@ -2268,6 +2310,7 @@ Notes:
2268
2310
 
2269
2311
  Options:
2270
2312
  --json Print full JSON (ScanResult)
2313
+ --persist Persist scan state when using --json
2271
2314
  --show-duplicates Print duplicates for skills, MCP servers, and hook assets
2272
2315
  --tui Render scan output in an interactive TUI (skills list)
2273
2316
  --no-config-from Disable default scan roots from ~/.ai/.facult/config.json (scanFrom)
@@ -2287,6 +2330,7 @@ export async function scanCommand(argv: string[]) {
2287
2330
  }
2288
2331
 
2289
2332
  const json = argv.includes("--json");
2333
+ const persist = argv.includes("--persist") || !json;
2290
2334
  const showDuplicates = argv.includes("--show-duplicates");
2291
2335
  const tui = argv.includes("--tui");
2292
2336
  const noConfigFrom = argv.includes("--no-config-from");
@@ -2427,7 +2471,9 @@ export async function scanCommand(argv: string[]) {
2427
2471
  maxResults: fromMaxResults,
2428
2472
  },
2429
2473
  });
2430
- await writeState(res);
2474
+ if (persist) {
2475
+ await writeState(res);
2476
+ }
2431
2477
 
2432
2478
  if (json) {
2433
2479
  if (tui) {
package/src/status.ts ADDED
@@ -0,0 +1,268 @@
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
+ export interface StatusIssue {
17
+ severity: "info" | "warning" | "error";
18
+ code: string;
19
+ message: string;
20
+ }
21
+
22
+ export interface FacultStatus {
23
+ version: 1;
24
+ packageVersion: string;
25
+ cwd: string;
26
+ globalRoot: string;
27
+ contextRoot: string;
28
+ projectRoot: string | null;
29
+ machineStateDir: string;
30
+ managedTools: string[];
31
+ generatedOnlyProjectRoot: boolean;
32
+ index: {
33
+ path: string;
34
+ exists: boolean;
35
+ };
36
+ graph: {
37
+ path: string;
38
+ exists: boolean;
39
+ };
40
+ writeback: {
41
+ queuePath: string;
42
+ pendingCount: number;
43
+ proposalDir: string;
44
+ proposalCount: number;
45
+ };
46
+ issues: StatusIssue[];
47
+ }
48
+
49
+ async function fileExists(pathValue: string): Promise<boolean> {
50
+ try {
51
+ return (await Bun.file(pathValue).stat()).isFile();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ async function dirHasVisibleEntries(pathValue: string): Promise<boolean> {
58
+ const entries = await readdir(pathValue).catch(() => [] as string[]);
59
+ return entries.some((entry) => !entry.startsWith("."));
60
+ }
61
+
62
+ async function hasCanonicalSource(rootDir: string): Promise<boolean> {
63
+ for (const relPath of [
64
+ "config.toml",
65
+ "config.local.toml",
66
+ "AGENTS.global.md",
67
+ "AGENTS.override.global.md",
68
+ ]) {
69
+ if (await fileExists(join(rootDir, relPath))) {
70
+ return true;
71
+ }
72
+ }
73
+
74
+ for (const relPath of [
75
+ "agents",
76
+ "automations",
77
+ "instructions",
78
+ "mcp",
79
+ "rules",
80
+ "skills",
81
+ "snippets",
82
+ "tools",
83
+ ]) {
84
+ if (await dirHasVisibleEntries(join(rootDir, relPath))) {
85
+ return true;
86
+ }
87
+ }
88
+
89
+ return false;
90
+ }
91
+
92
+ async function countPendingWritebacks(
93
+ homeDir: string,
94
+ rootDir: string
95
+ ): Promise<number> {
96
+ const { listWritebacks } = await import("./ai");
97
+ const rows = await listWritebacks({ homeDir, rootDir }).catch(() => []);
98
+ return rows.filter(
99
+ (row) =>
100
+ row.status !== "dismissed" &&
101
+ row.status !== "promoted" &&
102
+ row.status !== "resolved" &&
103
+ row.status !== "superseded"
104
+ ).length;
105
+ }
106
+
107
+ async function countActiveProposals(
108
+ homeDir: string,
109
+ rootDir: string
110
+ ): Promise<number> {
111
+ const { listProposals } = await import("./ai");
112
+ const rows = await listProposals({ homeDir, rootDir }).catch(() => []);
113
+ return rows.filter(
114
+ (row) =>
115
+ row.status !== "applied" &&
116
+ row.status !== "failed" &&
117
+ row.status !== "rejected" &&
118
+ row.status !== "superseded"
119
+ ).length;
120
+ }
121
+
122
+ export async function packageVersion(): Promise<string> {
123
+ const packagePath = join(dirname(import.meta.dir), "package.json");
124
+ const parsed = parseJsonLenient(await Bun.file(packagePath).text());
125
+ if (
126
+ parsed &&
127
+ typeof parsed === "object" &&
128
+ !Array.isArray(parsed) &&
129
+ typeof (parsed as Record<string, unknown>).version === "string"
130
+ ) {
131
+ const version = (parsed as Record<string, unknown>).version;
132
+ return typeof version === "string" ? version : "unknown";
133
+ }
134
+ return "unknown";
135
+ }
136
+
137
+ export async function buildStatus(opts?: {
138
+ cwd?: string;
139
+ homeDir?: string;
140
+ rootArg?: string;
141
+ scope?: "merged" | "global" | "project";
142
+ }): Promise<FacultStatus> {
143
+ const homeDir = opts?.homeDir ?? process.env.HOME ?? "";
144
+ const cwd = opts?.cwd ?? process.cwd();
145
+ const globalRoot = facultRootDir(homeDir);
146
+ const contextRoot = resolveCliContextRoot({
147
+ homeDir,
148
+ cwd,
149
+ rootArg: opts?.rootArg,
150
+ scope: opts?.scope,
151
+ });
152
+ const projectRoot = projectRootFromAiRoot(contextRoot, homeDir);
153
+ const generatedOnlyProjectRoot =
154
+ projectRoot !== null && !(await hasCanonicalSource(contextRoot));
155
+ const indexPath = facultAiIndexPath(homeDir, contextRoot);
156
+ const graphPath = facultAiGraphPath(homeDir, contextRoot);
157
+ const queuePath = facultAiWritebackQueuePath(homeDir, contextRoot);
158
+ const proposalDir = facultAiProposalDir(homeDir, contextRoot);
159
+ const managed = await loadManagedState(homeDir, contextRoot);
160
+
161
+ const issues: StatusIssue[] = [];
162
+ if (generatedOnlyProjectRoot) {
163
+ issues.push({
164
+ severity: "warning",
165
+ code: "project-generated-only",
166
+ message:
167
+ "Project .ai contains generated state only; managed project sync should stay paused until canonical source is restored or initialized.",
168
+ });
169
+ }
170
+ if (!(await fileExists(indexPath))) {
171
+ issues.push({
172
+ severity: "info",
173
+ code: "missing-index",
174
+ message:
175
+ 'Generated AI index is missing. Run "fclt index" after canonical source changes.',
176
+ });
177
+ }
178
+ if (!(await fileExists(graphPath))) {
179
+ issues.push({
180
+ severity: "info",
181
+ code: "missing-graph",
182
+ message: 'Generated AI graph is missing. Run "fclt index" to rebuild it.',
183
+ });
184
+ }
185
+
186
+ return {
187
+ version: 1,
188
+ packageVersion: await packageVersion(),
189
+ cwd,
190
+ globalRoot,
191
+ contextRoot,
192
+ projectRoot,
193
+ machineStateDir: facultMachineStateDir(homeDir, contextRoot),
194
+ managedTools: Object.keys(managed.tools).sort(),
195
+ generatedOnlyProjectRoot,
196
+ index: {
197
+ path: indexPath,
198
+ exists: await fileExists(indexPath),
199
+ },
200
+ graph: {
201
+ path: graphPath,
202
+ exists: await fileExists(graphPath),
203
+ },
204
+ writeback: {
205
+ queuePath,
206
+ pendingCount: await countPendingWritebacks(homeDir, contextRoot),
207
+ proposalDir,
208
+ proposalCount: await countActiveProposals(homeDir, contextRoot),
209
+ },
210
+ issues,
211
+ };
212
+ }
213
+
214
+ function printStatus(status: FacultStatus) {
215
+ console.log(`fclt ${status.packageVersion}`);
216
+ console.log(`cwd: ${status.cwd}`);
217
+ console.log(`global root: ${status.globalRoot}`);
218
+ console.log(`context root: ${status.contextRoot}`);
219
+ console.log(`project root: ${status.projectRoot ?? "(none)"}`);
220
+ console.log(`machine state: ${status.machineStateDir}`);
221
+ console.log(`managed tools: ${status.managedTools.join(", ") || "(none)"}`);
222
+ console.log(
223
+ `index: ${status.index.exists ? "present" : "missing"} (${status.index.path})`
224
+ );
225
+ console.log(
226
+ `graph: ${status.graph.exists ? "present" : "missing"} (${status.graph.path})`
227
+ );
228
+ console.log(
229
+ `writeback: ${status.writeback.pendingCount} queued, ${status.writeback.proposalCount} proposals`
230
+ );
231
+ if (status.issues.length > 0) {
232
+ console.log("issues:");
233
+ for (const issue of status.issues) {
234
+ console.log(`- [${issue.severity}] ${issue.code}: ${issue.message}`);
235
+ }
236
+ }
237
+ }
238
+
239
+ export async function statusCommand(argv: string[]) {
240
+ const parsed = parseCliContextArgs(argv);
241
+ if (
242
+ parsed.argv.includes("--help") ||
243
+ parsed.argv.includes("-h") ||
244
+ parsed.argv[0] === "help"
245
+ ) {
246
+ console.log(`fclt status
247
+
248
+ Usage:
249
+ fclt status [--json] [--global|--project|--root <path>]
250
+
251
+ Print the active canonical root, managed-tool state, generated index/graph state,
252
+ writeback counts, and high-signal sync risks.
253
+ `);
254
+ return;
255
+ }
256
+
257
+ const status = await buildStatus({
258
+ rootArg: parsed.rootArg,
259
+ scope: parsed.scope,
260
+ cwd: process.cwd(),
261
+ });
262
+
263
+ if (parsed.argv.includes("--json")) {
264
+ console.log(JSON.stringify(status, null, 2));
265
+ return;
266
+ }
267
+ printStatus(status);
268
+ }