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/manage.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import {
2
3
  cp,
3
4
  lstat,
@@ -15,6 +16,7 @@ import { renderCanonicalText } from "./agents";
15
16
  import { ensureAiIndexPath } from "./ai-state";
16
17
  import { builtinSyncDefaultsEnabled, facultBuiltinPackRoot } from "./builtin";
17
18
  import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
19
+ import { renderBullets, renderCode, renderPage } from "./cli-ui";
18
20
  import { contentHash, normalizeText } from "./conflicts";
19
21
  import {
20
22
  globalDocTargetPaths,
@@ -31,12 +33,18 @@ import {
31
33
  type FacultIndex,
32
34
  type SkillEntry,
33
35
  } from "./index-builder";
36
+ import {
37
+ extractServersObject,
38
+ loadCanonicalMcpState,
39
+ stringifyCanonicalMcpServers,
40
+ } from "./mcp-config";
34
41
  import {
35
42
  facultMachineStateDir,
36
43
  facultRootDir,
37
44
  legacyFacultStateDirForRoot,
38
45
  projectRootFromAiRoot,
39
46
  } from "./paths";
47
+ import { loadProjectToolSyncPolicy } from "./project-sync";
40
48
 
41
49
  export interface ManagedToolState {
42
50
  tool: string;
@@ -44,6 +52,8 @@ export interface ManagedToolState {
44
52
  skillsDir?: string;
45
53
  mcpConfig?: string;
46
54
  agentsDir?: string;
55
+ pluginsDir?: string;
56
+ pluginMarketplacePath?: string;
47
57
  automationDir?: string;
48
58
  toolHome?: string;
49
59
  globalAgentsPath?: string;
@@ -53,6 +63,8 @@ export interface ManagedToolState {
53
63
  skillsBackup?: string | null;
54
64
  mcpBackup?: string | null;
55
65
  agentsBackup?: string | null;
66
+ pluginsBackup?: string | null;
67
+ pluginMarketplaceBackup?: string | null;
56
68
  globalAgentsBackup?: string | null;
57
69
  globalAgentsOverrideBackup?: string | null;
58
70
  rulesBackup?: string | null;
@@ -76,6 +88,8 @@ export interface ToolPaths {
76
88
  skillsDir?: string;
77
89
  mcpConfig?: string;
78
90
  agentsDir?: string;
91
+ pluginsDir?: string;
92
+ pluginMarketplacePath?: string;
79
93
  automationDir?: string;
80
94
  toolHome?: string;
81
95
  rulesDir?: string;
@@ -111,6 +125,44 @@ function homePath(home: string, ...parts: string[]): string {
111
125
  return join(home, ...parts);
112
126
  }
113
127
 
128
+ function codexLiveRoot(home: string, rootDir?: string): string {
129
+ const projectRoot = rootDir ? projectRootFromAiRoot(rootDir, home) : null;
130
+ return projectRoot ?? home;
131
+ }
132
+
133
+ function codexPluginsDir(home: string, rootDir?: string): string {
134
+ return join(codexLiveRoot(home, rootDir), "plugins");
135
+ }
136
+
137
+ function codexSkillsDir(home: string, rootDir?: string): string {
138
+ return join(codexLiveRoot(home, rootDir), ".agents", "skills");
139
+ }
140
+
141
+ function codexPluginMarketplacePath(home: string, rootDir?: string): string {
142
+ return join(
143
+ codexLiveRoot(home, rootDir),
144
+ ".agents",
145
+ "plugins",
146
+ "marketplace.json"
147
+ );
148
+ }
149
+
150
+ function codexLegacySkillsDir(home: string, rootDir?: string): string {
151
+ return join(codexLiveRoot(home, rootDir), ".codex", "skills");
152
+ }
153
+
154
+ function codexLegacyPluginsDir(home: string, rootDir?: string): string {
155
+ return join(codexLiveRoot(home, rootDir), ".codex", "plugins");
156
+ }
157
+
158
+ function codexCanonicalPluginsRoot(rootDir: string): string {
159
+ return join(rootDir, "tools", "codex", "plugins");
160
+ }
161
+
162
+ function codexCanonicalPluginMarketplacePath(rootDir: string): string {
163
+ return join(codexCanonicalPluginsRoot(rootDir), "marketplace.json");
164
+ }
165
+
114
166
  function expandHomePath(pathValue: string, home: string): string {
115
167
  if (pathValue === "~") {
116
168
  return home;
@@ -146,6 +198,79 @@ function renderedHash(text: string): string {
146
198
  return contentHash(normalizeText(text));
147
199
  }
148
200
 
201
+ type ManagedTargetContent = string | Uint8Array;
202
+
203
+ function byteHash(content: Uint8Array): string {
204
+ return createHash("sha256").update(content).digest("hex");
205
+ }
206
+
207
+ function targetContentHash(
208
+ content: ManagedTargetContent,
209
+ options?: { normalizeText?: boolean }
210
+ ): string {
211
+ if (typeof content === "string") {
212
+ return options?.normalizeText === false
213
+ ? byteHash(Buffer.from(content))
214
+ : renderedHash(content);
215
+ }
216
+ return byteHash(content);
217
+ }
218
+
219
+ async function readTargetHash(
220
+ pathValue: string,
221
+ options?: { normalizeText?: boolean }
222
+ ): Promise<string | null> {
223
+ if (!(await fileExists(pathValue))) {
224
+ return null;
225
+ }
226
+ if (options?.normalizeText === false) {
227
+ return byteHash(await Bun.file(pathValue).bytes());
228
+ }
229
+ return renderedHash(await Bun.file(pathValue).text());
230
+ }
231
+
232
+ function normalizeCodexMarketplaceText(text: string): string {
233
+ try {
234
+ const parsed = JSON.parse(text) as unknown;
235
+ if (!isPlainObject(parsed)) {
236
+ return text.endsWith("\n") ? text : `${text}\n`;
237
+ }
238
+ const plugins = Array.isArray(parsed.plugins) ? parsed.plugins : null;
239
+ if (plugins) {
240
+ parsed.plugins = plugins.map((entry) => {
241
+ if (!isPlainObject(entry)) {
242
+ return entry;
243
+ }
244
+ const source = isPlainObject(entry.source) ? { ...entry.source } : null;
245
+ if (
246
+ source?.source === "local" &&
247
+ typeof source.path === "string" &&
248
+ source.path.startsWith("./.codex/plugins/")
249
+ ) {
250
+ source.path = source.path.replace("./.codex/plugins/", "./plugins/");
251
+ }
252
+ return source ? { ...entry, source } : entry;
253
+ });
254
+ }
255
+ return `${JSON.stringify(parsed, null, 2)}\n`;
256
+ } catch {
257
+ return text.endsWith("\n") ? text : `${text}\n`;
258
+ }
259
+ }
260
+
261
+ function isSafeCodexPluginName(name: string): boolean {
262
+ const trimmed = name.trim();
263
+ return (
264
+ trimmed.length > 0 &&
265
+ trimmed !== "." &&
266
+ trimmed !== ".." &&
267
+ !trimmed.includes("..") &&
268
+ !trimmed.includes("/") &&
269
+ !trimmed.includes("\\") &&
270
+ basename(trimmed) === trimmed
271
+ );
272
+ }
273
+
149
274
  function defaultToolPaths(
150
275
  home: string,
151
276
  rootDir?: string
@@ -162,9 +287,13 @@ function defaultToolPaths(
162
287
  },
163
288
  codex: {
164
289
  tool: "codex",
165
- skillsDir: toolBase(".codex", "skills"),
290
+ skillsDir: codexSkillsDir(home, rootDir),
166
291
  mcpConfig: toolBase(".codex", "mcp.json"),
167
292
  agentsDir: toolBase(".codex", "agents"),
293
+ pluginsDir: projectRoot ? undefined : codexPluginsDir(home, rootDir),
294
+ pluginMarketplacePath: projectRoot
295
+ ? undefined
296
+ : codexPluginMarketplacePath(home, rootDir),
168
297
  automationDir: homePath(home, ".codex", "automations"),
169
298
  toolHome: toolBase(".codex"),
170
299
  rulesDir: toolBase(".codex", "rules"),
@@ -264,6 +393,7 @@ async function resolveToolPaths(
264
393
  override?: Record<string, ToolPaths>
265
394
  ): Promise<ToolPaths | null> {
266
395
  const defaults = defaultToolPaths(home, rootDir);
396
+ const projectRoot = rootDir ? projectRootFromAiRoot(rootDir, home) : null;
267
397
  if (override?.[tool]) {
268
398
  const base = defaults[tool] ?? null;
269
399
  return base ? { ...base, ...override[tool] } : (override[tool] ?? null);
@@ -280,6 +410,10 @@ async function resolveToolPaths(
280
410
  return base;
281
411
  }
282
412
 
413
+ if (projectRoot) {
414
+ return base;
415
+ }
416
+
283
417
  const adapterPaths = getAdapter("codex")?.getDefaultPaths?.();
284
418
  const adapterConfig = adapterPaths?.config
285
419
  ? expandHomePath(adapterPaths.config, home)
@@ -629,6 +763,96 @@ async function canonicalAutomationsExist(rootDir: string): Promise<boolean> {
629
763
  }
630
764
  }
631
765
 
766
+ interface CanonicalPluginEntry {
767
+ name: string;
768
+ sourceDir: string;
769
+ files: Map<string, Uint8Array>;
770
+ }
771
+
772
+ async function listRelativeFilesWithDotfiles(root: string): Promise<string[]> {
773
+ const out: string[] = [];
774
+
775
+ async function visit(currentDir: string, prefix = ""): Promise<void> {
776
+ const entries = await readdir(currentDir, { withFileTypes: true }).catch(
777
+ () => [] as import("node:fs").Dirent[]
778
+ );
779
+ for (const entry of entries) {
780
+ const relPath = prefix ? join(prefix, entry.name) : entry.name;
781
+ const fullPath = join(currentDir, entry.name);
782
+ if (entry.isDirectory()) {
783
+ await visit(fullPath, relPath);
784
+ continue;
785
+ }
786
+ if (entry.isFile()) {
787
+ out.push(relPath);
788
+ }
789
+ }
790
+ }
791
+
792
+ await visit(root);
793
+ return out.sort();
794
+ }
795
+
796
+ async function loadCanonicalCodexPlugins(
797
+ rootDir: string
798
+ ): Promise<CanonicalPluginEntry[]> {
799
+ const pluginsRoot = codexCanonicalPluginsRoot(rootDir);
800
+ const entries = await readdir(pluginsRoot, { withFileTypes: true }).catch(
801
+ () => [] as import("node:fs").Dirent[]
802
+ );
803
+ const out: CanonicalPluginEntry[] = [];
804
+
805
+ for (const entry of entries) {
806
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
807
+ continue;
808
+ }
809
+ const sourceDir = join(pluginsRoot, entry.name);
810
+ if (!(await fileExists(join(sourceDir, ".codex-plugin", "plugin.json")))) {
811
+ continue;
812
+ }
813
+ const files = new Map<string, Uint8Array>();
814
+ for (const relPath of await listRelativeFilesWithDotfiles(sourceDir)) {
815
+ files.set(relPath, await Bun.file(join(sourceDir, relPath)).bytes());
816
+ }
817
+ out.push({ name: entry.name, sourceDir, files });
818
+ }
819
+
820
+ return out.sort((a, b) => a.name.localeCompare(b.name));
821
+ }
822
+
823
+ async function canonicalCodexPluginsExist(rootDir: string): Promise<boolean> {
824
+ if (await fileExists(codexCanonicalPluginMarketplacePath(rootDir))) {
825
+ return true;
826
+ }
827
+ return (await loadCanonicalCodexPlugins(rootDir)).length > 0;
828
+ }
829
+
830
+ async function loadCanonicalCodexMarketplaceText(
831
+ rootDir: string
832
+ ): Promise<{ text: string | null; sourcePath: string }> {
833
+ const sourcePath = codexCanonicalPluginMarketplacePath(rootDir);
834
+ const raw = await readTextOrNull(sourcePath);
835
+ return {
836
+ text: raw == null ? null : normalizeCodexMarketplaceText(raw),
837
+ sourcePath,
838
+ };
839
+ }
840
+
841
+ async function hashDirectoryTree(root: string): Promise<string | null> {
842
+ if (!(await fileExists(root))) {
843
+ return null;
844
+ }
845
+ const files = await listRelativeFilesWithDotfiles(root);
846
+ const hash = createHash("sha256");
847
+ for (const relPath of files) {
848
+ hash.update(relPath);
849
+ hash.update("\0");
850
+ hash.update(await Bun.file(join(root, relPath)).bytes());
851
+ hash.update("\0");
852
+ }
853
+ return hash.digest("hex");
854
+ }
855
+
632
856
  async function loadMergedIndex(
633
857
  homeDir: string,
634
858
  rootDir: string
@@ -650,7 +874,11 @@ async function loadEnabledSkillEntries(args: {
650
874
  tool: string;
651
875
  }): Promise<{ name: string; path: string }[]> {
652
876
  const index = await loadMergedIndex(args.homeDir, args.rootDir);
653
- const useBuiltinDefaults = await builtinSyncDefaultsEnabled(args.rootDir);
877
+ const projectPolicy = await loadProjectToolSyncPolicy(args);
878
+ const useBuiltinDefaults = await builtinSyncDefaultsEnabled(
879
+ args.rootDir,
880
+ args.homeDir
881
+ );
654
882
  const out: { name: string; path: string }[] = [];
655
883
 
656
884
  for (const [name, entry] of Object.entries(index.skills)) {
@@ -668,6 +896,15 @@ async function loadEnabledSkillEntries(args: {
668
896
  ) {
669
897
  continue;
670
898
  }
899
+ if (
900
+ projectPolicy &&
901
+ !(
902
+ projectPolicy.skills.includes("*") ||
903
+ projectPolicy.skills.includes(name)
904
+ )
905
+ ) {
906
+ continue;
907
+ }
671
908
  out.push({ name, path: skill.path });
672
909
  }
673
910
 
@@ -675,6 +912,10 @@ async function loadEnabledSkillEntries(args: {
675
912
  return out.sort((a, b) => a.name.localeCompare(b.name));
676
913
  }
677
914
 
915
+ if (projectPolicy) {
916
+ return [];
917
+ }
918
+
678
919
  return (await listSkillDirs(join(args.rootDir, "skills"))).map((name) => ({
679
920
  name,
680
921
  path: join(args.rootDir, "skills", name),
@@ -684,9 +925,14 @@ async function loadEnabledSkillEntries(args: {
684
925
  async function loadManagedAgentEntries(args: {
685
926
  homeDir: string;
686
927
  rootDir: string;
928
+ tool: string;
687
929
  }): Promise<{ name: string; sourcePath: string; raw: string }[]> {
688
930
  const index = await loadMergedIndex(args.homeDir, args.rootDir);
689
- const useBuiltinDefaults = await builtinSyncDefaultsEnabled(args.rootDir);
931
+ const projectPolicy = await loadProjectToolSyncPolicy(args);
932
+ const useBuiltinDefaults = await builtinSyncDefaultsEnabled(
933
+ args.rootDir,
934
+ args.homeDir
935
+ );
690
936
  const out: { name: string; sourcePath: string; raw: string }[] = [];
691
937
 
692
938
  for (const [name, entry] of Object.entries(index.agents)) {
@@ -698,6 +944,15 @@ async function loadManagedAgentEntries(args: {
698
944
  ) {
699
945
  continue;
700
946
  }
947
+ if (
948
+ projectPolicy &&
949
+ !(
950
+ projectPolicy.agents.includes("*") ||
951
+ projectPolicy.agents.includes(name)
952
+ )
953
+ ) {
954
+ continue;
955
+ }
701
956
  const raw = await readTextIfExists(agent.path);
702
957
  if (raw == null) {
703
958
  continue;
@@ -709,6 +964,10 @@ async function loadManagedAgentEntries(args: {
709
964
  return out.sort((a, b) => a.name.localeCompare(b.name));
710
965
  }
711
966
 
967
+ if (projectPolicy) {
968
+ return [];
969
+ }
970
+
712
971
  return await loadCanonicalAgents(args.rootDir);
713
972
  }
714
973
 
@@ -728,7 +987,7 @@ async function planAgentFileChanges({
728
987
  contents: Map<string, string>;
729
988
  sources: Map<string, string>;
730
989
  }> {
731
- const agents = await loadManagedAgentEntries({ homeDir, rootDir });
990
+ const agents = await loadManagedAgentEntries({ homeDir, rootDir, tool });
732
991
  const contents = new Map<string, string>();
733
992
  const sources = new Map<string, string>();
734
993
  const desiredPaths = new Set<string>();
@@ -890,6 +1149,10 @@ async function listSkillDirs(skillsRoot: string): Promise<string[]> {
890
1149
  }
891
1150
  }
892
1151
 
1152
+ async function canonicalSkillsExist(rootDir: string): Promise<boolean> {
1153
+ return (await listSkillDirs(join(rootDir, "skills"))).length > 0;
1154
+ }
1155
+
893
1156
  async function loadEnabledSkillNames({
894
1157
  homeDir,
895
1158
  rootDir,
@@ -903,24 +1166,6 @@ async function loadEnabledSkillNames({
903
1166
  return entries.map((entry) => entry.name);
904
1167
  }
905
1168
 
906
- function extractServersObject(parsed: unknown): Record<string, unknown> | null {
907
- if (!isPlainObject(parsed)) {
908
- return null;
909
- }
910
- const raw = parsed as Record<string, unknown>;
911
- const servers =
912
- (raw.servers as Record<string, unknown> | undefined) ??
913
- (raw.mcpServers as Record<string, unknown> | undefined) ??
914
- ((raw.mcp as Record<string, unknown> | undefined)?.servers as
915
- | Record<string, unknown>
916
- | undefined) ??
917
- null;
918
- if (servers && isPlainObject(servers)) {
919
- return servers;
920
- }
921
- return null;
922
- }
923
-
924
1169
  function canonicalServerToToolConfig(server: unknown): unknown {
925
1170
  if (!isPlainObject(server)) {
926
1171
  return server;
@@ -949,18 +1194,34 @@ function canonicalServerToToolConfig(server: unknown): unknown {
949
1194
  return out;
950
1195
  }
951
1196
 
952
- function filterServersForTool(
953
- servers: Record<string, unknown>,
954
- tool: string
955
- ): Record<string, unknown> {
1197
+ async function filterServersForTool(args: {
1198
+ homeDir: string;
1199
+ rootDir: string;
1200
+ servers: Record<string, unknown>;
1201
+ tool: string;
1202
+ }): Promise<Record<string, unknown>> {
1203
+ const projectPolicy = await loadProjectToolSyncPolicy({
1204
+ homeDir: args.homeDir,
1205
+ rootDir: args.rootDir,
1206
+ tool: args.tool,
1207
+ });
956
1208
  const out: Record<string, unknown> = {};
957
- for (const [name, cfg] of Object.entries(servers)) {
1209
+ for (const [name, cfg] of Object.entries(args.servers)) {
958
1210
  if (isPlainObject(cfg)) {
959
1211
  const enabledFor = cfg.enabledFor;
960
- if (Array.isArray(enabledFor) && !enabledFor.includes(tool)) {
1212
+ if (Array.isArray(enabledFor) && !enabledFor.includes(args.tool)) {
961
1213
  continue;
962
1214
  }
963
1215
  }
1216
+ if (
1217
+ projectPolicy &&
1218
+ !(
1219
+ projectPolicy.mcpServers.includes("*") ||
1220
+ projectPolicy.mcpServers.includes(name)
1221
+ )
1222
+ ) {
1223
+ continue;
1224
+ }
964
1225
  out[name] = canonicalServerToToolConfig(cfg);
965
1226
  }
966
1227
  return out;
@@ -970,21 +1231,11 @@ async function loadCanonicalServers(rootDir: string): Promise<{
970
1231
  servers: Record<string, unknown>;
971
1232
  sourcePath: string | null;
972
1233
  }> {
973
- const serversPath = join(rootDir, "mcp", "servers.json");
974
- const mcpPath = join(rootDir, "mcp", "mcp.json");
975
-
976
- const preferred = (await fileExists(serversPath)) ? serversPath : mcpPath;
977
- if (!(await fileExists(preferred))) {
978
- return { servers: {}, sourcePath: null };
979
- }
980
- try {
981
- const txt = await Bun.file(preferred).text();
982
- const parsed = JSON.parse(txt) as unknown;
983
- const servers = extractServersObject(parsed) ?? {};
984
- return { servers, sourcePath: preferred };
985
- } catch {
986
- return { servers: {}, sourcePath: preferred };
987
- }
1234
+ const loaded = await loadCanonicalMcpState(rootDir);
1235
+ const sourcePath = (await fileExists(loaded.trackedPath))
1236
+ ? loaded.trackedPath
1237
+ : null;
1238
+ return { servers: loaded.trackedServers, sourcePath };
988
1239
  }
989
1240
 
990
1241
  async function ensureEmptyDir(p: string) {
@@ -1200,6 +1451,8 @@ interface ExistingManagedItem {
1200
1451
  | "skill"
1201
1452
  | "agent"
1202
1453
  | "automation"
1454
+ | "plugin"
1455
+ | "plugin-marketplace"
1203
1456
  | "global-doc"
1204
1457
  | "rule"
1205
1458
  | "tool-config"
@@ -1744,12 +1997,6 @@ async function adoptExistingToolConfig(args: {
1744
1997
  ];
1745
1998
  }
1746
1999
 
1747
- function normalizeCanonicalMcpServers(
1748
- servers: Record<string, unknown>
1749
- ): string {
1750
- return JSON.stringify({ servers }, null, 2);
1751
- }
1752
-
1753
2000
  async function planExistingMcpAdoption(args: {
1754
2001
  rootDir: string;
1755
2002
  tool: string;
@@ -1845,7 +2092,7 @@ async function adoptExistingMcpServers(args: {
1845
2092
  const canonicalPath =
1846
2093
  canonical.sourcePath ?? join(args.rootDir, "mcp", "servers.json");
1847
2094
  await ensureDir(dirname(canonicalPath));
1848
- await Bun.write(canonicalPath, `${normalizeCanonicalMcpServers(merged)}\n`);
2095
+ await Bun.write(canonicalPath, stringifyCanonicalMcpServers(merged));
1849
2096
  return adopted;
1850
2097
  }
1851
2098
 
@@ -2036,16 +2283,25 @@ async function syncSkillSymlinks({
2036
2283
  }
2037
2284
 
2038
2285
  async function planMcpWrite({
2286
+ homeDir,
2039
2287
  mcpConfigPath,
2040
2288
  rootDir,
2041
2289
  tool,
2042
2290
  }: {
2291
+ homeDir: string;
2043
2292
  mcpConfigPath: string;
2044
2293
  rootDir: string;
2045
2294
  tool: string;
2046
2295
  }): Promise<{ needsWrite: boolean; contents: string }> {
2047
- const { servers } = await loadCanonicalServers(rootDir);
2048
- const filtered = filterServersForTool(servers, tool);
2296
+ const { servers } = await loadCanonicalMcpState(rootDir, {
2297
+ includeLocal: true,
2298
+ });
2299
+ const filtered = await filterServersForTool({
2300
+ homeDir,
2301
+ rootDir,
2302
+ servers,
2303
+ tool,
2304
+ });
2049
2305
  const contents = `${JSON.stringify({ mcpServers: filtered }, null, 2)}\n`;
2050
2306
 
2051
2307
  if (!(await fileExists(mcpConfigPath))) {
@@ -2060,17 +2316,19 @@ async function planMcpWrite({
2060
2316
  }
2061
2317
 
2062
2318
  async function syncMcpConfig({
2319
+ homeDir,
2063
2320
  mcpConfigPath,
2064
2321
  rootDir,
2065
2322
  tool,
2066
2323
  dryRun,
2067
2324
  }: {
2325
+ homeDir: string;
2068
2326
  mcpConfigPath: string;
2069
2327
  rootDir: string;
2070
2328
  tool: string;
2071
2329
  dryRun?: boolean;
2072
2330
  }): Promise<{ needsWrite: boolean }> {
2073
- const plan = await planMcpWrite({ mcpConfigPath, rootDir, tool });
2331
+ const plan = await planMcpWrite({ homeDir, mcpConfigPath, rootDir, tool });
2074
2332
  if (dryRun) {
2075
2333
  return { needsWrite: plan.needsWrite };
2076
2334
  }
@@ -2082,16 +2340,25 @@ async function syncMcpConfig({
2082
2340
  }
2083
2341
 
2084
2342
  async function writeToolMcpConfig({
2343
+ homeDir,
2085
2344
  mcpConfigPath,
2086
2345
  rootDir,
2087
2346
  tool,
2088
2347
  }: {
2348
+ homeDir: string;
2089
2349
  mcpConfigPath: string;
2090
2350
  rootDir: string;
2091
2351
  tool: string;
2092
2352
  }) {
2093
- const { servers } = await loadCanonicalServers(rootDir);
2094
- const filtered = filterServersForTool(servers, tool);
2353
+ const { servers } = await loadCanonicalMcpState(rootDir, {
2354
+ includeLocal: true,
2355
+ });
2356
+ const filtered = await filterServersForTool({
2357
+ homeDir,
2358
+ rootDir,
2359
+ servers,
2360
+ tool,
2361
+ });
2095
2362
  await ensureDir(dirname(mcpConfigPath));
2096
2363
  await Bun.write(
2097
2364
  mcpConfigPath,
@@ -2113,19 +2380,34 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2113
2380
  throw new Error(`Unknown tool: ${tool}`);
2114
2381
  }
2115
2382
 
2116
- const existingSkillPlan = toolPaths.skillsDir
2117
- ? await planExistingToolSkillAdoption({
2118
- rootDir,
2119
- toolSkillsDir: toolPaths.skillsDir,
2120
- })
2121
- : {
2122
- adopt: [],
2123
- identical: [],
2124
- conflicts: [],
2125
- ignored: [],
2126
- };
2383
+ const existingSkillPlan =
2384
+ toolPaths.skillsDir || tool === "codex"
2385
+ ? mergeManagedImportPlans(
2386
+ asManagedSkillPlan(
2387
+ toolPaths.skillsDir
2388
+ ? await planExistingToolSkillAdoption({
2389
+ rootDir,
2390
+ toolSkillsDir: toolPaths.skillsDir,
2391
+ })
2392
+ : {
2393
+ adopt: [],
2394
+ identical: [],
2395
+ conflicts: [],
2396
+ ignored: [],
2397
+ }
2398
+ ),
2399
+ tool === "codex"
2400
+ ? asManagedSkillPlan(
2401
+ await planExistingToolSkillAdoption({
2402
+ rootDir,
2403
+ toolSkillsDir: codexLegacySkillsDir(home, rootDir),
2404
+ })
2405
+ )
2406
+ : emptyManagedImportPlan()
2407
+ )
2408
+ : emptyManagedImportPlan();
2127
2409
  const existingImportPlan = mergeManagedImportPlans(
2128
- asManagedSkillPlan(existingSkillPlan),
2410
+ existingSkillPlan,
2129
2411
  toolPaths.agentsDir
2130
2412
  ? await planExistingToolAgentAdoption({
2131
2413
  tool,
@@ -2160,6 +2442,15 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2160
2442
  toolConfigPath: toolPaths.toolConfig,
2161
2443
  })
2162
2444
  : emptyManagedImportPlan(),
2445
+ tool === "codex" &&
2446
+ (toolPaths.pluginsDir || toolPaths.pluginMarketplacePath)
2447
+ ? await planExistingCodexPluginAdoption({
2448
+ homeDir: home,
2449
+ rootDir,
2450
+ pluginsDir: toolPaths.pluginsDir,
2451
+ pluginMarketplacePath: toolPaths.pluginMarketplacePath,
2452
+ })
2453
+ : emptyManagedImportPlan(),
2163
2454
  toolPaths.mcpConfig
2164
2455
  ? await planExistingMcpAdoption({
2165
2456
  rootDir,
@@ -2181,6 +2472,8 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2181
2472
  toolPaths.toolHome ||
2182
2473
  toolPaths.rulesDir ||
2183
2474
  toolPaths.toolConfig ||
2475
+ toolPaths.pluginsDir ||
2476
+ toolPaths.pluginMarketplacePath ||
2184
2477
  toolPaths.mcpConfig) &&
2185
2478
  !opts.adoptExisting &&
2186
2479
  (existingImportPlan.adopt.length > 0 ||
@@ -2227,7 +2520,10 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2227
2520
  ? await adoptSkillsIntoCanonicalStore({
2228
2521
  homeDir: home,
2229
2522
  rootDir,
2230
- skillSourceDirs: [toolPaths.skillsDir],
2523
+ skillSourceDirs: [
2524
+ toolPaths.skillsDir,
2525
+ ...(tool === "codex" ? [codexLegacySkillsDir(home, rootDir)] : []),
2526
+ ],
2231
2527
  })
2232
2528
  : [];
2233
2529
 
@@ -2250,6 +2546,26 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2250
2546
  });
2251
2547
  }
2252
2548
  }
2549
+ if (tool === "codex" && opts.adoptExisting) {
2550
+ const legacySkillsDir = codexLegacySkillsDir(home, rootDir);
2551
+ const result = await adoptExistingToolSkills({
2552
+ rootDir,
2553
+ toolSkillsDir: legacySkillsDir,
2554
+ conflictMode: importConflictMode,
2555
+ });
2556
+ for (const name of result.adopted) {
2557
+ if (!adoptedSkills.includes(name)) {
2558
+ adoptedSkills.push(name);
2559
+ }
2560
+ }
2561
+ if (result.adopted.length > 0) {
2562
+ await buildIndex({
2563
+ homeDir: home,
2564
+ rootDir,
2565
+ force: false,
2566
+ });
2567
+ }
2568
+ }
2253
2569
  if (toolPaths.agentsDir && opts.adoptExisting) {
2254
2570
  const result = await adoptExistingToolAgents({
2255
2571
  tool,
@@ -2303,6 +2619,20 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2303
2619
  });
2304
2620
  adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
2305
2621
  }
2622
+ if (
2623
+ tool === "codex" &&
2624
+ opts.adoptExisting &&
2625
+ (toolPaths.pluginsDir || toolPaths.pluginMarketplacePath)
2626
+ ) {
2627
+ const result = await adoptExistingCodexPlugins({
2628
+ homeDir: home,
2629
+ rootDir,
2630
+ pluginsDir: toolPaths.pluginsDir,
2631
+ pluginMarketplacePath: toolPaths.pluginMarketplacePath,
2632
+ conflictMode: importConflictMode,
2633
+ });
2634
+ adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
2635
+ }
2306
2636
  if (adoptedSkills.length > 0) {
2307
2637
  await buildIndex({
2308
2638
  homeDir: home,
@@ -2351,6 +2681,14 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2351
2681
  toolConfigPath: toolPaths.toolConfig,
2352
2682
  })
2353
2683
  : null;
2684
+ const pluginPreview =
2685
+ tool === "codex" && toolPaths.pluginsDir && toolPaths.pluginMarketplacePath
2686
+ ? await planCodexPluginFileChanges({
2687
+ rootDir,
2688
+ pluginsDir: toolPaths.pluginsDir,
2689
+ pluginMarketplacePath: toolPaths.pluginMarketplacePath,
2690
+ })
2691
+ : null;
2354
2692
 
2355
2693
  const skillsBackup = toolPaths.skillsDir
2356
2694
  ? await backupPath(toolPaths.skillsDir, opts.now)
@@ -2381,6 +2719,15 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2381
2719
  toolPaths.toolConfig && toolConfigPreview?.managedConfig
2382
2720
  ? await backupPath(toolPaths.toolConfig, opts.now)
2383
2721
  : null;
2722
+ const pluginsBackup =
2723
+ toolPaths.pluginsDir && pluginPreview?.contents.size
2724
+ ? await backupPath(toolPaths.pluginsDir, opts.now)
2725
+ : null;
2726
+ const pluginMarketplaceBackup =
2727
+ toolPaths.pluginMarketplacePath &&
2728
+ pluginPreview?.contents.has(toolPaths.pluginMarketplacePath)
2729
+ ? await backupPath(toolPaths.pluginMarketplacePath, opts.now)
2730
+ : null;
2384
2731
 
2385
2732
  if (toolPaths.skillsDir) {
2386
2733
  await ensureEmptyDir(toolPaths.skillsDir);
@@ -2402,6 +2749,7 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2402
2749
 
2403
2750
  if (toolPaths.mcpConfig) {
2404
2751
  await writeToolMcpConfig({
2752
+ homeDir: home,
2405
2753
  mcpConfigPath: toolPaths.mcpConfig,
2406
2754
  rootDir,
2407
2755
  tool,
@@ -2457,12 +2805,41 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2457
2805
  });
2458
2806
  }
2459
2807
 
2808
+ if (
2809
+ pluginPreview &&
2810
+ toolPaths.pluginsDir &&
2811
+ (pluginPreview.contents.size > 0 || pluginPreview.remove.length > 0)
2812
+ ) {
2813
+ await ensureDir(toolPaths.pluginsDir);
2814
+ if (
2815
+ toolPaths.pluginMarketplacePath &&
2816
+ pluginPreview.contents.has(toolPaths.pluginMarketplacePath)
2817
+ ) {
2818
+ await ensureDir(dirname(toolPaths.pluginMarketplacePath));
2819
+ }
2820
+ await applyRenderedRemoves(pluginPreview.remove);
2821
+ await applyRenderedWrites({
2822
+ contents: pluginPreview.contents,
2823
+ targets: Array.from(pluginPreview.contents.keys()),
2824
+ });
2825
+ await pruneEmptyParents(pluginPreview.remove, toolPaths.pluginsDir);
2826
+ }
2827
+
2460
2828
  state.tools[tool] = {
2461
2829
  tool,
2462
2830
  managedAt: nowIso(opts.now),
2463
2831
  skillsDir: toolPaths.skillsDir,
2464
2832
  mcpConfig: toolPaths.mcpConfig,
2465
2833
  agentsDir: toolPaths.agentsDir,
2834
+ pluginsDir:
2835
+ pluginPreview?.contents.size && toolPaths.pluginsDir
2836
+ ? toolPaths.pluginsDir
2837
+ : undefined,
2838
+ pluginMarketplacePath:
2839
+ toolPaths.pluginMarketplacePath &&
2840
+ pluginPreview?.contents.has(toolPaths.pluginMarketplacePath)
2841
+ ? toolPaths.pluginMarketplacePath
2842
+ : undefined,
2466
2843
  automationDir: toolPaths.automationDir,
2467
2844
  toolHome: globalDocsPreview?.managedTargets.length
2468
2845
  ? toolPaths.toolHome
@@ -2484,6 +2861,8 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2484
2861
  skillsBackup,
2485
2862
  mcpBackup,
2486
2863
  agentsBackup,
2864
+ pluginsBackup,
2865
+ pluginMarketplaceBackup,
2487
2866
  globalAgentsBackup,
2488
2867
  globalAgentsOverrideBackup,
2489
2868
  rulesBackup,
@@ -2549,6 +2928,17 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2549
2928
  });
2550
2929
  }
2551
2930
 
2931
+ if (pluginPreview) {
2932
+ updateRenderedTargetState({
2933
+ entry: managedEntry,
2934
+ writtenTargets: Array.from(pluginPreview.contents.keys()),
2935
+ removedTargets: pluginPreview.remove,
2936
+ contents: pluginPreview.contents,
2937
+ sources: pluginPreview.sources,
2938
+ normalizeText: false,
2939
+ });
2940
+ }
2941
+
2552
2942
  await saveManagedState(state, home, rootDir);
2553
2943
 
2554
2944
  for (const name of adoptedSkills) {
@@ -2625,6 +3015,20 @@ export async function unmanageTool(tool: string, opts: ManageOptions = {}) {
2625
3015
  });
2626
3016
  }
2627
3017
 
3018
+ if (entry.pluginsDir) {
3019
+ await restoreBackup({
3020
+ original: entry.pluginsDir,
3021
+ backup: entry.pluginsBackup ?? null,
3022
+ });
3023
+ }
3024
+
3025
+ if (entry.pluginMarketplacePath) {
3026
+ await restoreBackup({
3027
+ original: entry.pluginMarketplacePath,
3028
+ backup: entry.pluginMarketplaceBackup ?? null,
3029
+ });
3030
+ }
3031
+
2628
3032
  if (entry.automationDir) {
2629
3033
  const automationTargets = Object.keys(entry.renderedTargets ?? {}).filter(
2630
3034
  (targetPath) => targetPath.startsWith(join(entry.automationDir!, ""))
@@ -2705,6 +3109,19 @@ async function repairManagedToolEntry(args: {
2705
3109
  const next: ManagedToolState = { ...args.entry };
2706
3110
  let changed = false;
2707
3111
 
3112
+ if (
3113
+ tool === "codex" &&
3114
+ toolPaths.skillsDir &&
3115
+ (await canonicalSkillsExist(rootDir)) &&
3116
+ next.skillsDir !== toolPaths.skillsDir
3117
+ ) {
3118
+ if (!next.skillsBackup) {
3119
+ next.skillsBackup = await backupPath(toolPaths.skillsDir);
3120
+ }
3121
+ next.skillsDir = toolPaths.skillsDir;
3122
+ changed = true;
3123
+ }
3124
+
2708
3125
  if (
2709
3126
  !next.agentsDir &&
2710
3127
  toolPaths.agentsDir &&
@@ -2724,6 +3141,43 @@ async function repairManagedToolEntry(args: {
2724
3141
  changed = true;
2725
3142
  }
2726
3143
 
3144
+ if (
3145
+ tool === "codex" &&
3146
+ !(next.pluginsDir && next.pluginMarketplacePath) &&
3147
+ toolPaths.pluginsDir &&
3148
+ toolPaths.pluginMarketplacePath &&
3149
+ (await canonicalCodexPluginsExist(rootDir))
3150
+ ) {
3151
+ if (!next.pluginsDir) {
3152
+ next.pluginsBackup = await backupPath(toolPaths.pluginsDir);
3153
+ next.pluginsDir = toolPaths.pluginsDir;
3154
+ changed = true;
3155
+ }
3156
+ if (!next.pluginMarketplacePath) {
3157
+ next.pluginMarketplaceBackup = await backupPath(
3158
+ toolPaths.pluginMarketplacePath
3159
+ );
3160
+ next.pluginMarketplacePath = toolPaths.pluginMarketplacePath;
3161
+ changed = true;
3162
+ }
3163
+ }
3164
+
3165
+ if (
3166
+ tool === "codex" &&
3167
+ !toolPaths.pluginsDir &&
3168
+ !toolPaths.pluginMarketplacePath &&
3169
+ (next.pluginsDir ||
3170
+ next.pluginMarketplacePath ||
3171
+ next.pluginsBackup ||
3172
+ next.pluginMarketplaceBackup)
3173
+ ) {
3174
+ next.pluginsDir = undefined;
3175
+ next.pluginMarketplacePath = undefined;
3176
+ next.pluginsBackup = undefined;
3177
+ next.pluginMarketplaceBackup = undefined;
3178
+ changed = true;
3179
+ }
3180
+
2727
3181
  if (toolPaths.toolHome && !next.toolHome) {
2728
3182
  const preview = await syncToolGlobalDocs({
2729
3183
  homeDir,
@@ -2807,10 +3261,11 @@ async function planRenderedTargetConflicts(args: {
2807
3261
  entry: ManagedToolState;
2808
3262
  desiredWrites: string[];
2809
3263
  desiredRemoves: string[];
2810
- desiredContents: Map<string, string>;
3264
+ desiredContents: Map<string, ManagedTargetContent>;
2811
3265
  desiredSources: Map<string, string>;
2812
3266
  conflictMode?: "warn" | "overwrite";
2813
3267
  protectAllSources?: boolean;
3268
+ normalizeText?: boolean;
2814
3269
  }): Promise<RenderedApplyPlan> {
2815
3270
  if (args.conflictMode === "overwrite") {
2816
3271
  return {
@@ -2848,17 +3303,19 @@ async function planRenderedTargetConflicts(args: {
2848
3303
  }
2849
3304
 
2850
3305
  const prior = previous[targetPath];
2851
- const current = await readTextIfExists(targetPath);
2852
- if (current == null) {
3306
+ const currentHash = await readTargetHash(targetPath, {
3307
+ normalizeText: args.normalizeText,
3308
+ });
3309
+ if (currentHash == null) {
2853
3310
  if (args.desiredWrites.includes(targetPath)) {
2854
3311
  write.push(targetPath);
2855
3312
  }
2856
3313
  continue;
2857
3314
  }
2858
-
2859
- const currentHash = renderedHash(current);
2860
3315
  const desiredHash = args.desiredContents.get(targetPath)
2861
- ? renderedHash(args.desiredContents.get(targetPath)!)
3316
+ ? targetContentHash(args.desiredContents.get(targetPath)!, {
3317
+ normalizeText: args.normalizeText,
3318
+ })
2862
3319
  : null;
2863
3320
  if (prior?.hash) {
2864
3321
  if (
@@ -2931,7 +3388,7 @@ function logRenderedConflicts(
2931
3388
  }
2932
3389
 
2933
3390
  async function applyRenderedWrites(args: {
2934
- contents: Map<string, string>;
3391
+ contents: Map<string, ManagedTargetContent>;
2935
3392
  targets: string[];
2936
3393
  }) {
2937
3394
  for (const pathValue of args.targets) {
@@ -2942,7 +3399,9 @@ async function applyRenderedWrites(args: {
2942
3399
  await mkdir(dirname(pathValue), { recursive: true });
2943
3400
  await Bun.write(
2944
3401
  pathValue,
2945
- desired.endsWith("\n") ? desired : `${desired}\n`
3402
+ typeof desired === "string" && !desired.endsWith("\n")
3403
+ ? `${desired}\n`
3404
+ : desired
2946
3405
  );
2947
3406
  }
2948
3407
  }
@@ -2975,8 +3434,9 @@ function updateRenderedTargetState(args: {
2975
3434
  entry: ManagedToolState;
2976
3435
  writtenTargets: string[];
2977
3436
  removedTargets: string[];
2978
- contents: Map<string, string>;
3437
+ contents: Map<string, ManagedTargetContent>;
2979
3438
  sources: Map<string, string>;
3439
+ normalizeText?: boolean;
2980
3440
  }) {
2981
3441
  const next = { ...(args.entry.renderedTargets ?? {}) };
2982
3442
  for (const pathValue of args.removedTargets) {
@@ -2989,7 +3449,9 @@ function updateRenderedTargetState(args: {
2989
3449
  continue;
2990
3450
  }
2991
3451
  next[pathValue] = {
2992
- hash: renderedHash(contents),
3452
+ hash: targetContentHash(contents, {
3453
+ normalizeText: args.normalizeText,
3454
+ }),
2993
3455
  sourcePath,
2994
3456
  sourceKind: renderedSourceKindForPath(sourcePath),
2995
3457
  };
@@ -3036,6 +3498,8 @@ function logSyncDryRun({
3036
3498
  rulesConflicts,
3037
3499
  configPlan,
3038
3500
  configConflicts,
3501
+ pluginPlan,
3502
+ pluginConflicts,
3039
3503
  }: {
3040
3504
  tool: string;
3041
3505
  entry: ManagedToolState;
@@ -3051,6 +3515,8 @@ function logSyncDryRun({
3051
3515
  rulesConflicts: RenderedConflict[];
3052
3516
  configPlan: { write: boolean; remove: boolean; targetPath: string };
3053
3517
  configConflicts: RenderedConflict[];
3518
+ pluginPlan: { write: string[]; remove: string[] };
3519
+ pluginConflicts: RenderedConflict[];
3054
3520
  }) {
3055
3521
  for (const name of skillPlan.add) {
3056
3522
  console.log(`${tool}: would add skill ${name}`);
@@ -3093,6 +3559,13 @@ function logSyncDryRun({
3093
3559
  console.log(`${tool}: would remove tool config ${configPlan.targetPath}`);
3094
3560
  }
3095
3561
  logRenderedConflicts(tool, configConflicts, true);
3562
+ for (const p of pluginPlan.write) {
3563
+ console.log(`${tool}: would write plugin asset ${p}`);
3564
+ }
3565
+ for (const p of pluginPlan.remove) {
3566
+ console.log(`${tool}: would remove plugin asset ${p}`);
3567
+ }
3568
+ logRenderedConflicts(tool, pluginConflicts, true);
3096
3569
  if (mcpPlan.needsWrite && entry.mcpConfig) {
3097
3570
  console.log(`${tool}: would update mcp config ${entry.mcpConfig}`);
3098
3571
  }
@@ -3109,12 +3582,15 @@ function logSyncDryRun({
3109
3582
  rulesPlan.remove.length === 0 &&
3110
3583
  !configPlan.write &&
3111
3584
  !configPlan.remove &&
3585
+ pluginPlan.write.length === 0 &&
3586
+ pluginPlan.remove.length === 0 &&
3112
3587
  !mcpPlan.needsWrite &&
3113
3588
  agentConflicts.length === 0 &&
3114
3589
  automationConflicts.length === 0 &&
3115
3590
  globalDocsConflicts.length === 0 &&
3116
3591
  rulesConflicts.length === 0 &&
3117
- configConflicts.length === 0
3592
+ configConflicts.length === 0 &&
3593
+ pluginConflicts.length === 0
3118
3594
  ) {
3119
3595
  console.log(`${tool}: no changes`);
3120
3596
  }
@@ -3199,6 +3675,24 @@ async function repairManagedCanonicalContent(args: {
3199
3675
  adopted.push(...items.map((item) => `${item.kind}:${item.name}`));
3200
3676
  }
3201
3677
 
3678
+ if (
3679
+ args.tool === "codex" &&
3680
+ (args.entry.pluginsBackup ||
3681
+ args.entry.pluginsDir ||
3682
+ args.entry.pluginMarketplaceBackup ||
3683
+ args.entry.pluginMarketplacePath)
3684
+ ) {
3685
+ const items = await adoptExistingCodexPlugins({
3686
+ homeDir: args.homeDir,
3687
+ rootDir: args.rootDir,
3688
+ pluginsDir: args.entry.pluginsBackup ?? args.entry.pluginsDir,
3689
+ pluginMarketplacePath:
3690
+ args.entry.pluginMarketplaceBackup ?? args.entry.pluginMarketplacePath,
3691
+ conflictMode: "keep-canonical",
3692
+ });
3693
+ adopted.push(...items.map((item) => `${item.kind}:${item.name}`));
3694
+ }
3695
+
3202
3696
  if (adopted.length > 0) {
3203
3697
  await buildIndex({
3204
3698
  homeDir: args.homeDir,
@@ -3210,6 +3704,289 @@ async function repairManagedCanonicalContent(args: {
3210
3704
  return adopted;
3211
3705
  }
3212
3706
 
3707
+ async function discoverExistingCodexPluginEntries(args: {
3708
+ homeDir: string;
3709
+ rootDir: string;
3710
+ pluginMarketplacePath?: string;
3711
+ pluginsDir?: string;
3712
+ }): Promise<
3713
+ {
3714
+ name: string;
3715
+ livePath: string;
3716
+ sourcePath: string;
3717
+ }[]
3718
+ > {
3719
+ if (projectRootFromAiRoot(args.rootDir, args.homeDir) != null) {
3720
+ return [];
3721
+ }
3722
+
3723
+ const results = new Map<
3724
+ string,
3725
+ { name: string; livePath: string; sourcePath: string }
3726
+ >();
3727
+ const liveRoot = codexLiveRoot(args.homeDir, args.rootDir);
3728
+ const marketplaceRaw = args.pluginMarketplacePath
3729
+ ? await readTextOrNull(args.pluginMarketplacePath)
3730
+ : null;
3731
+ if (marketplaceRaw) {
3732
+ try {
3733
+ const parsed = JSON.parse(marketplaceRaw) as unknown;
3734
+ const plugins =
3735
+ isPlainObject(parsed) && Array.isArray(parsed.plugins)
3736
+ ? parsed.plugins
3737
+ : [];
3738
+ for (const entry of plugins) {
3739
+ if (
3740
+ !isPlainObject(entry) ||
3741
+ typeof entry.name !== "string" ||
3742
+ !isSafeCodexPluginName(entry.name)
3743
+ ) {
3744
+ continue;
3745
+ }
3746
+ const source = isPlainObject(entry.source) ? entry.source : null;
3747
+ if (!(source?.source === "local" && typeof source.path === "string")) {
3748
+ continue;
3749
+ }
3750
+ const pathValue = source.path.trim();
3751
+ if (
3752
+ !(
3753
+ pathValue === `./plugins/${entry.name}` ||
3754
+ pathValue === `./.codex/plugins/${entry.name}`
3755
+ )
3756
+ ) {
3757
+ continue;
3758
+ }
3759
+ const livePath = join(liveRoot, pathValue.slice(2));
3760
+ if (
3761
+ !(await fileExists(join(livePath, ".codex-plugin", "plugin.json")))
3762
+ ) {
3763
+ continue;
3764
+ }
3765
+ results.set(entry.name, {
3766
+ name: entry.name,
3767
+ livePath,
3768
+ sourcePath: pathValue,
3769
+ });
3770
+ }
3771
+ } catch {
3772
+ // Ignore malformed marketplace files during adoption planning.
3773
+ }
3774
+ }
3775
+
3776
+ for (const candidateRoot of [
3777
+ args.pluginsDir,
3778
+ codexLegacyPluginsDir(args.homeDir, args.rootDir),
3779
+ ]) {
3780
+ if (!(candidateRoot && (await fileExists(candidateRoot)))) {
3781
+ continue;
3782
+ }
3783
+ const entries = await readdir(candidateRoot, { withFileTypes: true }).catch(
3784
+ () => [] as import("node:fs").Dirent[]
3785
+ );
3786
+ for (const entry of entries) {
3787
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
3788
+ continue;
3789
+ }
3790
+ const livePath = join(candidateRoot, entry.name);
3791
+ if (!(await fileExists(join(livePath, ".codex-plugin", "plugin.json")))) {
3792
+ continue;
3793
+ }
3794
+ if (results.has(entry.name)) {
3795
+ continue;
3796
+ }
3797
+ const relativePrefix =
3798
+ candidateRoot === args.pluginsDir ? "./plugins/" : "./.codex/plugins/";
3799
+ results.set(entry.name, {
3800
+ name: entry.name,
3801
+ livePath,
3802
+ sourcePath: `${relativePrefix}${entry.name}`,
3803
+ });
3804
+ }
3805
+ }
3806
+
3807
+ return Array.from(results.values()).sort((a, b) =>
3808
+ a.name.localeCompare(b.name)
3809
+ );
3810
+ }
3811
+
3812
+ async function planExistingCodexPluginAdoption(args: {
3813
+ homeDir: string;
3814
+ rootDir: string;
3815
+ pluginMarketplacePath?: string;
3816
+ pluginsDir?: string;
3817
+ }): Promise<ExistingManagedImportPlan> {
3818
+ const plan = emptyManagedImportPlan();
3819
+ const canonicalMarketplacePath = codexCanonicalPluginMarketplacePath(
3820
+ args.rootDir
3821
+ );
3822
+ const marketplaceRaw = args.pluginMarketplacePath
3823
+ ? await readTextOrNull(args.pluginMarketplacePath)
3824
+ : null;
3825
+ if (marketplaceRaw != null) {
3826
+ const normalizedLive = normalizeCodexMarketplaceText(marketplaceRaw);
3827
+ const canonicalRaw = await readTextOrNull(canonicalMarketplacePath);
3828
+ const item: ExistingManagedItem = {
3829
+ kind: "plugin-marketplace",
3830
+ name: "codex/plugins/marketplace.json",
3831
+ livePath: args.pluginMarketplacePath!,
3832
+ canonicalPath: canonicalMarketplacePath,
3833
+ };
3834
+ if (canonicalRaw == null) {
3835
+ plan.adopt.push(item);
3836
+ } else if (normalizeCodexMarketplaceText(canonicalRaw) === normalizedLive) {
3837
+ plan.identical.push(item);
3838
+ } else {
3839
+ plan.conflicts.push(item);
3840
+ }
3841
+ }
3842
+
3843
+ for (const plugin of await discoverExistingCodexPluginEntries(args)) {
3844
+ const canonicalPath = join(
3845
+ codexCanonicalPluginsRoot(args.rootDir),
3846
+ plugin.name
3847
+ );
3848
+ const canonicalHash = await hashDirectoryTree(canonicalPath);
3849
+ const liveHash = await hashDirectoryTree(plugin.livePath);
3850
+ const item: ExistingManagedItem = {
3851
+ kind: "plugin",
3852
+ name: plugin.name,
3853
+ livePath: plugin.livePath,
3854
+ canonicalPath,
3855
+ };
3856
+ if (canonicalHash == null) {
3857
+ plan.adopt.push(item);
3858
+ } else if (canonicalHash === liveHash) {
3859
+ plan.identical.push(item);
3860
+ } else {
3861
+ plan.conflicts.push(item);
3862
+ }
3863
+ }
3864
+
3865
+ return mergeManagedImportPlans(plan);
3866
+ }
3867
+
3868
+ async function adoptExistingCodexPlugins(args: {
3869
+ homeDir: string;
3870
+ rootDir: string;
3871
+ pluginMarketplacePath?: string;
3872
+ pluginsDir?: string;
3873
+ conflictMode: "keep-canonical" | "keep-existing";
3874
+ }): Promise<ExistingManagedItem[]> {
3875
+ const adopted: ExistingManagedItem[] = [];
3876
+ const canonicalMarketplacePath = codexCanonicalPluginMarketplacePath(
3877
+ args.rootDir
3878
+ );
3879
+ const marketplaceRaw = args.pluginMarketplacePath
3880
+ ? await readTextOrNull(args.pluginMarketplacePath)
3881
+ : null;
3882
+ if (marketplaceRaw != null) {
3883
+ const normalizedLive = normalizeCodexMarketplaceText(marketplaceRaw);
3884
+ const canonicalRaw = await readTextOrNull(canonicalMarketplacePath);
3885
+ if (canonicalRaw == null || args.conflictMode === "keep-existing") {
3886
+ await ensureDir(dirname(canonicalMarketplacePath));
3887
+ await Bun.write(canonicalMarketplacePath, normalizedLive);
3888
+ adopted.push({
3889
+ kind: "plugin-marketplace",
3890
+ name: "codex/plugins/marketplace.json",
3891
+ livePath: args.pluginMarketplacePath!,
3892
+ canonicalPath: canonicalMarketplacePath,
3893
+ });
3894
+ }
3895
+ }
3896
+
3897
+ for (const plugin of await discoverExistingCodexPluginEntries(args)) {
3898
+ const canonicalPath = join(
3899
+ codexCanonicalPluginsRoot(args.rootDir),
3900
+ plugin.name
3901
+ );
3902
+ const canonicalHash = await hashDirectoryTree(canonicalPath);
3903
+ const liveHash = await hashDirectoryTree(plugin.livePath);
3904
+ if (
3905
+ canonicalHash != null &&
3906
+ canonicalHash !== liveHash &&
3907
+ args.conflictMode !== "keep-existing"
3908
+ ) {
3909
+ continue;
3910
+ }
3911
+ await ensureDir(dirname(canonicalPath));
3912
+ await rm(canonicalPath, { recursive: true, force: true });
3913
+ await cp(plugin.livePath, canonicalPath, { recursive: true });
3914
+ adopted.push({
3915
+ kind: "plugin",
3916
+ name: plugin.name,
3917
+ livePath: plugin.livePath,
3918
+ canonicalPath,
3919
+ });
3920
+ }
3921
+
3922
+ return adopted;
3923
+ }
3924
+
3925
+ async function planCodexPluginFileChanges(args: {
3926
+ rootDir: string;
3927
+ pluginsDir: string;
3928
+ pluginMarketplacePath?: string;
3929
+ previouslyManagedTargets?: string[];
3930
+ }): Promise<{
3931
+ add: string[];
3932
+ remove: string[];
3933
+ contents: Map<string, ManagedTargetContent>;
3934
+ sources: Map<string, string>;
3935
+ }> {
3936
+ const contents = new Map<string, ManagedTargetContent>();
3937
+ const sources = new Map<string, string>();
3938
+ const desiredPaths = new Set<string>();
3939
+
3940
+ const marketplace = await loadCanonicalCodexMarketplaceText(args.rootDir);
3941
+ if (marketplace.text != null && args.pluginMarketplacePath) {
3942
+ desiredPaths.add(args.pluginMarketplacePath);
3943
+ contents.set(args.pluginMarketplacePath, marketplace.text);
3944
+ sources.set(args.pluginMarketplacePath, marketplace.sourcePath);
3945
+ }
3946
+
3947
+ for (const plugin of await loadCanonicalCodexPlugins(args.rootDir)) {
3948
+ for (const [relPath, bytes] of plugin.files.entries()) {
3949
+ const targetPath = join(args.pluginsDir, plugin.name, relPath);
3950
+ desiredPaths.add(targetPath);
3951
+ contents.set(targetPath, bytes);
3952
+ sources.set(targetPath, join(plugin.sourceDir, relPath));
3953
+ }
3954
+ }
3955
+
3956
+ const add = new Set<string>();
3957
+ for (const targetPath of desiredPaths) {
3958
+ const currentHash = await readTargetHash(targetPath, {
3959
+ normalizeText: false,
3960
+ });
3961
+ const desired = contents.get(targetPath);
3962
+ if (desired == null) {
3963
+ continue;
3964
+ }
3965
+ if (currentHash !== targetContentHash(desired, { normalizeText: false })) {
3966
+ add.add(targetPath);
3967
+ }
3968
+ }
3969
+
3970
+ const remove = Array.from(
3971
+ new Set(
3972
+ (args.previouslyManagedTargets ?? []).filter((targetPath) => {
3973
+ const inManagedRoot =
3974
+ (args.pluginMarketplacePath != null &&
3975
+ targetPath === args.pluginMarketplacePath) ||
3976
+ targetPath.startsWith(join(args.pluginsDir, ""));
3977
+ return inManagedRoot && !desiredPaths.has(targetPath);
3978
+ })
3979
+ )
3980
+ ).sort();
3981
+
3982
+ return {
3983
+ add: Array.from(add).sort(),
3984
+ remove,
3985
+ contents,
3986
+ sources,
3987
+ };
3988
+ }
3989
+
3213
3990
  async function syncManagedToolEntry({
3214
3991
  homeDir,
3215
3992
  tool,
@@ -3267,6 +4044,7 @@ async function syncManagedToolEntry({
3267
4044
 
3268
4045
  const mcpPlan = entry.mcpConfig
3269
4046
  ? await syncMcpConfig({
4047
+ homeDir,
3270
4048
  mcpConfigPath: entry.mcpConfig,
3271
4049
  rootDir,
3272
4050
  tool,
@@ -3326,6 +4104,15 @@ async function syncManagedToolEntry({
3326
4104
  managedConfig: false,
3327
4105
  targetPath: "",
3328
4106
  };
4107
+ const pluginPlan =
4108
+ tool === "codex" && entry.pluginsDir
4109
+ ? await planCodexPluginFileChanges({
4110
+ rootDir,
4111
+ pluginsDir: entry.pluginsDir,
4112
+ pluginMarketplacePath: entry.pluginMarketplacePath,
4113
+ previouslyManagedTargets: Object.keys(entry.renderedTargets ?? {}),
4114
+ })
4115
+ : { add: [], remove: [], contents: new Map(), sources: new Map() };
3329
4116
 
3330
4117
  const agentRendered = await planRenderedTargetConflicts({
3331
4118
  entry,
@@ -3379,6 +4166,16 @@ async function syncManagedToolEntry({
3379
4166
  desiredSources: configSources,
3380
4167
  conflictMode: builtinConflictMode,
3381
4168
  });
4169
+ const pluginRendered = await planRenderedTargetConflicts({
4170
+ entry,
4171
+ desiredWrites: pluginPlan.add,
4172
+ desiredRemoves: pluginPlan.remove,
4173
+ desiredContents: pluginPlan.contents,
4174
+ desiredSources: pluginPlan.sources,
4175
+ conflictMode: builtinConflictMode,
4176
+ protectAllSources: true,
4177
+ normalizeText: false,
4178
+ });
3382
4179
 
3383
4180
  if (dryRun) {
3384
4181
  logSyncDryRun({
@@ -3406,6 +4203,11 @@ async function syncManagedToolEntry({
3406
4203
  targetPath: configPlan.targetPath,
3407
4204
  },
3408
4205
  configConflicts: configRendered.conflicts,
4206
+ pluginPlan: {
4207
+ write: pluginRendered.write,
4208
+ remove: pluginRendered.remove,
4209
+ },
4210
+ pluginConflicts: pluginRendered.conflicts,
3409
4211
  });
3410
4212
  } else {
3411
4213
  await applyRenderedRemoves(agentRendered.remove);
@@ -3436,11 +4238,20 @@ async function syncManagedToolEntry({
3436
4238
  contents: configContents,
3437
4239
  targets: configRendered.write,
3438
4240
  });
4241
+ await applyRenderedRemoves(pluginRendered.remove);
4242
+ await applyRenderedWrites({
4243
+ contents: pluginPlan.contents,
4244
+ targets: pluginRendered.write,
4245
+ });
4246
+ if (entry.pluginsDir) {
4247
+ await pruneEmptyParents(pluginRendered.remove, entry.pluginsDir);
4248
+ }
3439
4249
  logRenderedConflicts(tool, agentRendered.conflicts);
3440
4250
  logRenderedConflicts(tool, automationRendered.conflicts);
3441
4251
  logRenderedConflicts(tool, globalDocsRendered.conflicts);
3442
4252
  logRenderedConflicts(tool, rulesRendered.conflicts);
3443
4253
  logRenderedConflicts(tool, configRendered.conflicts);
4254
+ logRenderedConflicts(tool, pluginRendered.conflicts);
3444
4255
 
3445
4256
  updateRenderedTargetState({
3446
4257
  entry,
@@ -3477,6 +4288,14 @@ async function syncManagedToolEntry({
3477
4288
  contents: configContents,
3478
4289
  sources: configSources,
3479
4290
  });
4291
+ updateRenderedTargetState({
4292
+ entry,
4293
+ writtenTargets: pluginRendered.write,
4294
+ removedTargets: pluginRendered.remove,
4295
+ contents: pluginPlan.contents,
4296
+ sources: pluginPlan.sources,
4297
+ normalizeText: false,
4298
+ });
3480
4299
 
3481
4300
  for (const name of adoptedSkills) {
3482
4301
  console.log(
@@ -3667,11 +4486,20 @@ export async function managedCommand(argv: string[] = []) {
3667
4486
  parsed.argv.includes("-h") ||
3668
4487
  parsed.argv[0] === "help"
3669
4488
  ) {
3670
- console.log(`fclt managed — list tools currently in managed mode
3671
-
3672
- Usage:
3673
- fclt managed [--root PATH|--global|--project]
3674
- `);
4489
+ console.log(
4490
+ renderPage({
4491
+ title: "fclt managed",
4492
+ subtitle: "List tools currently in managed mode.",
4493
+ sections: [
4494
+ {
4495
+ title: "Usage",
4496
+ lines: renderBullets([
4497
+ renderCode("fclt managed [--root PATH|--global|--project]"),
4498
+ ]),
4499
+ },
4500
+ ],
4501
+ })
4502
+ );
3675
4503
  return;
3676
4504
  }
3677
4505
  const tools = await listManagedTools({
@@ -3682,12 +4510,27 @@ Usage:
3682
4510
  }),
3683
4511
  });
3684
4512
  if (!tools.length) {
3685
- console.log("No managed tools.");
4513
+ console.log(
4514
+ renderPage({
4515
+ title: "fclt managed",
4516
+ subtitle: "No managed tools.",
4517
+ sections: [],
4518
+ })
4519
+ );
3686
4520
  return;
3687
4521
  }
3688
- for (const tool of tools) {
3689
- console.log(tool);
3690
- }
4522
+ console.log(
4523
+ renderPage({
4524
+ title: "fclt managed",
4525
+ subtitle: `${tools.length} managed tool${tools.length === 1 ? "" : "s"}`,
4526
+ sections: [
4527
+ {
4528
+ title: "Tools",
4529
+ lines: renderBullets(tools),
4530
+ },
4531
+ ],
4532
+ })
4533
+ );
3691
4534
  }
3692
4535
 
3693
4536
  export async function syncCommand(argv: string[]) {