facult 2.6.0 → 2.7.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/src/doctor.ts CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  } from "./ai-state";
23
23
  import { repairAutosyncServices } from "./autosync";
24
24
  import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
25
+ import { loadManagedState } from "./manage";
26
+ import { extractServersObject } from "./mcp-config";
25
27
  import {
26
28
  facultAiGraphPath,
27
29
  facultAiIndexPath,
@@ -30,7 +32,14 @@ import {
30
32
  facultStateDir,
31
33
  legacyExternalFacultStateDir,
32
34
  legacyFacultStateDirForRoot,
35
+ projectRootFromAiRoot,
33
36
  } from "./paths";
37
+ import {
38
+ loadConfiguredProjectSyncTools,
39
+ writeProjectSyncPolicy,
40
+ } from "./project-sync";
41
+
42
+ const TOML_FILE_SUFFIX_RE = /\.toml$/;
34
43
 
35
44
  function legacyDefaultRoot(home: string): string {
36
45
  return join(home, "agents", ".facult");
@@ -223,6 +232,275 @@ async function repairLegacyState(args: {
223
232
  return { changed, conflicts };
224
233
  }
225
234
 
235
+ function normalizeCodexMarketplaceText(text: string): string {
236
+ try {
237
+ const parsed = JSON.parse(text) as unknown;
238
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
239
+ return text.endsWith("\n") ? text : `${text}\n`;
240
+ }
241
+ const plugins = Array.isArray((parsed as { plugins?: unknown[] }).plugins)
242
+ ? ((parsed as { plugins: unknown[] }).plugins ?? [])
243
+ : null;
244
+ if (plugins) {
245
+ (parsed as { plugins: unknown[] }).plugins = plugins.map((entry) => {
246
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
247
+ return entry;
248
+ }
249
+ const source =
250
+ "source" in entry &&
251
+ (entry as { source?: unknown }).source &&
252
+ typeof (entry as { source?: unknown }).source === "object" &&
253
+ !Array.isArray((entry as { source?: unknown }).source)
254
+ ? {
255
+ ...((entry as { source: Record<string, unknown> }).source ??
256
+ {}),
257
+ }
258
+ : null;
259
+ if (
260
+ source?.source === "local" &&
261
+ typeof source.path === "string" &&
262
+ source.path.startsWith("./.codex/plugins/")
263
+ ) {
264
+ source.path = source.path.replace("./.codex/plugins/", "./plugins/");
265
+ }
266
+ return source
267
+ ? { ...(entry as Record<string, unknown>), source }
268
+ : entry;
269
+ });
270
+ }
271
+ return `${JSON.stringify(parsed, null, 2)}\n`;
272
+ } catch {
273
+ return text.endsWith("\n") ? text : `${text}\n`;
274
+ }
275
+ }
276
+
277
+ async function repairLegacyCodexAuthoringLayout(args: {
278
+ home: string;
279
+ rootDir: string;
280
+ }): Promise<{ changed: boolean; conflicts: string[] }> {
281
+ const liveRoot = projectRootFromAiRoot(args.rootDir, args.home) ?? args.home;
282
+ const legacySkillsDir = join(liveRoot, ".codex", "skills");
283
+ const preferredSkillsDir = join(liveRoot, ".agents", "skills");
284
+ const legacyPluginsDir = join(liveRoot, ".codex", "plugins");
285
+ const preferredPluginsDir = join(liveRoot, "plugins");
286
+ const marketplacePath = join(
287
+ liveRoot,
288
+ ".agents",
289
+ "plugins",
290
+ "marketplace.json"
291
+ );
292
+ const conflicts: string[] = [];
293
+ let changed = false;
294
+
295
+ if (await moveMissingTree(legacySkillsDir, preferredSkillsDir, conflicts)) {
296
+ changed = true;
297
+ }
298
+
299
+ if (await moveMissingTree(legacyPluginsDir, preferredPluginsDir, conflicts)) {
300
+ changed = true;
301
+ }
302
+
303
+ try {
304
+ const current = await readFile(marketplacePath, "utf8");
305
+ const normalized = normalizeCodexMarketplaceText(current);
306
+ if (normalized !== current) {
307
+ await mkdir(dirname(marketplacePath), { recursive: true });
308
+ await writeFile(marketplacePath, normalized, "utf8");
309
+ changed = true;
310
+ }
311
+ } catch {
312
+ // Ignore missing or unreadable marketplace files.
313
+ }
314
+
315
+ return { changed, conflicts };
316
+ }
317
+
318
+ async function listProjectSkillNames(rootDir: string): Promise<string[]> {
319
+ const skillsDir = join(rootDir, "skills");
320
+ const entries = await readdir(skillsDir, { withFileTypes: true }).catch(
321
+ () => [] as import("node:fs").Dirent[]
322
+ );
323
+ return entries
324
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
325
+ .map((entry) => entry.name)
326
+ .sort((a, b) => a.localeCompare(b));
327
+ }
328
+
329
+ async function listProjectAgentNames(rootDir: string): Promise<string[]> {
330
+ const agentsDir = join(rootDir, "agents");
331
+ const entries = await readdir(agentsDir, { withFileTypes: true }).catch(
332
+ () => [] as import("node:fs").Dirent[]
333
+ );
334
+ return entries
335
+ .flatMap((entry) => {
336
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
337
+ return [entry.name];
338
+ }
339
+ if (entry.isFile() && entry.name.endsWith(".toml")) {
340
+ return [entry.name.replace(TOML_FILE_SUFFIX_RE, "")];
341
+ }
342
+ return [];
343
+ })
344
+ .sort((a, b) => a.localeCompare(b));
345
+ }
346
+
347
+ async function listProjectMcpNames(rootDir: string): Promise<string[]> {
348
+ const trackedPaths = [
349
+ join(rootDir, "mcp", "servers.json"),
350
+ join(rootDir, "mcp", "mcp.json"),
351
+ ];
352
+
353
+ for (const candidate of trackedPaths) {
354
+ try {
355
+ const raw = await Bun.file(candidate).text();
356
+ const parsed = JSON.parse(raw) as unknown;
357
+ const servers = extractServersObject(parsed) ?? {};
358
+ return Object.keys(servers).sort((a, b) => a.localeCompare(b));
359
+ } catch {
360
+ // Try next candidate.
361
+ }
362
+ }
363
+
364
+ return [];
365
+ }
366
+
367
+ async function hasProjectGlobalDocs(rootDir: string): Promise<boolean> {
368
+ return (
369
+ (await pathExists(join(rootDir, "AGENTS.global.md"))) ||
370
+ (await pathExists(join(rootDir, "AGENTS.override.global.md")))
371
+ );
372
+ }
373
+
374
+ async function hasProjectToolRules(
375
+ rootDir: string,
376
+ tool: string
377
+ ): Promise<boolean> {
378
+ const rulesDir = join(rootDir, "tools", tool, "rules");
379
+ const entries = await readdir(rulesDir, { withFileTypes: true }).catch(
380
+ () => [] as import("node:fs").Dirent[]
381
+ );
382
+ return entries.some(
383
+ (entry) => entry.isFile() && entry.name.endsWith(".rules")
384
+ );
385
+ }
386
+
387
+ async function hasProjectToolConfig(
388
+ rootDir: string,
389
+ tool: string
390
+ ): Promise<boolean> {
391
+ return (
392
+ (await pathExists(join(rootDir, "tools", tool, "config.toml"))) ||
393
+ (await pathExists(join(rootDir, "tools", tool, "config.local.toml")))
394
+ );
395
+ }
396
+
397
+ async function planProjectSyncPolicyRepair(args: {
398
+ home: string;
399
+ rootDir: string;
400
+ }): Promise<{
401
+ needed: boolean;
402
+ toolPolicies: Record<
403
+ string,
404
+ {
405
+ skills?: string[];
406
+ agents?: string[];
407
+ mcpServers?: string[];
408
+ globalDocs?: boolean;
409
+ toolRules?: boolean;
410
+ toolConfig?: boolean;
411
+ }
412
+ >;
413
+ }> {
414
+ if (projectRootFromAiRoot(args.rootDir, args.home) == null) {
415
+ return { needed: false, toolPolicies: {} };
416
+ }
417
+
418
+ const managedState = await loadManagedState(args.home, args.rootDir);
419
+ const managedTools = Object.keys(managedState.tools).sort((a, b) =>
420
+ a.localeCompare(b)
421
+ );
422
+ if (managedTools.length === 0) {
423
+ return { needed: false, toolPolicies: {} };
424
+ }
425
+
426
+ const configuredTools = new Set(
427
+ await loadConfiguredProjectSyncTools({ rootDir: args.rootDir })
428
+ );
429
+ const [skills, agents, mcpServers, globalDocs] = await Promise.all([
430
+ listProjectSkillNames(args.rootDir),
431
+ listProjectAgentNames(args.rootDir),
432
+ listProjectMcpNames(args.rootDir),
433
+ hasProjectGlobalDocs(args.rootDir),
434
+ ]);
435
+
436
+ const toolPolicies: Record<
437
+ string,
438
+ {
439
+ skills?: string[];
440
+ agents?: string[];
441
+ mcpServers?: string[];
442
+ globalDocs?: boolean;
443
+ toolRules?: boolean;
444
+ toolConfig?: boolean;
445
+ }
446
+ > = {};
447
+
448
+ for (const tool of managedTools) {
449
+ if (configuredTools.has(tool)) {
450
+ continue;
451
+ }
452
+ const [toolRules, toolConfig] = await Promise.all([
453
+ hasProjectToolRules(args.rootDir, tool),
454
+ hasProjectToolConfig(args.rootDir, tool),
455
+ ]);
456
+
457
+ if (
458
+ skills.length === 0 &&
459
+ agents.length === 0 &&
460
+ mcpServers.length === 0 &&
461
+ !globalDocs &&
462
+ !toolRules &&
463
+ !toolConfig
464
+ ) {
465
+ continue;
466
+ }
467
+
468
+ toolPolicies[tool] = {
469
+ ...(skills.length > 0 ? { skills } : {}),
470
+ ...(agents.length > 0 ? { agents } : {}),
471
+ ...(mcpServers.length > 0 ? { mcpServers } : {}),
472
+ ...(globalDocs ? { globalDocs: true } : {}),
473
+ ...(toolRules ? { toolRules: true } : {}),
474
+ ...(toolConfig ? { toolConfig: true } : {}),
475
+ };
476
+ }
477
+
478
+ return {
479
+ needed: Object.keys(toolPolicies).length > 0,
480
+ toolPolicies,
481
+ };
482
+ }
483
+
484
+ async function repairProjectSyncPolicy(args: {
485
+ home: string;
486
+ rootDir: string;
487
+ }): Promise<{ changed: boolean; path?: string; tools: string[] }> {
488
+ const plan = await planProjectSyncPolicyRepair(args);
489
+ if (!plan.needed) {
490
+ return { changed: false, tools: [] };
491
+ }
492
+ const result = await writeProjectSyncPolicy({
493
+ rootDir: args.rootDir,
494
+ toolPolicies: plan.toolPolicies,
495
+ targetFile: "config.local.toml",
496
+ });
497
+ return {
498
+ changed: result.changed,
499
+ path: result.path,
500
+ tools: Object.keys(plan.toolPolicies).sort((a, b) => a.localeCompare(b)),
501
+ };
502
+ }
503
+
226
504
  function printHelp() {
227
505
  console.log(`fclt doctor — inspect and repair local fclt state
228
506
 
@@ -258,6 +536,12 @@ export async function doctorCommand(argv: string[]) {
258
536
  let stateRepaired = false;
259
537
  let stateConflicts: string[] = [];
260
538
  let autosyncRepaired = false;
539
+ let codexAuthoringRepaired = false;
540
+ let codexAuthoringConflicts: string[] = [];
541
+ let projectSyncRepairNeeded = false;
542
+ let projectSyncRepaired = false;
543
+ let projectSyncRepairTools: string[] = [];
544
+ let projectSyncRepairPath: string | undefined;
261
545
  if (repair) {
262
546
  rootConfigRepaired = await repairLegacyRootConfig(home);
263
547
  }
@@ -266,6 +550,28 @@ export async function doctorCommand(argv: string[]) {
266
550
  stateRepaired = stateRepair.changed;
267
551
  stateConflicts = stateRepair.conflicts;
268
552
  autosyncRepaired = await repairAutosyncServices(home, rootDir);
553
+ const authoringRepair = await repairLegacyCodexAuthoringLayout({
554
+ home,
555
+ rootDir,
556
+ });
557
+ codexAuthoringRepaired = authoringRepair.changed;
558
+ codexAuthoringConflicts = authoringRepair.conflicts;
559
+ const projectSyncRepair = await repairProjectSyncPolicy({
560
+ home,
561
+ rootDir,
562
+ });
563
+ projectSyncRepaired = projectSyncRepair.changed;
564
+ projectSyncRepairTools = projectSyncRepair.tools;
565
+ projectSyncRepairPath = projectSyncRepair.path;
566
+ } else {
567
+ const projectSyncPlan = await planProjectSyncPolicyRepair({
568
+ home,
569
+ rootDir,
570
+ });
571
+ projectSyncRepairNeeded = projectSyncPlan.needed;
572
+ projectSyncRepairTools = Object.keys(projectSyncPlan.toolPolicies).sort(
573
+ (a, b) => a.localeCompare(b)
574
+ );
269
575
  }
270
576
  const generated = facultAiIndexPath(home, rootDir);
271
577
  const generatedGraph = facultAiGraphPath(home, rootDir);
@@ -300,6 +606,27 @@ export async function doctorCommand(argv: string[]) {
300
606
  if (autosyncRepaired) {
301
607
  console.log("Repaired autosync launch agent configuration.");
302
608
  }
609
+ if (codexAuthoringRepaired) {
610
+ console.log(
611
+ "Migrated legacy Codex authoring paths to .agents/skills, .agents/plugins/marketplace.json, and plugins/."
612
+ );
613
+ }
614
+ if (codexAuthoringConflicts.length) {
615
+ console.log("Skipped conflicting Codex authoring paths:");
616
+ for (const conflict of codexAuthoringConflicts) {
617
+ console.log(`- ${conflict}`);
618
+ }
619
+ }
620
+ if (projectSyncRepaired && projectSyncRepairPath) {
621
+ console.log(
622
+ `Materialized explicit project sync policy in ${projectSyncRepairPath} for: ${projectSyncRepairTools.join(", ")}`
623
+ );
624
+ }
625
+ if (!repair && projectSyncRepairNeeded) {
626
+ console.log(
627
+ `Project sync is still implicit for managed tools (${projectSyncRepairTools.join(", ")}). Run \`fclt doctor --repair\` to write explicit [project_sync.<tool>] entries.`
628
+ );
629
+ }
303
630
 
304
631
  if (result.source === "generated") {
305
632
  console.log("AI index is healthy.");
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { renderCanonicalText } from "./agents";
4
4
  import { builtinSyncDefaultsEnabled, facultBuiltinPackRoot } from "./builtin";
5
5
  import { projectRootFromAiRoot } from "./paths";
6
+ import { projectSyncAllowsToolSurface } from "./project-sync";
6
7
  import { renderSnippetText } from "./snippets";
7
8
 
8
9
  export interface GlobalDocPlan {
@@ -156,13 +157,24 @@ function stringifyTomlObject(obj: Record<string, unknown>): string {
156
157
  }
157
158
 
158
159
  async function listGlobalDocSources(args: {
160
+ homeDir: string;
159
161
  rootDir: string;
160
162
  tool: string;
161
163
  toolHome: string;
162
164
  }): Promise<SourceTarget[]> {
163
- const { rootDir, tool, toolHome } = args;
165
+ const { homeDir, rootDir, tool, toolHome } = args;
166
+ if (
167
+ !(await projectSyncAllowsToolSurface({
168
+ homeDir,
169
+ rootDir,
170
+ tool,
171
+ surface: "globalDocs",
172
+ }))
173
+ ) {
174
+ return [];
175
+ }
164
176
  const targets = globalDocTargetPaths(tool, toolHome);
165
- const useBuiltinDefaults = await builtinSyncDefaultsEnabled(rootDir);
177
+ const useBuiltinDefaults = await builtinSyncDefaultsEnabled(rootDir, homeDir);
166
178
 
167
179
  const candidates: SourceTarget[] = [];
168
180
  const base = join(rootDir, "AGENTS.global.md");
@@ -309,9 +321,20 @@ export async function syncToolGlobalDocs(args: {
309
321
  }
310
322
 
311
323
  async function listToolRules(args: {
324
+ homeDir: string;
312
325
  rootDir: string;
313
326
  tool: string;
314
327
  }): Promise<{ sourcePath: string; targetPath: string }[]> {
328
+ if (
329
+ !(await projectSyncAllowsToolSurface({
330
+ homeDir: args.homeDir,
331
+ rootDir: args.rootDir,
332
+ tool: args.tool,
333
+ surface: "toolRules",
334
+ }))
335
+ ) {
336
+ return [];
337
+ }
315
338
  const sourceRoot = join(args.rootDir, "tools", args.tool, "rules");
316
339
  const entries = await readdir(sourceRoot, { withFileTypes: true }).catch(
317
340
  () => [] as import("node:fs").Dirent[]
@@ -425,6 +448,24 @@ export async function planToolConfigSync(args: {
425
448
  existingConfigPath?: string;
426
449
  previouslyManaged?: boolean;
427
450
  }): Promise<ToolConfigPlan> {
451
+ if (
452
+ !(await projectSyncAllowsToolSurface({
453
+ homeDir: args.homeDir,
454
+ rootDir: args.rootDir,
455
+ tool: args.tool,
456
+ surface: "toolConfig",
457
+ }))
458
+ ) {
459
+ return {
460
+ targetPath: args.toolConfigPath,
461
+ write: false,
462
+ remove: false,
463
+ contents: null,
464
+ sourcePath: join(args.rootDir, "tools", args.tool, "config.toml"),
465
+ managedConfig: false,
466
+ };
467
+ }
468
+
428
469
  const sourcePath = join(args.rootDir, "tools", args.tool, "config.toml");
429
470
  const localSourcePath = join(
430
471
  args.rootDir,