facult 1.1.0 → 1.2.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/manage.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ cp,
2
3
  lstat,
3
4
  mkdir,
4
5
  readdir,
@@ -12,12 +13,29 @@ import { basename, dirname, join } from "node:path";
12
13
  import { getAdapter } from "./adapters";
13
14
  import { renderCanonicalText } from "./agents";
14
15
  import { ensureAiIndexPath } from "./ai-state";
16
+ import { builtinSyncDefaultsEnabled, facultBuiltinPackRoot } from "./builtin";
17
+ import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
18
+ import { contentHash, normalizeText } from "./conflicts";
15
19
  import {
20
+ globalDocTargetPaths,
21
+ planToolConfigSync,
22
+ planToolGlobalDocsSync,
23
+ planToolRulesSync,
16
24
  syncToolConfig,
17
25
  syncToolGlobalDocs,
18
26
  syncToolRules,
19
27
  } from "./global-docs";
20
- import { facultRootDir } from "./paths";
28
+ import {
29
+ type AgentEntry,
30
+ buildIndex,
31
+ type FacultIndex,
32
+ type SkillEntry,
33
+ } from "./index-builder";
34
+ import {
35
+ facultGeneratedStateDir,
36
+ facultRootDir,
37
+ projectRootFromAiRoot,
38
+ } from "./paths";
21
39
 
22
40
  export interface ManagedToolState {
23
41
  tool: string;
@@ -37,6 +55,13 @@ export interface ManagedToolState {
37
55
  globalAgentsOverrideBackup?: string | null;
38
56
  rulesBackup?: string | null;
39
57
  toolConfigBackup?: string | null;
58
+ renderedTargets?: Record<string, ManagedRenderedTargetState>;
59
+ }
60
+
61
+ interface ManagedRenderedTargetState {
62
+ hash: string;
63
+ sourcePath: string;
64
+ sourceKind: "builtin" | "canonical";
40
65
  }
41
66
 
42
67
  export interface ManagedState {
@@ -59,6 +84,10 @@ export interface ManageOptions {
59
84
  rootDir?: string;
60
85
  toolPaths?: Record<string, ToolPaths>;
61
86
  now?: () => Date;
87
+ dryRun?: boolean;
88
+ adoptExisting?: boolean;
89
+ existingConflictMode?: "keep-canonical" | "keep-existing";
90
+ builtinConflictMode?: "warn" | "overwrite";
62
91
  }
63
92
 
64
93
  export interface SyncOptions {
@@ -66,6 +95,7 @@ export interface SyncOptions {
66
95
  rootDir?: string;
67
96
  tool?: string;
68
97
  dryRun?: boolean;
98
+ builtinConflictMode?: "warn" | "overwrite";
69
99
  }
70
100
 
71
101
  const MANAGED_VERSION = 1 as const;
@@ -101,26 +131,48 @@ async function ensureDir(p: string) {
101
131
  await mkdir(p, { recursive: true });
102
132
  }
103
133
 
104
- function defaultToolPaths(home: string): Record<string, ToolPaths> {
134
+ function renderedSourceKindForPath(
135
+ sourcePath: string
136
+ ): ManagedRenderedTargetState["sourceKind"] {
137
+ return sourcePath.startsWith(facultBuiltinPackRoot())
138
+ ? "builtin"
139
+ : "canonical";
140
+ }
141
+
142
+ function renderedHash(text: string): string {
143
+ return contentHash(normalizeText(text));
144
+ }
145
+
146
+ function defaultToolPaths(
147
+ home: string,
148
+ rootDir?: string
149
+ ): Record<string, ToolPaths> {
150
+ const projectRoot = rootDir ? projectRootFromAiRoot(rootDir, home) : null;
151
+ const toolBase = (...parts: string[]) =>
152
+ projectRoot ? join(projectRoot, ...parts) : homePath(home, ...parts);
105
153
  const defaults: Record<string, ToolPaths> = {
106
154
  cursor: {
107
155
  tool: "cursor",
108
- skillsDir: homePath(home, ".cursor", "skills"),
109
- mcpConfig: homePath(home, ".cursor", "mcp.json"),
156
+ skillsDir: toolBase(".cursor", "skills"),
157
+ toolHome: toolBase(".cursor"),
158
+ mcpConfig: toolBase(".cursor", "mcp.json"),
110
159
  },
111
160
  codex: {
112
161
  tool: "codex",
113
- skillsDir: homePath(home, ".codex", "skills"),
114
- mcpConfig: homePath(home, ".codex", "mcp.json"),
115
- agentsDir: homePath(home, ".codex", "agents"),
116
- toolHome: homePath(home, ".codex"),
117
- rulesDir: homePath(home, ".codex", "rules"),
118
- toolConfig: homePath(home, ".codex", "config.toml"),
162
+ skillsDir: toolBase(".codex", "skills"),
163
+ mcpConfig: toolBase(".codex", "mcp.json"),
164
+ agentsDir: toolBase(".codex", "agents"),
165
+ toolHome: toolBase(".codex"),
166
+ rulesDir: toolBase(".codex", "rules"),
167
+ toolConfig: toolBase(".codex", "config.toml"),
119
168
  },
120
169
  claude: {
121
170
  tool: "claude",
122
- skillsDir: homePath(home, ".claude", "skills"),
123
- mcpConfig: homePath(home, ".claude.json"),
171
+ skillsDir: toolBase(".claude", "skills"),
172
+ toolHome: toolBase(".claude"),
173
+ mcpConfig: projectRoot
174
+ ? toolBase(".claude", "mcp.json")
175
+ : homePath(home, ".claude.json"),
124
176
  },
125
177
  "claude-desktop": {
126
178
  tool: "claude-desktop",
@@ -134,22 +186,25 @@ function defaultToolPaths(home: string): Record<string, ToolPaths> {
134
186
  },
135
187
  clawdbot: {
136
188
  tool: "clawdbot",
137
- skillsDir: homePath(home, ".clawdbot", "skills"),
138
- mcpConfig: homePath(home, ".clawdbot", "mcp.json"),
189
+ skillsDir: toolBase(".clawdbot", "skills"),
190
+ mcpConfig: toolBase(".clawdbot", "mcp.json"),
139
191
  },
140
192
  gemini: {
141
193
  tool: "gemini",
142
- skillsDir: homePath(home, ".gemini", "skills"),
143
- mcpConfig: homePath(home, ".gemini", "mcp.json"),
194
+ skillsDir: toolBase(".gemini", "skills"),
195
+ mcpConfig: toolBase(".gemini", "mcp.json"),
144
196
  },
145
197
  antigravity: {
146
198
  tool: "antigravity",
147
- skillsDir: homePath(home, ".antigravity", "skills"),
148
- mcpConfig: homePath(home, ".antigravity", "mcp.json"),
199
+ skillsDir: toolBase(".antigravity", "skills"),
200
+ mcpConfig: toolBase(".antigravity", "mcp.json"),
149
201
  },
150
202
  };
151
203
 
152
204
  const adapterDefaults = (tool: string): ToolPaths | null => {
205
+ if (projectRoot) {
206
+ return null;
207
+ }
153
208
  const adapter = getAdapter(tool);
154
209
  if (!adapter?.getDefaultPaths) {
155
210
  return null;
@@ -188,17 +243,23 @@ function defaultToolPaths(home: string): Record<string, ToolPaths> {
188
243
  async function resolveToolPaths(
189
244
  tool: string,
190
245
  home: string,
246
+ rootDir?: string,
191
247
  override?: Record<string, ToolPaths>
192
248
  ): Promise<ToolPaths | null> {
249
+ const defaults = defaultToolPaths(home, rootDir);
193
250
  if (override?.[tool]) {
194
- return override[tool] ?? null;
251
+ const base = defaults[tool] ?? null;
252
+ return base ? { ...base, ...override[tool] } : (override[tool] ?? null);
195
253
  }
196
- const defaults = defaultToolPaths(home);
197
254
  const base = defaults[tool] ?? null;
198
255
  if (!base) {
199
256
  return null;
200
257
  }
201
258
  if (tool !== "codex") {
259
+ // Codex has built-in default global-doc, rules, and tool-config
260
+ // locations. Claude and Cursor have built-in global doc locations through
261
+ // the base defaults above. Other tools can still opt into additional
262
+ // file-backed surfaces explicitly via toolPaths overrides.
202
263
  return base;
203
264
  }
204
265
 
@@ -224,13 +285,21 @@ async function resolveToolPaths(
224
285
  }
225
286
 
226
287
  export function managedStatePath(home: string = homedir()): string {
227
- return homePath(home, ".facult", "managed.json");
288
+ return managedStatePathForRoot(home);
289
+ }
290
+
291
+ export function managedStatePathForRoot(
292
+ home: string = homedir(),
293
+ rootDir?: string
294
+ ): string {
295
+ return join(facultGeneratedStateDir({ home, rootDir }), "managed.json");
228
296
  }
229
297
 
230
298
  export async function loadManagedState(
231
- home: string = homedir()
299
+ home: string = homedir(),
300
+ rootDir?: string
232
301
  ): Promise<ManagedState> {
233
- const p = managedStatePath(home);
302
+ const p = managedStatePathForRoot(home, rootDir);
234
303
  if (!(await fileExists(p))) {
235
304
  return { version: MANAGED_VERSION, tools: {} };
236
305
  }
@@ -248,12 +317,13 @@ export async function loadManagedState(
248
317
 
249
318
  export async function saveManagedState(
250
319
  state: ManagedState,
251
- home: string = homedir()
320
+ home: string = homedir(),
321
+ rootDir?: string
252
322
  ) {
253
- const dir = homePath(home, ".facult");
323
+ const dir = facultGeneratedStateDir({ home, rootDir });
254
324
  await ensureDir(dir);
255
325
  await Bun.write(
256
- managedStatePath(home),
326
+ managedStatePathForRoot(home, rootDir),
257
327
  `${JSON.stringify(state, null, 2)}\n`
258
328
  );
259
329
  }
@@ -293,10 +363,20 @@ async function readTextIfExists(p: string): Promise<string | null> {
293
363
  return await Bun.file(p).text();
294
364
  }
295
365
 
296
- async function loadCanonicalAgents(
297
- rootDir: string
366
+ async function readTomlFile(
367
+ pathValue: string
368
+ ): Promise<Record<string, unknown> | null> {
369
+ const text = await readTextIfExists(pathValue);
370
+ if (text == null) {
371
+ return null;
372
+ }
373
+ const parsed = Bun.TOML.parse(text);
374
+ return isPlainObject(parsed) ? parsed : null;
375
+ }
376
+
377
+ async function loadAgentsFromRoot(
378
+ agentsRoot: string
298
379
  ): Promise<{ name: string; sourcePath: string; raw: string }[]> {
299
- const agentsRoot = homePath(rootDir, "agents");
300
380
  const entries = await readdir(agentsRoot, { withFileTypes: true }).catch(
301
381
  () => [] as import("node:fs").Dirent[]
302
382
  );
@@ -331,6 +411,95 @@ async function loadCanonicalAgents(
331
411
  return out.sort((a, b) => a.name.localeCompare(b.name));
332
412
  }
333
413
 
414
+ async function loadCanonicalAgents(
415
+ rootDir: string
416
+ ): Promise<{ name: string; sourcePath: string; raw: string }[]> {
417
+ return await loadAgentsFromRoot(homePath(rootDir, "agents"));
418
+ }
419
+
420
+ async function loadMergedIndex(
421
+ homeDir: string,
422
+ rootDir: string
423
+ ): Promise<FacultIndex> {
424
+ const { path: indexPath } = await ensureAiIndexPath({
425
+ homeDir,
426
+ rootDir,
427
+ repair: true,
428
+ });
429
+ if (!(await fileExists(indexPath))) {
430
+ await buildIndex({ homeDir, rootDir, force: false });
431
+ }
432
+ return JSON.parse(await Bun.file(indexPath).text()) as FacultIndex;
433
+ }
434
+
435
+ async function loadEnabledSkillEntries(args: {
436
+ homeDir: string;
437
+ rootDir: string;
438
+ tool: string;
439
+ }): Promise<{ name: string; path: string }[]> {
440
+ const index = await loadMergedIndex(args.homeDir, args.rootDir);
441
+ const useBuiltinDefaults = await builtinSyncDefaultsEnabled(args.rootDir);
442
+ const out: { name: string; path: string }[] = [];
443
+
444
+ for (const [name, entry] of Object.entries(index.skills)) {
445
+ const skill = entry as SkillEntry;
446
+ if (
447
+ !useBuiltinDefaults &&
448
+ skill.sourceKind === "builtin" &&
449
+ skill.sourceRoot?.includes("facult-operating-model")
450
+ ) {
451
+ continue;
452
+ }
453
+ if (
454
+ Array.isArray(skill.enabledFor) &&
455
+ !skill.enabledFor.includes(args.tool)
456
+ ) {
457
+ continue;
458
+ }
459
+ out.push({ name, path: skill.path });
460
+ }
461
+
462
+ if (out.length > 0) {
463
+ return out.sort((a, b) => a.name.localeCompare(b.name));
464
+ }
465
+
466
+ return (await listSkillDirs(join(args.rootDir, "skills"))).map((name) => ({
467
+ name,
468
+ path: join(args.rootDir, "skills", name),
469
+ }));
470
+ }
471
+
472
+ async function loadManagedAgentEntries(args: {
473
+ homeDir: string;
474
+ rootDir: string;
475
+ }): Promise<{ name: string; sourcePath: string; raw: string }[]> {
476
+ const index = await loadMergedIndex(args.homeDir, args.rootDir);
477
+ const useBuiltinDefaults = await builtinSyncDefaultsEnabled(args.rootDir);
478
+ const out: { name: string; sourcePath: string; raw: string }[] = [];
479
+
480
+ for (const [name, entry] of Object.entries(index.agents)) {
481
+ const agent = entry as AgentEntry;
482
+ if (
483
+ !useBuiltinDefaults &&
484
+ agent.sourceKind === "builtin" &&
485
+ agent.sourceRoot?.includes("facult-operating-model")
486
+ ) {
487
+ continue;
488
+ }
489
+ const raw = await readTextIfExists(agent.path);
490
+ if (raw == null) {
491
+ continue;
492
+ }
493
+ out.push({ name, sourcePath: agent.path, raw });
494
+ }
495
+
496
+ if (out.length > 0) {
497
+ return out.sort((a, b) => a.name.localeCompare(b.name));
498
+ }
499
+
500
+ return await loadCanonicalAgents(args.rootDir);
501
+ }
502
+
334
503
  async function planAgentFileChanges({
335
504
  agentsDir,
336
505
  homeDir,
@@ -345,9 +514,11 @@ async function planAgentFileChanges({
345
514
  add: string[];
346
515
  remove: string[];
347
516
  contents: Map<string, string>;
517
+ sources: Map<string, string>;
348
518
  }> {
349
- const agents = await loadCanonicalAgents(rootDir);
519
+ const agents = await loadManagedAgentEntries({ homeDir, rootDir });
350
520
  const contents = new Map<string, string>();
521
+ const sources = new Map<string, string>();
351
522
  const desiredPaths = new Set<string>();
352
523
 
353
524
  for (const agent of agents) {
@@ -355,11 +526,13 @@ async function planAgentFileChanges({
355
526
  const rendered = await renderCanonicalText(agent.raw, {
356
527
  homeDir,
357
528
  rootDir,
529
+ projectRoot: projectRootFromAiRoot(rootDir, homeDir) ?? undefined,
358
530
  targetTool: tool,
359
531
  targetPath: target,
360
532
  });
361
533
  desiredPaths.add(target);
362
534
  contents.set(target, rendered);
535
+ sources.set(target, agent.sourcePath);
363
536
  }
364
537
 
365
538
  const existing = await readdir(agentsDir, { withFileTypes: true }).catch(
@@ -396,6 +569,7 @@ async function planAgentFileChanges({
396
569
  add: Array.from(add).sort(),
397
570
  remove: Array.from(remove).sort(),
398
571
  contents,
572
+ sources,
399
573
  };
400
574
  }
401
575
 
@@ -446,31 +620,6 @@ async function listSkillDirs(skillsRoot: string): Promise<string[]> {
446
620
  }
447
621
  }
448
622
 
449
- function skillNamesFromIndex(
450
- indexData: Record<string, unknown>,
451
- tool: string
452
- ): string[] {
453
- const skills = indexData.skills as Record<string, unknown> | undefined;
454
- if (!skills) {
455
- return [];
456
- }
457
- const names: string[] = [];
458
- for (const [name, entry] of Object.entries(skills)) {
459
- if (!isPlainObject(entry)) {
460
- continue;
461
- }
462
- const enabledFor = entry.enabledFor;
463
- if (Array.isArray(enabledFor)) {
464
- if (enabledFor.includes(tool)) {
465
- names.push(name);
466
- }
467
- } else {
468
- names.push(name);
469
- }
470
- }
471
- return names.sort();
472
- }
473
-
474
623
  async function loadEnabledSkillNames({
475
624
  homeDir,
476
625
  rootDir,
@@ -480,24 +629,8 @@ async function loadEnabledSkillNames({
480
629
  rootDir: string;
481
630
  tool: string;
482
631
  }): Promise<string[]> {
483
- const { path: indexPath } = await ensureAiIndexPath({
484
- homeDir,
485
- rootDir,
486
- repair: true,
487
- });
488
- if (await fileExists(indexPath)) {
489
- try {
490
- const txt = await Bun.file(indexPath).text();
491
- const parsed = JSON.parse(txt) as Record<string, unknown>;
492
- const names = skillNamesFromIndex(parsed, tool);
493
- if (names.length) {
494
- return names;
495
- }
496
- } catch {
497
- // fallthrough to directory listing
498
- }
499
- }
500
- return await listSkillDirs(join(rootDir, "skills"));
632
+ const entries = await loadEnabledSkillEntries({ homeDir, rootDir, tool });
633
+ return entries.map((entry) => entry.name);
501
634
  }
502
635
 
503
636
  function extractServersObject(parsed: unknown): Record<string, unknown> | null {
@@ -589,6 +722,773 @@ async function ensureEmptyDir(p: string) {
589
722
  await ensureDir(p);
590
723
  }
591
724
 
725
+ async function adoptExistingToolSkills({
726
+ rootDir,
727
+ toolSkillsDir,
728
+ conflictMode,
729
+ }: {
730
+ rootDir: string;
731
+ toolSkillsDir: string;
732
+ conflictMode?: "keep-canonical" | "keep-existing";
733
+ }): Promise<{ adopted: string[]; skipped: string[] }> {
734
+ const adopted: string[] = [];
735
+ const skipped: string[] = [];
736
+
737
+ const entries = await readdir(toolSkillsDir, { withFileTypes: true }).catch(
738
+ () => [] as import("node:fs").Dirent[]
739
+ );
740
+ if (entries.length === 0) {
741
+ return { adopted, skipped };
742
+ }
743
+
744
+ const canonicalSkillsDir = join(rootDir, "skills");
745
+ await ensureDir(canonicalSkillsDir);
746
+
747
+ for (const entry of entries) {
748
+ if (!entry.isDirectory()) {
749
+ continue;
750
+ }
751
+ if (entry.name.startsWith(".")) {
752
+ skipped.push(entry.name);
753
+ continue;
754
+ }
755
+
756
+ const existingSkillDir = join(toolSkillsDir, entry.name);
757
+ const existingSkillFile = join(existingSkillDir, "SKILL.md");
758
+ if (!(await fileExists(existingSkillFile))) {
759
+ skipped.push(entry.name);
760
+ continue;
761
+ }
762
+
763
+ const canonicalSkillDir = join(canonicalSkillsDir, entry.name);
764
+ const canonicalSkillFile = join(canonicalSkillDir, "SKILL.md");
765
+ if (await fileExists(canonicalSkillFile)) {
766
+ if (conflictMode !== "keep-existing") {
767
+ skipped.push(entry.name);
768
+ continue;
769
+ }
770
+ await rm(canonicalSkillDir, { recursive: true, force: true });
771
+ await cp(existingSkillDir, canonicalSkillDir, { recursive: true });
772
+ adopted.push(entry.name);
773
+ continue;
774
+ }
775
+
776
+ await cp(existingSkillDir, canonicalSkillDir, { recursive: true });
777
+ adopted.push(entry.name);
778
+ }
779
+
780
+ return {
781
+ adopted: adopted.sort(),
782
+ skipped: skipped.sort(),
783
+ };
784
+ }
785
+
786
+ async function adoptSkillsIntoCanonicalStore(args: {
787
+ homeDir: string;
788
+ rootDir: string;
789
+ skillSourceDirs: string[];
790
+ }): Promise<string[]> {
791
+ const adopted = new Set<string>();
792
+
793
+ for (const dir of args.skillSourceDirs) {
794
+ if (!dir) {
795
+ continue;
796
+ }
797
+ const result = await adoptExistingToolSkills({
798
+ rootDir: args.rootDir,
799
+ toolSkillsDir: dir,
800
+ conflictMode: "keep-canonical",
801
+ });
802
+ for (const name of result.adopted) {
803
+ adopted.add(name);
804
+ }
805
+ }
806
+
807
+ if (adopted.size > 0) {
808
+ await buildIndex({
809
+ homeDir: args.homeDir,
810
+ rootDir: args.rootDir,
811
+ force: false,
812
+ });
813
+ }
814
+
815
+ return Array.from(adopted).sort();
816
+ }
817
+
818
+ interface ExistingSkillConflict {
819
+ name: string;
820
+ livePath: string;
821
+ canonicalPath: string;
822
+ }
823
+
824
+ interface ExistingSkillPlan {
825
+ adopt: string[];
826
+ identical: string[];
827
+ conflicts: ExistingSkillConflict[];
828
+ ignored: string[];
829
+ }
830
+
831
+ async function readTextOrNull(pathValue: string): Promise<string | null> {
832
+ if (!(await fileExists(pathValue))) {
833
+ return null;
834
+ }
835
+ return await Bun.file(pathValue).text();
836
+ }
837
+
838
+ async function planExistingToolSkillAdoption(args: {
839
+ rootDir: string;
840
+ toolSkillsDir: string;
841
+ }): Promise<ExistingSkillPlan> {
842
+ const adopt: string[] = [];
843
+ const identical: string[] = [];
844
+ const conflicts: ExistingSkillConflict[] = [];
845
+ const ignored: string[] = [];
846
+
847
+ const entries = await readdir(args.toolSkillsDir, {
848
+ withFileTypes: true,
849
+ }).catch(() => [] as import("node:fs").Dirent[]);
850
+
851
+ for (const entry of entries) {
852
+ if (!entry.isDirectory()) {
853
+ continue;
854
+ }
855
+ if (entry.name.startsWith(".")) {
856
+ ignored.push(entry.name);
857
+ continue;
858
+ }
859
+
860
+ const liveSkillDir = join(args.toolSkillsDir, entry.name);
861
+ const liveSkillFile = join(liveSkillDir, "SKILL.md");
862
+ if (!(await fileExists(liveSkillFile))) {
863
+ ignored.push(entry.name);
864
+ continue;
865
+ }
866
+
867
+ const canonicalSkillDir = join(args.rootDir, "skills", entry.name);
868
+ const canonicalSkillFile = join(canonicalSkillDir, "SKILL.md");
869
+ if (!(await fileExists(canonicalSkillFile))) {
870
+ adopt.push(entry.name);
871
+ continue;
872
+ }
873
+
874
+ const [liveText, canonicalText] = await Promise.all([
875
+ readTextOrNull(liveSkillFile),
876
+ readTextOrNull(canonicalSkillFile),
877
+ ]);
878
+ if (liveText === canonicalText) {
879
+ identical.push(entry.name);
880
+ continue;
881
+ }
882
+
883
+ conflicts.push({
884
+ name: entry.name,
885
+ livePath: liveSkillDir,
886
+ canonicalPath: canonicalSkillDir,
887
+ });
888
+ }
889
+
890
+ return {
891
+ adopt: adopt.sort(),
892
+ identical: identical.sort(),
893
+ conflicts: conflicts.sort((a, b) => a.name.localeCompare(b.name)),
894
+ ignored: ignored.sort(),
895
+ };
896
+ }
897
+
898
+ function logManagePreflight(tool: string, plan: ExistingSkillPlan) {
899
+ if (
900
+ plan.adopt.length === 0 &&
901
+ plan.conflicts.length === 0 &&
902
+ plan.identical.length === 0 &&
903
+ plan.ignored.length === 0
904
+ ) {
905
+ console.log(`${tool}: no existing tool-native skills detected`);
906
+ return;
907
+ }
908
+ for (const name of plan.adopt) {
909
+ console.log(
910
+ `${tool}: would adopt existing skill ${name} into canonical store`
911
+ );
912
+ }
913
+ for (const name of plan.identical) {
914
+ console.log(
915
+ `${tool}: existing skill ${name} already matches canonical store`
916
+ );
917
+ }
918
+ for (const conflict of plan.conflicts) {
919
+ console.log(
920
+ `${tool}: conflict for skill ${conflict.name} (live ${conflict.livePath} vs canonical ${conflict.canonicalPath})`
921
+ );
922
+ }
923
+ for (const name of plan.ignored) {
924
+ console.log(`${tool}: would ignore existing entry ${name}`);
925
+ }
926
+ }
927
+
928
+ interface ExistingManagedItem {
929
+ kind:
930
+ | "skill"
931
+ | "agent"
932
+ | "global-doc"
933
+ | "rule"
934
+ | "tool-config"
935
+ | "mcp-server";
936
+ name: string;
937
+ livePath: string;
938
+ canonicalPath: string;
939
+ }
940
+
941
+ interface ExistingManagedImportPlan {
942
+ adopt: ExistingManagedItem[];
943
+ identical: ExistingManagedItem[];
944
+ conflicts: ExistingManagedItem[];
945
+ ignored: ExistingManagedItem[];
946
+ }
947
+
948
+ function emptyManagedImportPlan(): ExistingManagedImportPlan {
949
+ return {
950
+ adopt: [],
951
+ identical: [],
952
+ conflicts: [],
953
+ ignored: [],
954
+ };
955
+ }
956
+
957
+ function mergeManagedImportPlans(
958
+ ...plans: ExistingManagedImportPlan[]
959
+ ): ExistingManagedImportPlan {
960
+ const merged = emptyManagedImportPlan();
961
+ for (const plan of plans) {
962
+ merged.adopt.push(...plan.adopt);
963
+ merged.identical.push(...plan.identical);
964
+ merged.conflicts.push(...plan.conflicts);
965
+ merged.ignored.push(...plan.ignored);
966
+ }
967
+ const sortItems = (items: ExistingManagedItem[]) =>
968
+ items.sort((a, b) =>
969
+ `${a.kind}:${a.name}`.localeCompare(`${b.kind}:${b.name}`)
970
+ );
971
+ return {
972
+ adopt: sortItems(merged.adopt),
973
+ identical: sortItems(merged.identical),
974
+ conflicts: sortItems(merged.conflicts),
975
+ ignored: sortItems(merged.ignored),
976
+ };
977
+ }
978
+
979
+ function asManagedSkillPlan(
980
+ plan: ExistingSkillPlan
981
+ ): ExistingManagedImportPlan {
982
+ return {
983
+ adopt: plan.adopt.map((name) => ({
984
+ kind: "skill" as const,
985
+ name,
986
+ livePath: "",
987
+ canonicalPath: "",
988
+ })),
989
+ identical: plan.identical.map((name) => ({
990
+ kind: "skill" as const,
991
+ name,
992
+ livePath: "",
993
+ canonicalPath: "",
994
+ })),
995
+ conflicts: plan.conflicts.map((item) => ({
996
+ kind: "skill" as const,
997
+ name: item.name,
998
+ livePath: item.livePath,
999
+ canonicalPath: item.canonicalPath,
1000
+ })),
1001
+ ignored: plan.ignored.map((name) => ({
1002
+ kind: "skill" as const,
1003
+ name,
1004
+ livePath: "",
1005
+ canonicalPath: "",
1006
+ })),
1007
+ };
1008
+ }
1009
+
1010
+ function formatManagedItem(item: ExistingManagedItem): string {
1011
+ return item.kind === "global-doc" || item.kind === "tool-config"
1012
+ ? `${item.kind}:${item.name}`
1013
+ : `${item.kind}:${item.name}`;
1014
+ }
1015
+
1016
+ function logManagedImportPlan(tool: string, plan: ExistingManagedImportPlan) {
1017
+ if (
1018
+ plan.adopt.length === 0 &&
1019
+ plan.identical.length === 0 &&
1020
+ plan.conflicts.length === 0 &&
1021
+ plan.ignored.length === 0
1022
+ ) {
1023
+ console.log(`${tool}: no existing managed content detected`);
1024
+ return;
1025
+ }
1026
+ for (const item of plan.adopt) {
1027
+ console.log(
1028
+ `${tool}: would adopt existing ${formatManagedItem(item)} into canonical store`
1029
+ );
1030
+ }
1031
+ for (const item of plan.identical) {
1032
+ console.log(
1033
+ `${tool}: existing ${formatManagedItem(item)} already matches canonical store`
1034
+ );
1035
+ }
1036
+ for (const item of plan.conflicts) {
1037
+ console.log(
1038
+ `${tool}: conflict for ${formatManagedItem(item)} (live ${item.livePath} vs canonical ${item.canonicalPath})`
1039
+ );
1040
+ }
1041
+ for (const item of plan.ignored) {
1042
+ console.log(`${tool}: would ignore existing ${formatManagedItem(item)}`);
1043
+ }
1044
+ }
1045
+
1046
+ async function planExistingToolAgentAdoption(args: {
1047
+ rootDir: string;
1048
+ agentsDir: string;
1049
+ }): Promise<ExistingManagedImportPlan> {
1050
+ const plan = emptyManagedImportPlan();
1051
+ const agents = await loadAgentsFromRoot(args.agentsDir);
1052
+ for (const agent of agents) {
1053
+ const canonicalPath = join(
1054
+ args.rootDir,
1055
+ "agents",
1056
+ agent.name,
1057
+ "agent.toml"
1058
+ );
1059
+ const canonicalRaw = await readTextOrNull(canonicalPath);
1060
+ const item: ExistingManagedItem = {
1061
+ kind: "agent",
1062
+ name: agent.name,
1063
+ livePath: agent.sourcePath,
1064
+ canonicalPath,
1065
+ };
1066
+ if (canonicalRaw == null) {
1067
+ plan.adopt.push(item);
1068
+ } else if (canonicalRaw === agent.raw) {
1069
+ plan.identical.push(item);
1070
+ } else {
1071
+ plan.conflicts.push(item);
1072
+ }
1073
+ }
1074
+ return mergeManagedImportPlans(plan);
1075
+ }
1076
+
1077
+ async function adoptExistingToolAgents(args: {
1078
+ rootDir: string;
1079
+ agentsDir: string;
1080
+ conflictMode: "keep-canonical" | "keep-existing";
1081
+ }): Promise<ExistingManagedItem[]> {
1082
+ const adopted: ExistingManagedItem[] = [];
1083
+ const agents = await loadAgentsFromRoot(args.agentsDir);
1084
+ for (const agent of agents) {
1085
+ const canonicalPath = join(
1086
+ args.rootDir,
1087
+ "agents",
1088
+ agent.name,
1089
+ "agent.toml"
1090
+ );
1091
+ const canonicalRaw = await readTextOrNull(canonicalPath);
1092
+ if (canonicalRaw != null && args.conflictMode !== "keep-existing") {
1093
+ continue;
1094
+ }
1095
+ await ensureDir(dirname(canonicalPath));
1096
+ await Bun.write(
1097
+ canonicalPath,
1098
+ agent.raw.endsWith("\n") ? agent.raw : `${agent.raw}\n`
1099
+ );
1100
+ adopted.push({
1101
+ kind: "agent",
1102
+ name: agent.name,
1103
+ livePath: agent.sourcePath,
1104
+ canonicalPath,
1105
+ });
1106
+ }
1107
+ return adopted;
1108
+ }
1109
+
1110
+ async function planExistingGlobalDocAdoption(args: {
1111
+ rootDir: string;
1112
+ tool: string;
1113
+ toolHome: string;
1114
+ }): Promise<ExistingManagedImportPlan> {
1115
+ const targets = globalDocTargetPaths(args.tool, args.toolHome);
1116
+ const mappings = [
1117
+ {
1118
+ name: basename(targets.primary),
1119
+ livePath: targets.primary,
1120
+ canonicalPath: join(args.rootDir, "AGENTS.global.md"),
1121
+ },
1122
+ ...(targets.override
1123
+ ? [
1124
+ {
1125
+ name: basename(targets.override),
1126
+ livePath: targets.override,
1127
+ canonicalPath: join(args.rootDir, "AGENTS.override.global.md"),
1128
+ },
1129
+ ]
1130
+ : []),
1131
+ ];
1132
+ const plan = emptyManagedImportPlan();
1133
+ for (const mapping of mappings) {
1134
+ const liveRaw = await readTextOrNull(mapping.livePath);
1135
+ if (liveRaw == null) {
1136
+ continue;
1137
+ }
1138
+ const canonicalRaw = await readTextOrNull(mapping.canonicalPath);
1139
+ const item: ExistingManagedItem = {
1140
+ kind: "global-doc",
1141
+ name: mapping.name,
1142
+ livePath: mapping.livePath,
1143
+ canonicalPath: mapping.canonicalPath,
1144
+ };
1145
+ if (canonicalRaw == null) {
1146
+ plan.adopt.push(item);
1147
+ } else if (canonicalRaw === liveRaw) {
1148
+ plan.identical.push(item);
1149
+ } else {
1150
+ plan.conflicts.push(item);
1151
+ }
1152
+ }
1153
+ return mergeManagedImportPlans(plan);
1154
+ }
1155
+
1156
+ async function adoptExistingGlobalDocs(args: {
1157
+ rootDir: string;
1158
+ tool: string;
1159
+ toolHome: string;
1160
+ conflictMode: "keep-canonical" | "keep-existing";
1161
+ }): Promise<ExistingManagedItem[]> {
1162
+ const adopted: ExistingManagedItem[] = [];
1163
+ const targets = globalDocTargetPaths(args.tool, args.toolHome);
1164
+ const mappings = [
1165
+ {
1166
+ name: basename(targets.primary),
1167
+ livePath: targets.primary,
1168
+ canonicalPath: join(args.rootDir, "AGENTS.global.md"),
1169
+ },
1170
+ ...(targets.override
1171
+ ? [
1172
+ {
1173
+ name: basename(targets.override),
1174
+ livePath: targets.override,
1175
+ canonicalPath: join(args.rootDir, "AGENTS.override.global.md"),
1176
+ },
1177
+ ]
1178
+ : []),
1179
+ ];
1180
+ for (const mapping of mappings) {
1181
+ const liveRaw = await readTextOrNull(mapping.livePath);
1182
+ if (liveRaw == null) {
1183
+ continue;
1184
+ }
1185
+ if (
1186
+ (await readTextOrNull(mapping.canonicalPath)) != null &&
1187
+ args.conflictMode !== "keep-existing"
1188
+ ) {
1189
+ continue;
1190
+ }
1191
+ await ensureDir(dirname(mapping.canonicalPath));
1192
+ await Bun.write(
1193
+ mapping.canonicalPath,
1194
+ liveRaw.endsWith("\n") ? liveRaw : `${liveRaw}\n`
1195
+ );
1196
+ adopted.push({
1197
+ kind: "global-doc",
1198
+ name: mapping.name,
1199
+ livePath: mapping.livePath,
1200
+ canonicalPath: mapping.canonicalPath,
1201
+ });
1202
+ }
1203
+ return adopted;
1204
+ }
1205
+
1206
+ async function adoptExistingGlobalDocFile(args: {
1207
+ sourcePath: string;
1208
+ canonicalPath: string;
1209
+ name: string;
1210
+ conflictMode: "keep-canonical" | "keep-existing";
1211
+ }): Promise<ExistingManagedItem[]> {
1212
+ const liveRaw = await readTextOrNull(args.sourcePath);
1213
+ if (liveRaw == null) {
1214
+ return [];
1215
+ }
1216
+ if (
1217
+ (await readTextOrNull(args.canonicalPath)) != null &&
1218
+ args.conflictMode !== "keep-existing"
1219
+ ) {
1220
+ return [];
1221
+ }
1222
+ await ensureDir(dirname(args.canonicalPath));
1223
+ await Bun.write(
1224
+ args.canonicalPath,
1225
+ liveRaw.endsWith("\n") ? liveRaw : `${liveRaw}\n`
1226
+ );
1227
+ return [
1228
+ {
1229
+ kind: "global-doc",
1230
+ name: args.name,
1231
+ livePath: args.sourcePath,
1232
+ canonicalPath: args.canonicalPath,
1233
+ },
1234
+ ];
1235
+ }
1236
+
1237
+ async function planExistingRuleAdoption(args: {
1238
+ rootDir: string;
1239
+ tool: string;
1240
+ rulesDir: string;
1241
+ }): Promise<ExistingManagedImportPlan> {
1242
+ const plan = emptyManagedImportPlan();
1243
+ const entries = await readdir(args.rulesDir, { withFileTypes: true }).catch(
1244
+ () => [] as import("node:fs").Dirent[]
1245
+ );
1246
+ for (const entry of entries) {
1247
+ if (!(entry.isFile() && entry.name.endsWith(".rules"))) {
1248
+ continue;
1249
+ }
1250
+ const livePath = join(args.rulesDir, entry.name);
1251
+ const canonicalPath = join(
1252
+ args.rootDir,
1253
+ "tools",
1254
+ args.tool,
1255
+ "rules",
1256
+ entry.name
1257
+ );
1258
+ const liveRaw = await readTextOrNull(livePath);
1259
+ if (liveRaw == null) {
1260
+ continue;
1261
+ }
1262
+ const canonicalRaw = await readTextOrNull(canonicalPath);
1263
+ const item: ExistingManagedItem = {
1264
+ kind: "rule",
1265
+ name: entry.name,
1266
+ livePath,
1267
+ canonicalPath,
1268
+ };
1269
+ if (canonicalRaw == null) {
1270
+ plan.adopt.push(item);
1271
+ } else if (canonicalRaw === liveRaw) {
1272
+ plan.identical.push(item);
1273
+ } else {
1274
+ plan.conflicts.push(item);
1275
+ }
1276
+ }
1277
+ return mergeManagedImportPlans(plan);
1278
+ }
1279
+
1280
+ async function adoptExistingRules(args: {
1281
+ rootDir: string;
1282
+ tool: string;
1283
+ rulesDir: string;
1284
+ conflictMode: "keep-canonical" | "keep-existing";
1285
+ }): Promise<ExistingManagedItem[]> {
1286
+ const adopted: ExistingManagedItem[] = [];
1287
+ const entries = await readdir(args.rulesDir, { withFileTypes: true }).catch(
1288
+ () => [] as import("node:fs").Dirent[]
1289
+ );
1290
+ for (const entry of entries) {
1291
+ if (!(entry.isFile() && entry.name.endsWith(".rules"))) {
1292
+ continue;
1293
+ }
1294
+ const livePath = join(args.rulesDir, entry.name);
1295
+ const canonicalPath = join(
1296
+ args.rootDir,
1297
+ "tools",
1298
+ args.tool,
1299
+ "rules",
1300
+ entry.name
1301
+ );
1302
+ const liveRaw = await readTextOrNull(livePath);
1303
+ if (liveRaw == null) {
1304
+ continue;
1305
+ }
1306
+ if (
1307
+ (await readTextOrNull(canonicalPath)) != null &&
1308
+ args.conflictMode !== "keep-existing"
1309
+ ) {
1310
+ continue;
1311
+ }
1312
+ await ensureDir(dirname(canonicalPath));
1313
+ await Bun.write(
1314
+ canonicalPath,
1315
+ liveRaw.endsWith("\n") ? liveRaw : `${liveRaw}\n`
1316
+ );
1317
+ adopted.push({
1318
+ kind: "rule",
1319
+ name: entry.name,
1320
+ livePath,
1321
+ canonicalPath,
1322
+ });
1323
+ }
1324
+ return adopted;
1325
+ }
1326
+
1327
+ async function planExistingToolConfigAdoption(args: {
1328
+ rootDir: string;
1329
+ tool: string;
1330
+ toolConfigPath: string;
1331
+ }): Promise<ExistingManagedImportPlan> {
1332
+ const plan = emptyManagedImportPlan();
1333
+ const liveRaw = await readTextOrNull(args.toolConfigPath);
1334
+ if (liveRaw == null) {
1335
+ return plan;
1336
+ }
1337
+ const canonicalPath = join(args.rootDir, "tools", args.tool, "config.toml");
1338
+ const canonicalRaw = await readTextOrNull(canonicalPath);
1339
+ const item: ExistingManagedItem = {
1340
+ kind: "tool-config",
1341
+ name: `${args.tool}/config.toml`,
1342
+ livePath: args.toolConfigPath,
1343
+ canonicalPath,
1344
+ };
1345
+ if (canonicalRaw == null) {
1346
+ plan.adopt.push(item);
1347
+ } else if (canonicalRaw === liveRaw) {
1348
+ plan.identical.push(item);
1349
+ } else {
1350
+ plan.conflicts.push(item);
1351
+ }
1352
+ return plan;
1353
+ }
1354
+
1355
+ async function adoptExistingToolConfig(args: {
1356
+ rootDir: string;
1357
+ tool: string;
1358
+ toolConfigPath: string;
1359
+ conflictMode: "keep-canonical" | "keep-existing";
1360
+ }): Promise<ExistingManagedItem[]> {
1361
+ const liveRaw = await readTextOrNull(args.toolConfigPath);
1362
+ if (liveRaw == null) {
1363
+ return [];
1364
+ }
1365
+ const canonicalPath = join(args.rootDir, "tools", args.tool, "config.toml");
1366
+ if (
1367
+ (await readTextOrNull(canonicalPath)) != null &&
1368
+ args.conflictMode !== "keep-existing"
1369
+ ) {
1370
+ return [];
1371
+ }
1372
+ await ensureDir(dirname(canonicalPath));
1373
+ await Bun.write(
1374
+ canonicalPath,
1375
+ liveRaw.endsWith("\n") ? liveRaw : `${liveRaw}\n`
1376
+ );
1377
+ return [
1378
+ {
1379
+ kind: "tool-config",
1380
+ name: `${args.tool}/config.toml`,
1381
+ livePath: args.toolConfigPath,
1382
+ canonicalPath,
1383
+ },
1384
+ ];
1385
+ }
1386
+
1387
+ function normalizeCanonicalMcpServers(
1388
+ servers: Record<string, unknown>
1389
+ ): string {
1390
+ return JSON.stringify({ servers }, null, 2);
1391
+ }
1392
+
1393
+ async function planExistingMcpAdoption(args: {
1394
+ rootDir: string;
1395
+ tool: string;
1396
+ mcpConfigPath: string;
1397
+ }): Promise<ExistingManagedImportPlan> {
1398
+ const plan = emptyManagedImportPlan();
1399
+ const liveConfig = await readTomlFile(args.mcpConfigPath).catch(() => null);
1400
+ const liveRawJson = await readTextIfExists(args.mcpConfigPath);
1401
+ let liveServers: Record<string, unknown> | null = null;
1402
+ if (liveConfig) {
1403
+ liveServers = extractServersObject(liveConfig);
1404
+ }
1405
+ if (!liveServers && liveRawJson != null) {
1406
+ try {
1407
+ liveServers = extractServersObject(JSON.parse(liveRawJson));
1408
+ } catch {
1409
+ liveServers = null;
1410
+ }
1411
+ }
1412
+ if (!liveServers || Object.keys(liveServers).length === 0) {
1413
+ return plan;
1414
+ }
1415
+ const canonical = await loadCanonicalServers(args.rootDir);
1416
+ const canonicalPath =
1417
+ canonical.sourcePath ?? join(args.rootDir, "mcp", "servers.json");
1418
+ for (const [name, definition] of Object.entries(liveServers)) {
1419
+ const item: ExistingManagedItem = {
1420
+ kind: "mcp-server",
1421
+ name,
1422
+ livePath: args.mcpConfigPath,
1423
+ canonicalPath,
1424
+ };
1425
+ if (!(name in canonical.servers)) {
1426
+ plan.adopt.push(item);
1427
+ continue;
1428
+ }
1429
+ if (
1430
+ JSON.stringify(canonical.servers[name]) === JSON.stringify(definition)
1431
+ ) {
1432
+ plan.identical.push(item);
1433
+ } else {
1434
+ plan.conflicts.push(item);
1435
+ }
1436
+ }
1437
+ return plan;
1438
+ }
1439
+
1440
+ async function adoptExistingMcpServers(args: {
1441
+ rootDir: string;
1442
+ tool: string;
1443
+ mcpConfigPath: string;
1444
+ conflictMode: "keep-canonical" | "keep-existing";
1445
+ }): Promise<ExistingManagedItem[]> {
1446
+ const liveRaw = await readTextIfExists(args.mcpConfigPath);
1447
+ if (liveRaw == null) {
1448
+ return [];
1449
+ }
1450
+ let liveServers: Record<string, unknown> | null = null;
1451
+ try {
1452
+ liveServers = extractServersObject(Bun.TOML.parse(liveRaw));
1453
+ } catch {
1454
+ liveServers = null;
1455
+ }
1456
+ if (!liveServers) {
1457
+ try {
1458
+ liveServers = extractServersObject(JSON.parse(liveRaw));
1459
+ } catch {
1460
+ liveServers = null;
1461
+ }
1462
+ }
1463
+ if (!liveServers) {
1464
+ return [];
1465
+ }
1466
+
1467
+ const canonical = await loadCanonicalServers(args.rootDir);
1468
+ const merged = { ...canonical.servers };
1469
+ const adopted: ExistingManagedItem[] = [];
1470
+ for (const [name, definition] of Object.entries(liveServers)) {
1471
+ if (!(name in merged) || args.conflictMode === "keep-existing") {
1472
+ merged[name] = definition;
1473
+ adopted.push({
1474
+ kind: "mcp-server",
1475
+ name,
1476
+ livePath: args.mcpConfigPath,
1477
+ canonicalPath:
1478
+ canonical.sourcePath ?? join(args.rootDir, "mcp", "servers.json"),
1479
+ });
1480
+ }
1481
+ }
1482
+ if (adopted.length === 0) {
1483
+ return [];
1484
+ }
1485
+ const canonicalPath =
1486
+ canonical.sourcePath ?? join(args.rootDir, "mcp", "servers.json");
1487
+ await ensureDir(dirname(canonicalPath));
1488
+ await Bun.write(canonicalPath, `${normalizeCanonicalMcpServers(merged)}\n`);
1489
+ return adopted;
1490
+ }
1491
+
592
1492
  async function createSkillSymlinks({
593
1493
  homeDir,
594
1494
  toolSkillsDir,
@@ -601,17 +1501,17 @@ async function createSkillSymlinks({
601
1501
  tool: string;
602
1502
  }) {
603
1503
  await ensureDir(toolSkillsDir);
604
- const skillNames = await loadEnabledSkillNames({
1504
+ const skills = await loadEnabledSkillEntries({
605
1505
  homeDir,
606
1506
  rootDir,
607
1507
  tool,
608
1508
  });
609
- for (const name of skillNames) {
610
- const target = join(rootDir, "skills", name);
1509
+ for (const skill of skills) {
1510
+ const target = skill.path;
611
1511
  if (!(await fileExists(target))) {
612
1512
  continue;
613
1513
  }
614
- const linkPath = join(toolSkillsDir, name);
1514
+ const linkPath = join(toolSkillsDir, skill.name);
615
1515
  try {
616
1516
  const st = await lstat(linkPath);
617
1517
  if (st.isSymbolicLink()) {
@@ -625,6 +1525,34 @@ async function createSkillSymlinks({
625
1525
  }
626
1526
  }
627
1527
 
1528
+ function isPreservedToolSkillEntry(name: string): boolean {
1529
+ return name.startsWith(".");
1530
+ }
1531
+
1532
+ async function restorePreservedToolSkillEntries({
1533
+ backupDir,
1534
+ toolSkillsDir,
1535
+ }: {
1536
+ backupDir: string | null | undefined;
1537
+ toolSkillsDir: string;
1538
+ }) {
1539
+ if (!(backupDir && (await fileExists(backupDir)))) {
1540
+ return;
1541
+ }
1542
+ const entries = await readdir(backupDir, { withFileTypes: true }).catch(
1543
+ () => [] as import("node:fs").Dirent[]
1544
+ );
1545
+ for (const entry of entries) {
1546
+ if (!isPreservedToolSkillEntry(entry.name)) {
1547
+ continue;
1548
+ }
1549
+ const source = join(backupDir, entry.name);
1550
+ const target = join(toolSkillsDir, entry.name);
1551
+ await rm(target, { recursive: true, force: true });
1552
+ await cp(source, target, { recursive: true });
1553
+ }
1554
+ }
1555
+
628
1556
  async function planSkillSymlinkChanges({
629
1557
  homeDir,
630
1558
  toolSkillsDir,
@@ -636,8 +1564,15 @@ async function planSkillSymlinkChanges({
636
1564
  rootDir: string;
637
1565
  tool: string;
638
1566
  }): Promise<{ add: string[]; remove: string[] }> {
639
- const desired = await loadEnabledSkillNames({ homeDir, rootDir, tool });
640
- const desiredSet = new Set(desired);
1567
+ const desiredEntries = await loadEnabledSkillEntries({
1568
+ homeDir,
1569
+ rootDir,
1570
+ tool,
1571
+ });
1572
+ const desiredTargets = new Map(
1573
+ desiredEntries.map((entry) => [entry.name, entry.path])
1574
+ );
1575
+ const desiredSet = new Set(desiredEntries.map((entry) => entry.name));
641
1576
  const existing = await readdir(toolSkillsDir, { withFileTypes: true }).catch(
642
1577
  () => [] as import("node:fs").Dirent[]
643
1578
  );
@@ -646,12 +1581,19 @@ async function planSkillSymlinkChanges({
646
1581
  const add: string[] = [];
647
1582
 
648
1583
  for (const entry of existing) {
1584
+ if (isPreservedToolSkillEntry(entry.name)) {
1585
+ continue;
1586
+ }
649
1587
  if (!desiredSet.has(entry.name)) {
650
1588
  remove.push(entry.name);
651
1589
  continue;
652
1590
  }
653
1591
  const linkPath = join(toolSkillsDir, entry.name);
654
- const target = join(rootDir, "skills", entry.name);
1592
+ const target = desiredTargets.get(entry.name);
1593
+ if (!target) {
1594
+ remove.push(entry.name);
1595
+ continue;
1596
+ }
655
1597
  try {
656
1598
  const st = await lstat(linkPath);
657
1599
  if (!st.isSymbolicLink()) {
@@ -669,12 +1611,11 @@ async function planSkillSymlinkChanges({
669
1611
  }
670
1612
  }
671
1613
 
672
- for (const name of desired) {
1614
+ for (const { name, path } of desiredEntries) {
673
1615
  if (existing.find((entry) => entry.name === name)) {
674
1616
  continue;
675
1617
  }
676
- const target = join(rootDir, "skills", name);
677
- if (await fileExists(target)) {
1618
+ if (await fileExists(path)) {
678
1619
  add.push(name);
679
1620
  }
680
1621
  }
@@ -708,14 +1649,24 @@ async function syncSkillSymlinks({
708
1649
  return plan;
709
1650
  }
710
1651
 
1652
+ const desiredSkills = new Map(
1653
+ (
1654
+ await loadEnabledSkillEntries({
1655
+ homeDir,
1656
+ rootDir,
1657
+ tool,
1658
+ })
1659
+ ).map((entry) => [entry.name, entry.path])
1660
+ );
1661
+
711
1662
  await ensureDir(toolSkillsDir);
712
1663
  for (const name of plan.remove) {
713
1664
  const linkPath = join(toolSkillsDir, name);
714
1665
  await rm(linkPath, { recursive: true, force: true });
715
1666
  }
716
1667
  for (const name of plan.add) {
717
- const target = join(rootDir, "skills", name);
718
- if (!(await fileExists(target))) {
1668
+ const target = desiredSkills.get(name);
1669
+ if (!(target && (await fileExists(target)))) {
719
1670
  continue;
720
1671
  }
721
1672
  const linkPath = join(toolSkillsDir, name);
@@ -788,44 +1739,233 @@ async function writeToolMcpConfig({
788
1739
  );
789
1740
  }
790
1741
 
791
- export async function manageTool(tool: string, opts: ManageOptions = {}) {
792
- const home = opts.homeDir ?? homedir();
793
- const rootDir = opts.rootDir ?? facultRootDir(home);
794
- const state = await loadManagedState(home);
1742
+ export async function manageTool(tool: string, opts: ManageOptions = {}) {
1743
+ const home = opts.homeDir ?? homedir();
1744
+ const rootDir = opts.rootDir ?? facultRootDir(home);
1745
+ const state = await loadManagedState(home, rootDir);
1746
+
1747
+ if (state.tools[tool]) {
1748
+ throw new Error(`${tool} is already managed`);
1749
+ }
1750
+
1751
+ const toolPaths = await resolveToolPaths(tool, home, rootDir, opts.toolPaths);
1752
+ if (!toolPaths) {
1753
+ throw new Error(`Unknown tool: ${tool}`);
1754
+ }
1755
+
1756
+ const existingSkillPlan = toolPaths.skillsDir
1757
+ ? await planExistingToolSkillAdoption({
1758
+ rootDir,
1759
+ toolSkillsDir: toolPaths.skillsDir,
1760
+ })
1761
+ : {
1762
+ adopt: [],
1763
+ identical: [],
1764
+ conflicts: [],
1765
+ ignored: [],
1766
+ };
1767
+ const existingImportPlan = mergeManagedImportPlans(
1768
+ asManagedSkillPlan(existingSkillPlan),
1769
+ toolPaths.agentsDir
1770
+ ? await planExistingToolAgentAdoption({
1771
+ rootDir,
1772
+ agentsDir: toolPaths.agentsDir,
1773
+ })
1774
+ : emptyManagedImportPlan(),
1775
+ toolPaths.toolHome
1776
+ ? await planExistingGlobalDocAdoption({
1777
+ rootDir,
1778
+ tool,
1779
+ toolHome: toolPaths.toolHome,
1780
+ })
1781
+ : emptyManagedImportPlan(),
1782
+ toolPaths.rulesDir
1783
+ ? await planExistingRuleAdoption({
1784
+ rootDir,
1785
+ tool,
1786
+ rulesDir: toolPaths.rulesDir,
1787
+ })
1788
+ : emptyManagedImportPlan(),
1789
+ toolPaths.toolConfig
1790
+ ? await planExistingToolConfigAdoption({
1791
+ rootDir,
1792
+ tool,
1793
+ toolConfigPath: toolPaths.toolConfig,
1794
+ })
1795
+ : emptyManagedImportPlan(),
1796
+ toolPaths.mcpConfig
1797
+ ? await planExistingMcpAdoption({
1798
+ rootDir,
1799
+ tool,
1800
+ mcpConfigPath: toolPaths.mcpConfig,
1801
+ })
1802
+ : emptyManagedImportPlan()
1803
+ );
1804
+
1805
+ if (opts.dryRun) {
1806
+ logManagedImportPlan(tool, existingImportPlan);
1807
+ return;
1808
+ }
795
1809
 
796
- if (state.tools[tool]) {
797
- throw new Error(`${tool} is already managed`);
1810
+ if (
1811
+ (toolPaths.skillsDir ||
1812
+ toolPaths.agentsDir ||
1813
+ toolPaths.toolHome ||
1814
+ toolPaths.rulesDir ||
1815
+ toolPaths.toolConfig ||
1816
+ toolPaths.mcpConfig) &&
1817
+ !opts.adoptExisting &&
1818
+ (existingImportPlan.adopt.length > 0 ||
1819
+ existingImportPlan.conflicts.length > 0)
1820
+ ) {
1821
+ const summary = [
1822
+ `${tool} has existing managed content that must be reviewed before entering managed mode.`,
1823
+ existingImportPlan.adopt.length
1824
+ ? `Adoptable items: ${existingImportPlan.adopt
1825
+ .map((item) => formatManagedItem(item))
1826
+ .join(", ")}`
1827
+ : null,
1828
+ existingImportPlan.conflicts.length
1829
+ ? `Conflicting items: ${existingImportPlan.conflicts
1830
+ .map((item) => formatManagedItem(item))
1831
+ .join(", ")}`
1832
+ : null,
1833
+ `Run "facult manage ${tool} --dry-run" to review the plan, then rerun with "--adopt-existing"`,
1834
+ existingImportPlan.conflicts.length > 0
1835
+ ? ' and "--existing-conflicts keep-canonical|keep-existing".'
1836
+ : ".",
1837
+ ]
1838
+ .filter(Boolean)
1839
+ .join(" ");
1840
+ throw new Error(summary);
798
1841
  }
799
1842
 
800
- const toolPaths = await resolveToolPaths(tool, home, opts.toolPaths);
801
- if (!toolPaths) {
802
- throw new Error(`Unknown tool: ${tool}`);
1843
+ if (
1844
+ opts.adoptExisting &&
1845
+ existingImportPlan.conflicts.length > 0 &&
1846
+ !opts.existingConflictMode
1847
+ ) {
1848
+ throw new Error(
1849
+ `${tool} has conflicting existing content (${existingImportPlan.conflicts
1850
+ .map((item) => formatManagedItem(item))
1851
+ .join(
1852
+ ", "
1853
+ )}). Rerun with "--existing-conflicts keep-canonical" or "--existing-conflicts keep-existing".`
1854
+ );
1855
+ }
1856
+ const importConflictMode = opts.existingConflictMode ?? "keep-canonical";
1857
+
1858
+ const adoptedSkills = toolPaths.skillsDir
1859
+ ? await adoptSkillsIntoCanonicalStore({
1860
+ homeDir: home,
1861
+ rootDir,
1862
+ skillSourceDirs: [toolPaths.skillsDir],
1863
+ })
1864
+ : [];
1865
+
1866
+ if (toolPaths.skillsDir && opts.adoptExisting) {
1867
+ const result = await adoptExistingToolSkills({
1868
+ rootDir,
1869
+ toolSkillsDir: toolPaths.skillsDir,
1870
+ conflictMode: importConflictMode,
1871
+ });
1872
+ for (const name of result.adopted) {
1873
+ if (!adoptedSkills.includes(name)) {
1874
+ adoptedSkills.push(name);
1875
+ }
1876
+ }
1877
+ if (result.adopted.length > 0) {
1878
+ await buildIndex({
1879
+ homeDir: home,
1880
+ rootDir,
1881
+ force: false,
1882
+ });
1883
+ }
1884
+ }
1885
+ if (toolPaths.agentsDir && opts.adoptExisting) {
1886
+ const result = await adoptExistingToolAgents({
1887
+ rootDir,
1888
+ agentsDir: toolPaths.agentsDir,
1889
+ conflictMode: importConflictMode,
1890
+ });
1891
+ adoptedSkills.push(...result.map((item) => item.name));
1892
+ }
1893
+ if (toolPaths.toolHome && opts.adoptExisting) {
1894
+ const result = await adoptExistingGlobalDocs({
1895
+ rootDir,
1896
+ tool,
1897
+ toolHome: toolPaths.toolHome,
1898
+ conflictMode: importConflictMode,
1899
+ });
1900
+ adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
1901
+ }
1902
+ if (toolPaths.rulesDir && opts.adoptExisting) {
1903
+ const result = await adoptExistingRules({
1904
+ rootDir,
1905
+ tool,
1906
+ rulesDir: toolPaths.rulesDir,
1907
+ conflictMode: importConflictMode,
1908
+ });
1909
+ adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
1910
+ }
1911
+ if (toolPaths.toolConfig && opts.adoptExisting) {
1912
+ const result = await adoptExistingToolConfig({
1913
+ rootDir,
1914
+ tool,
1915
+ toolConfigPath: toolPaths.toolConfig,
1916
+ conflictMode: importConflictMode,
1917
+ });
1918
+ adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
1919
+ }
1920
+ if (toolPaths.mcpConfig && opts.adoptExisting) {
1921
+ const result = await adoptExistingMcpServers({
1922
+ rootDir,
1923
+ tool,
1924
+ mcpConfigPath: toolPaths.mcpConfig,
1925
+ conflictMode: importConflictMode,
1926
+ });
1927
+ adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
1928
+ }
1929
+ if (adoptedSkills.length > 0) {
1930
+ await buildIndex({
1931
+ homeDir: home,
1932
+ rootDir,
1933
+ force: false,
1934
+ });
803
1935
  }
1936
+ const agentPreview = toolPaths.agentsDir
1937
+ ? await planAgentFileChanges({
1938
+ agentsDir: toolPaths.agentsDir,
1939
+ homeDir: home,
1940
+ rootDir,
1941
+ tool,
1942
+ })
1943
+ : null;
804
1944
  const globalDocsPreview = toolPaths.toolHome
805
- ? await syncToolGlobalDocs({
1945
+ ? await planToolGlobalDocsSync({
806
1946
  homeDir: home,
807
1947
  rootDir,
808
1948
  tool,
809
1949
  toolHome: toolPaths.toolHome,
810
- dryRun: true,
811
1950
  })
812
1951
  : null;
1952
+ const globalDocTargets = toolPaths.toolHome
1953
+ ? globalDocTargetPaths(tool, toolPaths.toolHome)
1954
+ : null;
813
1955
  const rulesPreview = toolPaths.rulesDir
814
- ? await syncToolRules({
1956
+ ? await planToolRulesSync({
815
1957
  homeDir: home,
816
1958
  rootDir,
817
1959
  tool,
818
1960
  rulesDir: toolPaths.rulesDir,
819
- dryRun: true,
820
1961
  })
821
1962
  : null;
822
1963
  const toolConfigPreview = toolPaths.toolConfig
823
- ? await syncToolConfig({
1964
+ ? await planToolConfigSync({
824
1965
  homeDir: home,
825
1966
  rootDir,
826
1967
  tool,
827
1968
  toolConfigPath: toolPaths.toolConfig,
828
- dryRun: true,
829
1969
  })
830
1970
  : null;
831
1971
 
@@ -840,20 +1980,15 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
840
1980
  : null;
841
1981
  const globalAgentsBackup =
842
1982
  toolPaths.toolHome &&
843
- globalDocsPreview?.managedTargets.includes(
844
- join(toolPaths.toolHome, "AGENTS.md")
845
- )
846
- ? await backupPath(join(toolPaths.toolHome, "AGENTS.md"), opts.now)
1983
+ globalDocTargets &&
1984
+ globalDocsPreview?.managedTargets.includes(globalDocTargets.primary)
1985
+ ? await backupPath(globalDocTargets.primary, opts.now)
847
1986
  : null;
848
1987
  const globalAgentsOverrideBackup =
849
1988
  toolPaths.toolHome &&
850
- globalDocsPreview?.managedTargets.includes(
851
- join(toolPaths.toolHome, "AGENTS.override.md")
852
- )
853
- ? await backupPath(
854
- join(toolPaths.toolHome, "AGENTS.override.md"),
855
- opts.now
856
- )
1989
+ globalDocTargets?.override &&
1990
+ globalDocsPreview?.managedTargets.includes(globalDocTargets.override)
1991
+ ? await backupPath(globalDocTargets.override, opts.now)
857
1992
  : null;
858
1993
  const rulesBackup =
859
1994
  toolPaths.rulesDir && rulesPreview?.managedRulesDir
@@ -866,6 +2001,10 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
866
2001
 
867
2002
  if (toolPaths.skillsDir) {
868
2003
  await ensureEmptyDir(toolPaths.skillsDir);
2004
+ await restorePreservedToolSkillEntries({
2005
+ backupDir: skillsBackup,
2006
+ toolSkillsDir: toolPaths.skillsDir,
2007
+ });
869
2008
  await createSkillSymlinks({
870
2009
  homeDir: home,
871
2010
  toolSkillsDir: toolPaths.skillsDir,
@@ -886,43 +2025,42 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
886
2025
  });
887
2026
  }
888
2027
 
889
- if (toolPaths.agentsDir) {
890
- await syncAgentFiles({
891
- agentsDir: toolPaths.agentsDir,
892
- homeDir: home,
893
- rootDir,
894
- tool,
2028
+ if (toolPaths.agentsDir && agentPreview) {
2029
+ await applyRenderedWrites({
2030
+ contents: agentPreview.contents,
2031
+ targets: Array.from(agentPreview.contents.keys()),
895
2032
  });
896
2033
  }
897
2034
 
898
2035
  if (toolPaths.toolHome && globalDocsPreview) {
899
2036
  await ensureDir(toolPaths.toolHome);
900
- await syncToolGlobalDocs({
901
- homeDir: home,
902
- rootDir,
903
- tool,
904
- toolHome: toolPaths.toolHome,
2037
+ await applyRenderedRemoves(globalDocsPreview.remove);
2038
+ await applyRenderedWrites({
2039
+ contents: globalDocsPreview.contents,
2040
+ targets: Array.from(globalDocsPreview.contents.keys()),
905
2041
  });
906
2042
  }
907
2043
 
908
2044
  if (toolPaths.rulesDir && rulesPreview?.managedRulesDir) {
909
2045
  await ensureEmptyDir(toolPaths.rulesDir);
910
- await syncToolRules({
911
- homeDir: home,
912
- rootDir,
913
- tool,
914
- rulesDir: toolPaths.rulesDir,
915
- previouslyManaged: true,
2046
+ await applyRenderedRemoves(rulesPreview.remove);
2047
+ await applyRenderedWrites({
2048
+ contents: rulesPreview.contents,
2049
+ targets: Array.from(rulesPreview.contents.keys()),
916
2050
  });
917
2051
  }
918
2052
 
919
2053
  if (toolPaths.toolConfig && toolConfigPreview?.managedConfig) {
920
- await syncToolConfig({
921
- homeDir: home,
922
- rootDir,
923
- tool,
924
- toolConfigPath: toolPaths.toolConfig,
925
- existingConfigPath: toolConfigBackup ?? undefined,
2054
+ await applyRenderedWrites({
2055
+ contents: new Map(
2056
+ toolConfigPreview.contents != null
2057
+ ? [[toolConfigPreview.targetPath, toolConfigPreview.contents]]
2058
+ : []
2059
+ ),
2060
+ targets:
2061
+ toolConfigPreview.managedConfig && toolConfigPreview.contents != null
2062
+ ? [toolConfigPreview.targetPath]
2063
+ : [],
926
2064
  });
927
2065
  }
928
2066
 
@@ -935,16 +2073,16 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
935
2073
  toolHome: globalDocsPreview?.managedTargets.length
936
2074
  ? toolPaths.toolHome
937
2075
  : undefined,
938
- globalAgentsPath: globalDocsPreview?.managedTargets.includes(
939
- join(toolPaths.toolHome ?? "", "AGENTS.md")
940
- )
941
- ? join(toolPaths.toolHome ?? "", "AGENTS.md")
942
- : undefined,
943
- globalAgentsOverridePath: globalDocsPreview?.managedTargets.includes(
944
- join(toolPaths.toolHome ?? "", "AGENTS.override.md")
945
- )
946
- ? join(toolPaths.toolHome ?? "", "AGENTS.override.md")
947
- : undefined,
2076
+ globalAgentsPath:
2077
+ globalDocTargets &&
2078
+ globalDocsPreview?.managedTargets.includes(globalDocTargets.primary)
2079
+ ? globalDocTargets.primary
2080
+ : undefined,
2081
+ globalAgentsOverridePath:
2082
+ globalDocTargets?.override &&
2083
+ globalDocsPreview?.managedTargets.includes(globalDocTargets.override)
2084
+ ? globalDocTargets.override
2085
+ : undefined,
948
2086
  rulesDir: rulesPreview?.managedRulesDir ? toolPaths.rulesDir : undefined,
949
2087
  toolConfig: toolConfigPreview?.managedConfig
950
2088
  ? toolPaths.toolConfig
@@ -956,9 +2094,65 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
956
2094
  globalAgentsOverrideBackup,
957
2095
  rulesBackup,
958
2096
  toolConfigBackup,
2097
+ renderedTargets: {},
959
2098
  };
960
2099
 
961
- await saveManagedState(state, home);
2100
+ const managedEntry = state.tools[tool]!;
2101
+ if (agentPreview) {
2102
+ updateRenderedTargetState({
2103
+ entry: managedEntry,
2104
+ writtenTargets: Array.from(agentPreview.contents.keys()),
2105
+ removedTargets: agentPreview.remove,
2106
+ contents: agentPreview.contents,
2107
+ sources: agentPreview.sources,
2108
+ });
2109
+ }
2110
+ if (globalDocsPreview) {
2111
+ updateRenderedTargetState({
2112
+ entry: managedEntry,
2113
+ writtenTargets: Array.from(globalDocsPreview.contents.keys()),
2114
+ removedTargets: globalDocsPreview.remove,
2115
+ contents: globalDocsPreview.contents,
2116
+ sources: globalDocsPreview.sources,
2117
+ });
2118
+ }
2119
+ if (rulesPreview) {
2120
+ updateRenderedTargetState({
2121
+ entry: managedEntry,
2122
+ writtenTargets: Array.from(rulesPreview.contents.keys()),
2123
+ removedTargets: rulesPreview.remove,
2124
+ contents: rulesPreview.contents,
2125
+ sources: rulesPreview.sources,
2126
+ });
2127
+ }
2128
+ if (toolConfigPreview?.managedConfig && toolConfigPreview.contents != null) {
2129
+ updateRenderedTargetState({
2130
+ entry: managedEntry,
2131
+ writtenTargets:
2132
+ toolConfigPreview.managedConfig && toolConfigPreview.contents != null
2133
+ ? [toolConfigPreview.targetPath]
2134
+ : [],
2135
+ removedTargets: toolConfigPreview.remove
2136
+ ? [toolConfigPreview.targetPath]
2137
+ : [],
2138
+ contents: new Map([
2139
+ [toolConfigPreview.targetPath, toolConfigPreview.contents],
2140
+ ]),
2141
+ sources: new Map(
2142
+ toolConfigPreview.sourcePath
2143
+ ? [[toolConfigPreview.targetPath, toolConfigPreview.sourcePath]]
2144
+ : []
2145
+ ),
2146
+ });
2147
+ }
2148
+
2149
+ await saveManagedState(state, home, rootDir);
2150
+
2151
+ for (const name of adoptedSkills) {
2152
+ console.log(
2153
+ `${tool}: adopted existing content ${name} into canonical store`
2154
+ );
2155
+ }
962
2156
  }
963
2157
 
964
2158
  async function restoreBackup({
@@ -999,7 +2193,8 @@ async function removeSymlinks(skillsDir: string) {
999
2193
 
1000
2194
  export async function unmanageTool(tool: string, opts: ManageOptions = {}) {
1001
2195
  const home = opts.homeDir ?? homedir();
1002
- const state = await loadManagedState(home);
2196
+ const rootDir = opts.rootDir ?? facultRootDir(home);
2197
+ const state = await loadManagedState(home, rootDir);
1003
2198
  const entry = state.tools[tool];
1004
2199
  if (!entry) {
1005
2200
  throw new Error(`${tool} is not managed`);
@@ -1063,13 +2258,15 @@ export async function unmanageTool(tool: string, opts: ManageOptions = {}) {
1063
2258
  nextTools[name] = config;
1064
2259
  }
1065
2260
  state.tools = nextTools;
1066
- await saveManagedState(state, home);
2261
+ await saveManagedState(state, home, rootDir);
1067
2262
  }
1068
2263
 
1069
2264
  export async function listManagedTools(
1070
- opts: { homeDir?: string } = {}
2265
+ opts: { homeDir?: string; rootDir?: string } = {}
1071
2266
  ): Promise<string[]> {
1072
- const state = await loadManagedState(opts.homeDir ?? homedir());
2267
+ const home = opts.homeDir ?? homedir();
2268
+ const rootDir = opts.rootDir ?? facultRootDir(home);
2269
+ const state = await loadManagedState(home, rootDir);
1073
2270
  return Object.keys(state.tools).sort();
1074
2271
  }
1075
2272
 
@@ -1089,7 +2286,7 @@ async function repairManagedToolEntry(args: {
1089
2286
  entry: ManagedToolState;
1090
2287
  }): Promise<{ entry: ManagedToolState; changed: boolean }> {
1091
2288
  const { homeDir, rootDir, tool } = args;
1092
- const toolPaths = await resolveToolPaths(tool, homeDir);
2289
+ const toolPaths = await resolveToolPaths(tool, homeDir, rootDir);
1093
2290
  if (!toolPaths) {
1094
2291
  return { entry: args.entry, changed: false };
1095
2292
  }
@@ -1117,8 +2314,9 @@ async function repairManagedToolEntry(args: {
1117
2314
  });
1118
2315
  if (preview.managedTargets.length > 0) {
1119
2316
  next.toolHome = toolPaths.toolHome;
1120
- const agentsPath = join(toolPaths.toolHome, "AGENTS.md");
1121
- const overridePath = join(toolPaths.toolHome, "AGENTS.override.md");
2317
+ const targets = globalDocTargetPaths(tool, toolPaths.toolHome);
2318
+ const agentsPath = targets.primary;
2319
+ const overridePath = targets.override;
1122
2320
  if (
1123
2321
  preview.managedTargets.includes(agentsPath) &&
1124
2322
  !next.globalAgentsPath
@@ -1128,6 +2326,7 @@ async function repairManagedToolEntry(args: {
1128
2326
  changed = true;
1129
2327
  }
1130
2328
  if (
2329
+ overridePath &&
1131
2330
  preview.managedTargets.includes(overridePath) &&
1132
2331
  !next.globalAgentsOverridePath
1133
2332
  ) {
@@ -1171,24 +2370,197 @@ async function repairManagedToolEntry(args: {
1171
2370
  return { entry: next, changed };
1172
2371
  }
1173
2372
 
2373
+ interface RenderedConflict {
2374
+ targetPath: string;
2375
+ sourcePath: string;
2376
+ sourceKind: ManagedRenderedTargetState["sourceKind"];
2377
+ reason: "modified" | "unknown_state";
2378
+ }
2379
+
2380
+ interface RenderedApplyPlan {
2381
+ write: string[];
2382
+ remove: string[];
2383
+ conflicts: RenderedConflict[];
2384
+ }
2385
+
2386
+ async function planRenderedTargetConflicts(args: {
2387
+ entry: ManagedToolState;
2388
+ desiredWrites: string[];
2389
+ desiredRemoves: string[];
2390
+ desiredContents: Map<string, string>;
2391
+ desiredSources: Map<string, string>;
2392
+ conflictMode?: "warn" | "overwrite";
2393
+ }): Promise<RenderedApplyPlan> {
2394
+ if (args.conflictMode === "overwrite") {
2395
+ return {
2396
+ write: args.desiredWrites,
2397
+ remove: args.desiredRemoves,
2398
+ conflicts: [],
2399
+ };
2400
+ }
2401
+
2402
+ const previous = args.entry.renderedTargets ?? {};
2403
+ const write: string[] = [];
2404
+ const remove: string[] = [];
2405
+ const conflicts: RenderedConflict[] = [];
2406
+ const allTargets = new Set([...args.desiredWrites, ...args.desiredRemoves]);
2407
+
2408
+ for (const targetPath of allTargets) {
2409
+ const sourcePath =
2410
+ args.desiredSources.get(targetPath) ?? previous[targetPath]?.sourcePath;
2411
+ if (!sourcePath) {
2412
+ if (args.desiredWrites.includes(targetPath)) {
2413
+ write.push(targetPath);
2414
+ } else {
2415
+ remove.push(targetPath);
2416
+ }
2417
+ continue;
2418
+ }
2419
+ const sourceKind = renderedSourceKindForPath(sourcePath);
2420
+ if (sourceKind !== "builtin") {
2421
+ if (args.desiredWrites.includes(targetPath)) {
2422
+ write.push(targetPath);
2423
+ } else {
2424
+ remove.push(targetPath);
2425
+ }
2426
+ continue;
2427
+ }
2428
+
2429
+ const prior = previous[targetPath];
2430
+ const current = await readTextIfExists(targetPath);
2431
+ if (current == null) {
2432
+ if (args.desiredWrites.includes(targetPath)) {
2433
+ write.push(targetPath);
2434
+ }
2435
+ continue;
2436
+ }
2437
+
2438
+ const currentHash = renderedHash(current);
2439
+ if (prior?.hash) {
2440
+ if (currentHash === prior.hash) {
2441
+ if (args.desiredWrites.includes(targetPath)) {
2442
+ write.push(targetPath);
2443
+ } else {
2444
+ remove.push(targetPath);
2445
+ }
2446
+ continue;
2447
+ }
2448
+ conflicts.push({
2449
+ targetPath,
2450
+ sourcePath,
2451
+ sourceKind,
2452
+ reason: "modified",
2453
+ });
2454
+ continue;
2455
+ }
2456
+
2457
+ conflicts.push({
2458
+ targetPath,
2459
+ sourcePath,
2460
+ sourceKind,
2461
+ reason: "unknown_state",
2462
+ });
2463
+ }
2464
+
2465
+ return {
2466
+ write: write.sort(),
2467
+ remove: remove.sort(),
2468
+ conflicts,
2469
+ };
2470
+ }
2471
+
2472
+ function logRenderedConflicts(
2473
+ tool: string,
2474
+ conflicts: RenderedConflict[],
2475
+ dryRun?: boolean
2476
+ ) {
2477
+ for (const conflict of conflicts) {
2478
+ const verb = dryRun ? "would skip" : "skipped";
2479
+ const state =
2480
+ conflict.reason === "unknown_state"
2481
+ ? "no prior managed hash is recorded"
2482
+ : "local edits were detected";
2483
+ console.warn(
2484
+ `${tool}: ${verb} builtin-backed target ${conflict.targetPath} because ${state}. Rerun with "--builtin-conflicts overwrite" to replace it with the latest packaged default.`
2485
+ );
2486
+ }
2487
+ }
2488
+
2489
+ async function applyRenderedWrites(args: {
2490
+ contents: Map<string, string>;
2491
+ targets: string[];
2492
+ }) {
2493
+ for (const pathValue of args.targets) {
2494
+ const desired = args.contents.get(pathValue);
2495
+ if (desired == null) {
2496
+ continue;
2497
+ }
2498
+ await mkdir(dirname(pathValue), { recursive: true });
2499
+ await Bun.write(
2500
+ pathValue,
2501
+ desired.endsWith("\n") ? desired : `${desired}\n`
2502
+ );
2503
+ }
2504
+ }
2505
+
2506
+ async function applyRenderedRemoves(targets: string[]) {
2507
+ for (const pathValue of targets) {
2508
+ await rm(pathValue, { force: true });
2509
+ }
2510
+ }
2511
+
2512
+ function updateRenderedTargetState(args: {
2513
+ entry: ManagedToolState;
2514
+ writtenTargets: string[];
2515
+ removedTargets: string[];
2516
+ contents: Map<string, string>;
2517
+ sources: Map<string, string>;
2518
+ }) {
2519
+ const next = { ...(args.entry.renderedTargets ?? {}) };
2520
+ for (const pathValue of args.removedTargets) {
2521
+ delete next[pathValue];
2522
+ }
2523
+ for (const pathValue of args.writtenTargets) {
2524
+ const contents = args.contents.get(pathValue);
2525
+ const sourcePath = args.sources.get(pathValue);
2526
+ if (!(contents && sourcePath)) {
2527
+ continue;
2528
+ }
2529
+ next[pathValue] = {
2530
+ hash: renderedHash(contents),
2531
+ sourcePath,
2532
+ sourceKind: renderedSourceKindForPath(sourcePath),
2533
+ };
2534
+ }
2535
+ args.entry.renderedTargets = next;
2536
+ }
2537
+
1174
2538
  function logSyncDryRun({
1175
2539
  tool,
1176
2540
  entry,
1177
2541
  skillPlan,
1178
2542
  mcpPlan,
1179
2543
  agentPlan,
2544
+ agentConflicts,
1180
2545
  globalDocsPlan,
2546
+ globalDocsConflicts,
1181
2547
  rulesPlan,
2548
+ rulesConflicts,
1182
2549
  configPlan,
2550
+ configConflicts,
1183
2551
  }: {
1184
2552
  tool: string;
1185
2553
  entry: ManagedToolState;
1186
2554
  skillPlan: { add: string[]; remove: string[] };
1187
2555
  mcpPlan: { needsWrite: boolean };
1188
2556
  agentPlan: { add: string[]; remove: string[] };
2557
+ agentConflicts: RenderedConflict[];
1189
2558
  globalDocsPlan: { write: string[]; remove: string[] };
2559
+ globalDocsConflicts: RenderedConflict[];
1190
2560
  rulesPlan: { write: string[]; remove: string[] };
2561
+ rulesConflicts: RenderedConflict[];
1191
2562
  configPlan: { write: boolean; remove: boolean; targetPath: string };
2563
+ configConflicts: RenderedConflict[];
1192
2564
  }) {
1193
2565
  for (const name of skillPlan.add) {
1194
2566
  console.log(`${tool}: would add skill ${name}`);
@@ -1202,24 +2574,28 @@ function logSyncDryRun({
1202
2574
  for (const p of agentPlan.remove) {
1203
2575
  console.log(`${tool}: would remove agent ${p}`);
1204
2576
  }
2577
+ logRenderedConflicts(tool, agentConflicts, true);
1205
2578
  for (const p of globalDocsPlan.write) {
1206
2579
  console.log(`${tool}: would write global doc ${p}`);
1207
2580
  }
1208
2581
  for (const p of globalDocsPlan.remove) {
1209
2582
  console.log(`${tool}: would remove global doc ${p}`);
1210
2583
  }
2584
+ logRenderedConflicts(tool, globalDocsConflicts, true);
1211
2585
  for (const p of rulesPlan.write) {
1212
2586
  console.log(`${tool}: would write rule ${p}`);
1213
2587
  }
1214
2588
  for (const p of rulesPlan.remove) {
1215
2589
  console.log(`${tool}: would remove rule ${p}`);
1216
2590
  }
2591
+ logRenderedConflicts(tool, rulesConflicts, true);
1217
2592
  if (configPlan.write) {
1218
2593
  console.log(`${tool}: would write tool config ${configPlan.targetPath}`);
1219
2594
  }
1220
2595
  if (configPlan.remove) {
1221
2596
  console.log(`${tool}: would remove tool config ${configPlan.targetPath}`);
1222
2597
  }
2598
+ logRenderedConflicts(tool, configConflicts, true);
1223
2599
  if (mcpPlan.needsWrite && entry.mcpConfig) {
1224
2600
  console.log(`${tool}: would update mcp config ${entry.mcpConfig}`);
1225
2601
  }
@@ -1234,25 +2610,129 @@ function logSyncDryRun({
1234
2610
  rulesPlan.remove.length === 0 &&
1235
2611
  !configPlan.write &&
1236
2612
  !configPlan.remove &&
1237
- !mcpPlan.needsWrite
2613
+ !mcpPlan.needsWrite &&
2614
+ agentConflicts.length === 0 &&
2615
+ globalDocsConflicts.length === 0 &&
2616
+ rulesConflicts.length === 0 &&
2617
+ configConflicts.length === 0
1238
2618
  ) {
1239
2619
  console.log(`${tool}: no changes`);
1240
2620
  }
1241
2621
  }
1242
2622
 
2623
+ async function repairManagedCanonicalContent(args: {
2624
+ homeDir: string;
2625
+ rootDir: string;
2626
+ tool: string;
2627
+ entry: ManagedToolState;
2628
+ }): Promise<string[]> {
2629
+ const adopted: string[] = [];
2630
+
2631
+ for (const name of await adoptSkillsIntoCanonicalStore({
2632
+ homeDir: args.homeDir,
2633
+ rootDir: args.rootDir,
2634
+ skillSourceDirs: [
2635
+ args.entry.skillsBackup ?? "",
2636
+ args.entry.skillsDir ?? "",
2637
+ ],
2638
+ })) {
2639
+ adopted.push(name);
2640
+ }
2641
+
2642
+ if (args.entry.agentsBackup) {
2643
+ const items = await adoptExistingToolAgents({
2644
+ rootDir: args.rootDir,
2645
+ agentsDir: args.entry.agentsBackup,
2646
+ conflictMode: "keep-canonical",
2647
+ });
2648
+ adopted.push(...items.map((item) => `agent:${item.name}`));
2649
+ }
2650
+
2651
+ if (args.entry.globalAgentsBackup) {
2652
+ const items = await adoptExistingGlobalDocFile({
2653
+ sourcePath: args.entry.globalAgentsBackup,
2654
+ canonicalPath: join(args.rootDir, "AGENTS.global.md"),
2655
+ name: "AGENTS.md",
2656
+ conflictMode: "keep-canonical",
2657
+ });
2658
+ adopted.push(...items.map((item) => `${item.kind}:${item.name}`));
2659
+ }
2660
+
2661
+ if (args.entry.globalAgentsOverrideBackup) {
2662
+ const items = await adoptExistingGlobalDocFile({
2663
+ sourcePath: args.entry.globalAgentsOverrideBackup,
2664
+ canonicalPath: join(args.rootDir, "AGENTS.override.global.md"),
2665
+ name: "AGENTS.override.md",
2666
+ conflictMode: "keep-canonical",
2667
+ });
2668
+ adopted.push(...items.map((item) => `${item.kind}:${item.name}`));
2669
+ }
2670
+
2671
+ if (args.entry.rulesBackup) {
2672
+ const items = await adoptExistingRules({
2673
+ rootDir: args.rootDir,
2674
+ tool: args.tool,
2675
+ rulesDir: args.entry.rulesBackup,
2676
+ conflictMode: "keep-canonical",
2677
+ });
2678
+ adopted.push(...items.map((item) => `${item.kind}:${item.name}`));
2679
+ }
2680
+
2681
+ if (args.entry.toolConfigBackup) {
2682
+ const items = await adoptExistingToolConfig({
2683
+ rootDir: args.rootDir,
2684
+ tool: args.tool,
2685
+ toolConfigPath: args.entry.toolConfigBackup,
2686
+ conflictMode: "keep-canonical",
2687
+ });
2688
+ adopted.push(...items.map((item) => `${item.kind}:${item.name}`));
2689
+ }
2690
+
2691
+ if (args.entry.mcpBackup) {
2692
+ const items = await adoptExistingMcpServers({
2693
+ rootDir: args.rootDir,
2694
+ tool: args.tool,
2695
+ mcpConfigPath: args.entry.mcpBackup,
2696
+ conflictMode: "keep-canonical",
2697
+ });
2698
+ adopted.push(...items.map((item) => `${item.kind}:${item.name}`));
2699
+ }
2700
+
2701
+ if (adopted.length > 0) {
2702
+ await buildIndex({
2703
+ homeDir: args.homeDir,
2704
+ rootDir: args.rootDir,
2705
+ force: false,
2706
+ });
2707
+ }
2708
+
2709
+ return adopted;
2710
+ }
2711
+
1243
2712
  async function syncManagedToolEntry({
1244
2713
  homeDir,
1245
2714
  tool,
1246
2715
  entry,
1247
2716
  rootDir,
1248
2717
  dryRun,
2718
+ builtinConflictMode,
1249
2719
  }: {
1250
2720
  homeDir: string;
1251
2721
  tool: string;
1252
2722
  entry: ManagedToolState;
1253
2723
  rootDir: string;
1254
2724
  dryRun?: boolean;
2725
+ builtinConflictMode?: "warn" | "overwrite";
1255
2726
  }) {
2727
+ const adoptedSkills = dryRun
2728
+ ? []
2729
+ : await repairManagedCanonicalContent({
2730
+ homeDir,
2731
+ rootDir,
2732
+ tool,
2733
+ entry,
2734
+ });
2735
+
1256
2736
  const skillPlan = entry.skillsDir
1257
2737
  ? await syncSkillSymlinks({
1258
2738
  homeDir,
@@ -1264,14 +2744,13 @@ async function syncManagedToolEntry({
1264
2744
  : { add: [], remove: [] };
1265
2745
 
1266
2746
  const agentPlan = entry.agentsDir
1267
- ? await syncAgentFiles({
2747
+ ? await planAgentFileChanges({
1268
2748
  agentsDir: entry.agentsDir,
1269
2749
  homeDir,
1270
2750
  rootDir,
1271
2751
  tool,
1272
- dryRun,
1273
2752
  })
1274
- : { add: [], remove: [] };
2753
+ : { add: [], remove: [], contents: new Map(), sources: new Map() };
1275
2754
 
1276
2755
  const mcpPlan = entry.mcpConfig
1277
2756
  ? await syncMcpConfig({
@@ -1283,7 +2762,7 @@ async function syncManagedToolEntry({
1283
2762
  : { needsWrite: false };
1284
2763
 
1285
2764
  const globalDocsPlan = entry.toolHome
1286
- ? await syncToolGlobalDocs({
2765
+ ? await planToolGlobalDocsSync({
1287
2766
  homeDir,
1288
2767
  rootDir,
1289
2768
  tool,
@@ -1292,51 +2771,175 @@ async function syncManagedToolEntry({
1292
2771
  entry.globalAgentsPath,
1293
2772
  entry.globalAgentsOverridePath,
1294
2773
  ].filter((value): value is string => Boolean(value)),
1295
- dryRun,
1296
2774
  })
1297
- : { write: [], remove: [], contents: new Map(), managedTargets: [] };
2775
+ : {
2776
+ write: [],
2777
+ remove: [],
2778
+ contents: new Map(),
2779
+ sources: new Map(),
2780
+ managedTargets: [],
2781
+ };
1298
2782
 
1299
2783
  const rulesPlan = entry.rulesDir
1300
- ? await syncToolRules({
2784
+ ? await planToolRulesSync({
1301
2785
  homeDir,
1302
2786
  rootDir,
1303
2787
  tool,
1304
2788
  rulesDir: entry.rulesDir,
1305
2789
  previouslyManaged: true,
1306
- dryRun,
1307
2790
  })
1308
- : { write: [], remove: [], contents: new Map(), managedRulesDir: false };
2791
+ : {
2792
+ write: [],
2793
+ remove: [],
2794
+ contents: new Map(),
2795
+ sources: new Map(),
2796
+ managedRulesDir: false,
2797
+ };
1309
2798
 
1310
2799
  const configPlan = entry.toolConfig
1311
- ? await syncToolConfig({
2800
+ ? await planToolConfigSync({
1312
2801
  homeDir,
1313
2802
  rootDir,
1314
2803
  tool,
1315
2804
  toolConfigPath: entry.toolConfig,
1316
2805
  existingConfigPath: entry.toolConfigBackup ?? undefined,
1317
2806
  previouslyManaged: true,
1318
- dryRun,
1319
2807
  })
1320
2808
  : {
1321
2809
  write: false,
1322
2810
  remove: false,
1323
2811
  contents: null,
2812
+ sourcePath: undefined,
1324
2813
  managedConfig: false,
1325
2814
  targetPath: "",
1326
2815
  };
1327
2816
 
2817
+ const agentRendered = await planRenderedTargetConflicts({
2818
+ entry,
2819
+ desiredWrites: agentPlan.add,
2820
+ desiredRemoves: agentPlan.remove,
2821
+ desiredContents: agentPlan.contents,
2822
+ desiredSources: agentPlan.sources,
2823
+ conflictMode: builtinConflictMode,
2824
+ });
2825
+ const globalDocsRendered = await planRenderedTargetConflicts({
2826
+ entry,
2827
+ desiredWrites: globalDocsPlan.write,
2828
+ desiredRemoves: globalDocsPlan.remove,
2829
+ desiredContents: globalDocsPlan.contents,
2830
+ desiredSources: globalDocsPlan.sources,
2831
+ conflictMode: builtinConflictMode,
2832
+ });
2833
+ const rulesRendered = await planRenderedTargetConflicts({
2834
+ entry,
2835
+ desiredWrites: rulesPlan.write,
2836
+ desiredRemoves: rulesPlan.remove,
2837
+ desiredContents: rulesPlan.contents,
2838
+ desiredSources: rulesPlan.sources,
2839
+ conflictMode: builtinConflictMode,
2840
+ });
2841
+ const configContents =
2842
+ configPlan.contents != null
2843
+ ? new Map([[configPlan.targetPath, configPlan.contents]])
2844
+ : new Map<string, string>();
2845
+ const configSources = new Map<string, string>(
2846
+ configPlan.sourcePath
2847
+ ? [[configPlan.targetPath, configPlan.sourcePath]]
2848
+ : []
2849
+ );
2850
+ const configRendered = await planRenderedTargetConflicts({
2851
+ entry,
2852
+ desiredWrites:
2853
+ configPlan.write && configPlan.targetPath ? [configPlan.targetPath] : [],
2854
+ desiredRemoves:
2855
+ configPlan.remove && configPlan.targetPath ? [configPlan.targetPath] : [],
2856
+ desiredContents: configContents,
2857
+ desiredSources: configSources,
2858
+ conflictMode: builtinConflictMode,
2859
+ });
2860
+
1328
2861
  if (dryRun) {
1329
2862
  logSyncDryRun({
1330
2863
  tool,
1331
2864
  entry,
1332
2865
  skillPlan,
1333
2866
  mcpPlan,
1334
- agentPlan,
1335
- globalDocsPlan,
1336
- rulesPlan,
1337
- configPlan,
2867
+ agentPlan: { add: agentRendered.write, remove: agentRendered.remove },
2868
+ agentConflicts: agentRendered.conflicts,
2869
+ globalDocsPlan: {
2870
+ write: globalDocsRendered.write,
2871
+ remove: globalDocsRendered.remove,
2872
+ },
2873
+ globalDocsConflicts: globalDocsRendered.conflicts,
2874
+ rulesPlan: { write: rulesRendered.write, remove: rulesRendered.remove },
2875
+ rulesConflicts: rulesRendered.conflicts,
2876
+ configPlan: {
2877
+ write: configRendered.write.length > 0,
2878
+ remove: configRendered.remove.length > 0,
2879
+ targetPath: configPlan.targetPath,
2880
+ },
2881
+ configConflicts: configRendered.conflicts,
1338
2882
  });
1339
2883
  } else {
2884
+ await applyRenderedRemoves(agentRendered.remove);
2885
+ await applyRenderedWrites({
2886
+ contents: agentPlan.contents,
2887
+ targets: agentRendered.write,
2888
+ });
2889
+ await applyRenderedRemoves(globalDocsRendered.remove);
2890
+ await applyRenderedWrites({
2891
+ contents: globalDocsPlan.contents,
2892
+ targets: globalDocsRendered.write,
2893
+ });
2894
+ await applyRenderedRemoves(rulesRendered.remove);
2895
+ await applyRenderedWrites({
2896
+ contents: rulesPlan.contents,
2897
+ targets: rulesRendered.write,
2898
+ });
2899
+ await applyRenderedRemoves(configRendered.remove);
2900
+ await applyRenderedWrites({
2901
+ contents: configContents,
2902
+ targets: configRendered.write,
2903
+ });
2904
+ logRenderedConflicts(tool, agentRendered.conflicts);
2905
+ logRenderedConflicts(tool, globalDocsRendered.conflicts);
2906
+ logRenderedConflicts(tool, rulesRendered.conflicts);
2907
+ logRenderedConflicts(tool, configRendered.conflicts);
2908
+
2909
+ updateRenderedTargetState({
2910
+ entry,
2911
+ writtenTargets: agentRendered.write,
2912
+ removedTargets: agentRendered.remove,
2913
+ contents: agentPlan.contents,
2914
+ sources: agentPlan.sources,
2915
+ });
2916
+ updateRenderedTargetState({
2917
+ entry,
2918
+ writtenTargets: globalDocsRendered.write,
2919
+ removedTargets: globalDocsRendered.remove,
2920
+ contents: globalDocsPlan.contents,
2921
+ sources: globalDocsPlan.sources,
2922
+ });
2923
+ updateRenderedTargetState({
2924
+ entry,
2925
+ writtenTargets: rulesRendered.write,
2926
+ removedTargets: rulesRendered.remove,
2927
+ contents: rulesPlan.contents,
2928
+ sources: rulesPlan.sources,
2929
+ });
2930
+ updateRenderedTargetState({
2931
+ entry,
2932
+ writtenTargets: configRendered.write,
2933
+ removedTargets: configRendered.remove,
2934
+ contents: configContents,
2935
+ sources: configSources,
2936
+ });
2937
+
2938
+ for (const name of adoptedSkills) {
2939
+ console.log(
2940
+ `${tool}: adopted existing content ${name} into canonical store`
2941
+ );
2942
+ }
1340
2943
  console.log(`${tool} synced`);
1341
2944
  }
1342
2945
  }
@@ -1344,7 +2947,7 @@ async function syncManagedToolEntry({
1344
2947
  export async function syncManagedTools(opts: SyncOptions = {}) {
1345
2948
  const home = opts.homeDir ?? homedir();
1346
2949
  const rootDir = opts.rootDir ?? facultRootDir(home);
1347
- const state = await loadManagedState(home);
2950
+ const state = await loadManagedState(home, rootDir);
1348
2951
  const tools = opts.tool ? [opts.tool] : Object.keys(state.tools).sort();
1349
2952
 
1350
2953
  if (!tools.length) {
@@ -1370,7 +2973,7 @@ export async function syncManagedTools(opts: SyncOptions = {}) {
1370
2973
  }
1371
2974
  }
1372
2975
  if (changed) {
1373
- await saveManagedState(state, home);
2976
+ await saveManagedState(state, home, rootDir);
1374
2977
  }
1375
2978
  }
1376
2979
 
@@ -1385,28 +2988,94 @@ export async function syncManagedTools(opts: SyncOptions = {}) {
1385
2988
  entry,
1386
2989
  rootDir,
1387
2990
  dryRun: opts.dryRun,
2991
+ builtinConflictMode: opts.builtinConflictMode,
1388
2992
  });
1389
2993
  }
2994
+
2995
+ if (!opts.dryRun) {
2996
+ await saveManagedState(state, home, rootDir);
2997
+ }
1390
2998
  }
1391
2999
 
1392
3000
  export async function manageCommand(argv: string[]) {
1393
- if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
3001
+ const parsed = parseCliContextArgs(argv);
3002
+ const args = [...parsed.argv];
3003
+ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
1394
3004
  console.log(`facult manage — enter managed mode for a tool (backup + symlinks + MCP generation)
1395
3005
 
1396
3006
  Usage:
1397
- facult manage <tool>
3007
+ facult manage <tool> [--dry-run] [--adopt-existing] [--existing-conflicts keep-canonical|keep-existing] [--builtin-conflicts overwrite] [--root PATH|--global|--project]
1398
3008
  `);
1399
3009
  return;
1400
3010
  }
1401
- const tool = argv[0];
3011
+ const dryRun = args.includes("--dry-run");
3012
+ const adoptExisting = args.includes("--adopt-existing");
3013
+ const conflictIndex = args.indexOf("--existing-conflicts");
3014
+ const builtinConflictIndex = args.indexOf("--builtin-conflicts");
3015
+ let existingConflictMode: "keep-canonical" | "keep-existing" | undefined;
3016
+ let builtinConflictMode: "warn" | "overwrite" | undefined;
3017
+ if (conflictIndex !== -1) {
3018
+ const value = args[conflictIndex + 1];
3019
+ if (value !== "keep-canonical" && value !== "keep-existing") {
3020
+ console.error(
3021
+ '--existing-conflicts requires "keep-canonical" or "keep-existing"'
3022
+ );
3023
+ process.exitCode = 1;
3024
+ return;
3025
+ }
3026
+ existingConflictMode = value;
3027
+ }
3028
+ if (builtinConflictIndex !== -1) {
3029
+ const value = args[builtinConflictIndex + 1];
3030
+ if (value !== "overwrite") {
3031
+ console.error('--builtin-conflicts currently supports only "overwrite"');
3032
+ process.exitCode = 1;
3033
+ return;
3034
+ }
3035
+ builtinConflictMode = value;
3036
+ }
3037
+ const positional: string[] = [];
3038
+ for (let i = 0; i < args.length; i += 1) {
3039
+ const value = args[i];
3040
+ if (!value) {
3041
+ continue;
3042
+ }
3043
+ if (value === "--existing-conflicts") {
3044
+ i += 1;
3045
+ continue;
3046
+ }
3047
+ if (value === "--builtin-conflicts") {
3048
+ i += 1;
3049
+ continue;
3050
+ }
3051
+ if (value.startsWith("--")) {
3052
+ continue;
3053
+ }
3054
+ positional.push(value);
3055
+ }
3056
+ const tool = positional[0];
1402
3057
  if (!tool) {
1403
3058
  console.error("manage requires a tool name");
1404
3059
  process.exitCode = 1;
1405
3060
  return;
1406
3061
  }
1407
3062
  try {
1408
- await manageTool(tool);
1409
- console.log(`${tool} is now managed`);
3063
+ await manageTool(tool, {
3064
+ rootDir: resolveCliContextRoot({
3065
+ rootArg: parsed.rootArg,
3066
+ scope: parsed.scope,
3067
+ cwd: process.cwd(),
3068
+ }),
3069
+ dryRun,
3070
+ adoptExisting,
3071
+ existingConflictMode,
3072
+ builtinConflictMode,
3073
+ });
3074
+ if (dryRun) {
3075
+ console.log(`${tool}: preflight complete`);
3076
+ } else {
3077
+ console.log(`${tool} is now managed`);
3078
+ }
1410
3079
  } catch (err) {
1411
3080
  console.error(err instanceof Error ? err.message : String(err));
1412
3081
  process.exitCode = 1;
@@ -1414,22 +3083,33 @@ Usage:
1414
3083
  }
1415
3084
 
1416
3085
  export async function unmanageCommand(argv: string[]) {
1417
- if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
3086
+ const parsed = parseCliContextArgs(argv);
3087
+ if (
3088
+ parsed.argv.includes("--help") ||
3089
+ parsed.argv.includes("-h") ||
3090
+ parsed.argv[0] === "help"
3091
+ ) {
1418
3092
  console.log(`facult unmanage — exit managed mode for a tool (restore backups)
1419
3093
 
1420
3094
  Usage:
1421
- facult unmanage <tool>
3095
+ facult unmanage <tool> [--root PATH|--global|--project]
1422
3096
  `);
1423
3097
  return;
1424
3098
  }
1425
- const tool = argv[0];
3099
+ const tool = parsed.argv[0];
1426
3100
  if (!tool) {
1427
3101
  console.error("unmanage requires a tool name");
1428
3102
  process.exitCode = 1;
1429
3103
  return;
1430
3104
  }
1431
3105
  try {
1432
- await unmanageTool(tool);
3106
+ await unmanageTool(tool, {
3107
+ rootDir: resolveCliContextRoot({
3108
+ rootArg: parsed.rootArg,
3109
+ scope: parsed.scope,
3110
+ cwd: process.cwd(),
3111
+ }),
3112
+ });
1433
3113
  console.log(`${tool} is no longer managed`);
1434
3114
  } catch (err) {
1435
3115
  console.error(err instanceof Error ? err.message : String(err));
@@ -1438,15 +3118,26 @@ Usage:
1438
3118
  }
1439
3119
 
1440
3120
  export async function managedCommand(argv: string[] = []) {
1441
- if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
3121
+ const parsed = parseCliContextArgs(argv);
3122
+ if (
3123
+ parsed.argv.includes("--help") ||
3124
+ parsed.argv.includes("-h") ||
3125
+ parsed.argv[0] === "help"
3126
+ ) {
1442
3127
  console.log(`facult managed — list tools currently in managed mode
1443
3128
 
1444
3129
  Usage:
1445
- facult managed
3130
+ facult managed [--root PATH|--global|--project]
1446
3131
  `);
1447
3132
  return;
1448
3133
  }
1449
- const tools = await listManagedTools();
3134
+ const tools = await listManagedTools({
3135
+ rootDir: resolveCliContextRoot({
3136
+ rootArg: parsed.rootArg,
3137
+ scope: parsed.scope,
3138
+ cwd: process.cwd(),
3139
+ }),
3140
+ });
1450
3141
  if (!tools.length) {
1451
3142
  console.log("No managed tools.");
1452
3143
  return;
@@ -1457,21 +3148,47 @@ Usage:
1457
3148
  }
1458
3149
 
1459
3150
  export async function syncCommand(argv: string[]) {
1460
- if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
3151
+ const parsed = parseCliContextArgs(argv);
3152
+ if (
3153
+ parsed.argv.includes("--help") ||
3154
+ parsed.argv.includes("-h") ||
3155
+ parsed.argv[0] === "help"
3156
+ ) {
1461
3157
  console.log(`facult sync — sync managed tools with canonical state
1462
3158
 
1463
3159
  Usage:
1464
- facult sync [tool] [--dry-run]
3160
+ facult sync [tool] [--dry-run] [--builtin-conflicts overwrite] [--root PATH|--global|--project]
1465
3161
 
1466
3162
  Options:
1467
3163
  --dry-run Show what would change
3164
+ --builtin-conflicts overwrite Replace locally modified builtin-backed rendered files
1468
3165
  `);
1469
3166
  return;
1470
3167
  }
1471
- const tool = argv.find((arg) => !arg.startsWith("-"));
1472
- const dryRun = argv.includes("--dry-run");
3168
+ const tool = parsed.argv.find((arg) => !arg.startsWith("-"));
3169
+ const dryRun = parsed.argv.includes("--dry-run");
3170
+ const builtinConflictIndex = parsed.argv.indexOf("--builtin-conflicts");
3171
+ let builtinConflictMode: "warn" | "overwrite" | undefined;
3172
+ if (builtinConflictIndex !== -1) {
3173
+ const value = parsed.argv[builtinConflictIndex + 1];
3174
+ if (value !== "overwrite") {
3175
+ console.error('--builtin-conflicts currently supports only "overwrite"');
3176
+ process.exitCode = 1;
3177
+ return;
3178
+ }
3179
+ builtinConflictMode = value;
3180
+ }
1473
3181
  try {
1474
- await syncManagedTools({ tool, dryRun });
3182
+ await syncManagedTools({
3183
+ tool,
3184
+ dryRun,
3185
+ builtinConflictMode,
3186
+ rootDir: resolveCliContextRoot({
3187
+ rootArg: parsed.rootArg,
3188
+ scope: parsed.scope,
3189
+ cwd: process.cwd(),
3190
+ }),
3191
+ });
1475
3192
  } catch (err) {
1476
3193
  console.error(err instanceof Error ? err.message : String(err));
1477
3194
  process.exitCode = 1;