facult 2.7.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,
@@ -43,6 +44,7 @@ import {
43
44
  legacyFacultStateDirForRoot,
44
45
  projectRootFromAiRoot,
45
46
  } from "./paths";
47
+ import { loadProjectToolSyncPolicy } from "./project-sync";
46
48
 
47
49
  export interface ManagedToolState {
48
50
  tool: string;
@@ -50,6 +52,8 @@ export interface ManagedToolState {
50
52
  skillsDir?: string;
51
53
  mcpConfig?: string;
52
54
  agentsDir?: string;
55
+ pluginsDir?: string;
56
+ pluginMarketplacePath?: string;
53
57
  automationDir?: string;
54
58
  toolHome?: string;
55
59
  globalAgentsPath?: string;
@@ -59,6 +63,8 @@ export interface ManagedToolState {
59
63
  skillsBackup?: string | null;
60
64
  mcpBackup?: string | null;
61
65
  agentsBackup?: string | null;
66
+ pluginsBackup?: string | null;
67
+ pluginMarketplaceBackup?: string | null;
62
68
  globalAgentsBackup?: string | null;
63
69
  globalAgentsOverrideBackup?: string | null;
64
70
  rulesBackup?: string | null;
@@ -82,6 +88,8 @@ export interface ToolPaths {
82
88
  skillsDir?: string;
83
89
  mcpConfig?: string;
84
90
  agentsDir?: string;
91
+ pluginsDir?: string;
92
+ pluginMarketplacePath?: string;
85
93
  automationDir?: string;
86
94
  toolHome?: string;
87
95
  rulesDir?: string;
@@ -117,6 +125,44 @@ function homePath(home: string, ...parts: string[]): string {
117
125
  return join(home, ...parts);
118
126
  }
119
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
+
120
166
  function expandHomePath(pathValue: string, home: string): string {
121
167
  if (pathValue === "~") {
122
168
  return home;
@@ -152,6 +198,79 @@ function renderedHash(text: string): string {
152
198
  return contentHash(normalizeText(text));
153
199
  }
154
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
+
155
274
  function defaultToolPaths(
156
275
  home: string,
157
276
  rootDir?: string
@@ -168,9 +287,13 @@ function defaultToolPaths(
168
287
  },
169
288
  codex: {
170
289
  tool: "codex",
171
- skillsDir: toolBase(".codex", "skills"),
290
+ skillsDir: codexSkillsDir(home, rootDir),
172
291
  mcpConfig: toolBase(".codex", "mcp.json"),
173
292
  agentsDir: toolBase(".codex", "agents"),
293
+ pluginsDir: projectRoot ? undefined : codexPluginsDir(home, rootDir),
294
+ pluginMarketplacePath: projectRoot
295
+ ? undefined
296
+ : codexPluginMarketplacePath(home, rootDir),
174
297
  automationDir: homePath(home, ".codex", "automations"),
175
298
  toolHome: toolBase(".codex"),
176
299
  rulesDir: toolBase(".codex", "rules"),
@@ -270,6 +393,7 @@ async function resolveToolPaths(
270
393
  override?: Record<string, ToolPaths>
271
394
  ): Promise<ToolPaths | null> {
272
395
  const defaults = defaultToolPaths(home, rootDir);
396
+ const projectRoot = rootDir ? projectRootFromAiRoot(rootDir, home) : null;
273
397
  if (override?.[tool]) {
274
398
  const base = defaults[tool] ?? null;
275
399
  return base ? { ...base, ...override[tool] } : (override[tool] ?? null);
@@ -286,6 +410,10 @@ async function resolveToolPaths(
286
410
  return base;
287
411
  }
288
412
 
413
+ if (projectRoot) {
414
+ return base;
415
+ }
416
+
289
417
  const adapterPaths = getAdapter("codex")?.getDefaultPaths?.();
290
418
  const adapterConfig = adapterPaths?.config
291
419
  ? expandHomePath(adapterPaths.config, home)
@@ -635,6 +763,96 @@ async function canonicalAutomationsExist(rootDir: string): Promise<boolean> {
635
763
  }
636
764
  }
637
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
+
638
856
  async function loadMergedIndex(
639
857
  homeDir: string,
640
858
  rootDir: string
@@ -656,7 +874,11 @@ async function loadEnabledSkillEntries(args: {
656
874
  tool: string;
657
875
  }): Promise<{ name: string; path: string }[]> {
658
876
  const index = await loadMergedIndex(args.homeDir, args.rootDir);
659
- const useBuiltinDefaults = await builtinSyncDefaultsEnabled(args.rootDir);
877
+ const projectPolicy = await loadProjectToolSyncPolicy(args);
878
+ const useBuiltinDefaults = await builtinSyncDefaultsEnabled(
879
+ args.rootDir,
880
+ args.homeDir
881
+ );
660
882
  const out: { name: string; path: string }[] = [];
661
883
 
662
884
  for (const [name, entry] of Object.entries(index.skills)) {
@@ -674,6 +896,15 @@ async function loadEnabledSkillEntries(args: {
674
896
  ) {
675
897
  continue;
676
898
  }
899
+ if (
900
+ projectPolicy &&
901
+ !(
902
+ projectPolicy.skills.includes("*") ||
903
+ projectPolicy.skills.includes(name)
904
+ )
905
+ ) {
906
+ continue;
907
+ }
677
908
  out.push({ name, path: skill.path });
678
909
  }
679
910
 
@@ -681,6 +912,10 @@ async function loadEnabledSkillEntries(args: {
681
912
  return out.sort((a, b) => a.name.localeCompare(b.name));
682
913
  }
683
914
 
915
+ if (projectPolicy) {
916
+ return [];
917
+ }
918
+
684
919
  return (await listSkillDirs(join(args.rootDir, "skills"))).map((name) => ({
685
920
  name,
686
921
  path: join(args.rootDir, "skills", name),
@@ -690,9 +925,14 @@ async function loadEnabledSkillEntries(args: {
690
925
  async function loadManagedAgentEntries(args: {
691
926
  homeDir: string;
692
927
  rootDir: string;
928
+ tool: string;
693
929
  }): Promise<{ name: string; sourcePath: string; raw: string }[]> {
694
930
  const index = await loadMergedIndex(args.homeDir, args.rootDir);
695
- const useBuiltinDefaults = await builtinSyncDefaultsEnabled(args.rootDir);
931
+ const projectPolicy = await loadProjectToolSyncPolicy(args);
932
+ const useBuiltinDefaults = await builtinSyncDefaultsEnabled(
933
+ args.rootDir,
934
+ args.homeDir
935
+ );
696
936
  const out: { name: string; sourcePath: string; raw: string }[] = [];
697
937
 
698
938
  for (const [name, entry] of Object.entries(index.agents)) {
@@ -704,6 +944,15 @@ async function loadManagedAgentEntries(args: {
704
944
  ) {
705
945
  continue;
706
946
  }
947
+ if (
948
+ projectPolicy &&
949
+ !(
950
+ projectPolicy.agents.includes("*") ||
951
+ projectPolicy.agents.includes(name)
952
+ )
953
+ ) {
954
+ continue;
955
+ }
707
956
  const raw = await readTextIfExists(agent.path);
708
957
  if (raw == null) {
709
958
  continue;
@@ -715,6 +964,10 @@ async function loadManagedAgentEntries(args: {
715
964
  return out.sort((a, b) => a.name.localeCompare(b.name));
716
965
  }
717
966
 
967
+ if (projectPolicy) {
968
+ return [];
969
+ }
970
+
718
971
  return await loadCanonicalAgents(args.rootDir);
719
972
  }
720
973
 
@@ -734,7 +987,7 @@ async function planAgentFileChanges({
734
987
  contents: Map<string, string>;
735
988
  sources: Map<string, string>;
736
989
  }> {
737
- const agents = await loadManagedAgentEntries({ homeDir, rootDir });
990
+ const agents = await loadManagedAgentEntries({ homeDir, rootDir, tool });
738
991
  const contents = new Map<string, string>();
739
992
  const sources = new Map<string, string>();
740
993
  const desiredPaths = new Set<string>();
@@ -896,6 +1149,10 @@ async function listSkillDirs(skillsRoot: string): Promise<string[]> {
896
1149
  }
897
1150
  }
898
1151
 
1152
+ async function canonicalSkillsExist(rootDir: string): Promise<boolean> {
1153
+ return (await listSkillDirs(join(rootDir, "skills"))).length > 0;
1154
+ }
1155
+
899
1156
  async function loadEnabledSkillNames({
900
1157
  homeDir,
901
1158
  rootDir,
@@ -937,18 +1194,34 @@ function canonicalServerToToolConfig(server: unknown): unknown {
937
1194
  return out;
938
1195
  }
939
1196
 
940
- function filterServersForTool(
941
- servers: Record<string, unknown>,
942
- tool: string
943
- ): 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
+ });
944
1208
  const out: Record<string, unknown> = {};
945
- for (const [name, cfg] of Object.entries(servers)) {
1209
+ for (const [name, cfg] of Object.entries(args.servers)) {
946
1210
  if (isPlainObject(cfg)) {
947
1211
  const enabledFor = cfg.enabledFor;
948
- if (Array.isArray(enabledFor) && !enabledFor.includes(tool)) {
1212
+ if (Array.isArray(enabledFor) && !enabledFor.includes(args.tool)) {
949
1213
  continue;
950
1214
  }
951
1215
  }
1216
+ if (
1217
+ projectPolicy &&
1218
+ !(
1219
+ projectPolicy.mcpServers.includes("*") ||
1220
+ projectPolicy.mcpServers.includes(name)
1221
+ )
1222
+ ) {
1223
+ continue;
1224
+ }
952
1225
  out[name] = canonicalServerToToolConfig(cfg);
953
1226
  }
954
1227
  return out;
@@ -1178,6 +1451,8 @@ interface ExistingManagedItem {
1178
1451
  | "skill"
1179
1452
  | "agent"
1180
1453
  | "automation"
1454
+ | "plugin"
1455
+ | "plugin-marketplace"
1181
1456
  | "global-doc"
1182
1457
  | "rule"
1183
1458
  | "tool-config"
@@ -2008,10 +2283,12 @@ async function syncSkillSymlinks({
2008
2283
  }
2009
2284
 
2010
2285
  async function planMcpWrite({
2286
+ homeDir,
2011
2287
  mcpConfigPath,
2012
2288
  rootDir,
2013
2289
  tool,
2014
2290
  }: {
2291
+ homeDir: string;
2015
2292
  mcpConfigPath: string;
2016
2293
  rootDir: string;
2017
2294
  tool: string;
@@ -2019,7 +2296,12 @@ async function planMcpWrite({
2019
2296
  const { servers } = await loadCanonicalMcpState(rootDir, {
2020
2297
  includeLocal: true,
2021
2298
  });
2022
- const filtered = filterServersForTool(servers, tool);
2299
+ const filtered = await filterServersForTool({
2300
+ homeDir,
2301
+ rootDir,
2302
+ servers,
2303
+ tool,
2304
+ });
2023
2305
  const contents = `${JSON.stringify({ mcpServers: filtered }, null, 2)}\n`;
2024
2306
 
2025
2307
  if (!(await fileExists(mcpConfigPath))) {
@@ -2034,17 +2316,19 @@ async function planMcpWrite({
2034
2316
  }
2035
2317
 
2036
2318
  async function syncMcpConfig({
2319
+ homeDir,
2037
2320
  mcpConfigPath,
2038
2321
  rootDir,
2039
2322
  tool,
2040
2323
  dryRun,
2041
2324
  }: {
2325
+ homeDir: string;
2042
2326
  mcpConfigPath: string;
2043
2327
  rootDir: string;
2044
2328
  tool: string;
2045
2329
  dryRun?: boolean;
2046
2330
  }): Promise<{ needsWrite: boolean }> {
2047
- const plan = await planMcpWrite({ mcpConfigPath, rootDir, tool });
2331
+ const plan = await planMcpWrite({ homeDir, mcpConfigPath, rootDir, tool });
2048
2332
  if (dryRun) {
2049
2333
  return { needsWrite: plan.needsWrite };
2050
2334
  }
@@ -2056,10 +2340,12 @@ async function syncMcpConfig({
2056
2340
  }
2057
2341
 
2058
2342
  async function writeToolMcpConfig({
2343
+ homeDir,
2059
2344
  mcpConfigPath,
2060
2345
  rootDir,
2061
2346
  tool,
2062
2347
  }: {
2348
+ homeDir: string;
2063
2349
  mcpConfigPath: string;
2064
2350
  rootDir: string;
2065
2351
  tool: string;
@@ -2067,7 +2353,12 @@ async function writeToolMcpConfig({
2067
2353
  const { servers } = await loadCanonicalMcpState(rootDir, {
2068
2354
  includeLocal: true,
2069
2355
  });
2070
- const filtered = filterServersForTool(servers, tool);
2356
+ const filtered = await filterServersForTool({
2357
+ homeDir,
2358
+ rootDir,
2359
+ servers,
2360
+ tool,
2361
+ });
2071
2362
  await ensureDir(dirname(mcpConfigPath));
2072
2363
  await Bun.write(
2073
2364
  mcpConfigPath,
@@ -2089,19 +2380,34 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2089
2380
  throw new Error(`Unknown tool: ${tool}`);
2090
2381
  }
2091
2382
 
2092
- const existingSkillPlan = toolPaths.skillsDir
2093
- ? await planExistingToolSkillAdoption({
2094
- rootDir,
2095
- toolSkillsDir: toolPaths.skillsDir,
2096
- })
2097
- : {
2098
- adopt: [],
2099
- identical: [],
2100
- conflicts: [],
2101
- ignored: [],
2102
- };
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();
2103
2409
  const existingImportPlan = mergeManagedImportPlans(
2104
- asManagedSkillPlan(existingSkillPlan),
2410
+ existingSkillPlan,
2105
2411
  toolPaths.agentsDir
2106
2412
  ? await planExistingToolAgentAdoption({
2107
2413
  tool,
@@ -2136,6 +2442,15 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2136
2442
  toolConfigPath: toolPaths.toolConfig,
2137
2443
  })
2138
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(),
2139
2454
  toolPaths.mcpConfig
2140
2455
  ? await planExistingMcpAdoption({
2141
2456
  rootDir,
@@ -2157,6 +2472,8 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2157
2472
  toolPaths.toolHome ||
2158
2473
  toolPaths.rulesDir ||
2159
2474
  toolPaths.toolConfig ||
2475
+ toolPaths.pluginsDir ||
2476
+ toolPaths.pluginMarketplacePath ||
2160
2477
  toolPaths.mcpConfig) &&
2161
2478
  !opts.adoptExisting &&
2162
2479
  (existingImportPlan.adopt.length > 0 ||
@@ -2203,7 +2520,10 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2203
2520
  ? await adoptSkillsIntoCanonicalStore({
2204
2521
  homeDir: home,
2205
2522
  rootDir,
2206
- skillSourceDirs: [toolPaths.skillsDir],
2523
+ skillSourceDirs: [
2524
+ toolPaths.skillsDir,
2525
+ ...(tool === "codex" ? [codexLegacySkillsDir(home, rootDir)] : []),
2526
+ ],
2207
2527
  })
2208
2528
  : [];
2209
2529
 
@@ -2226,6 +2546,26 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2226
2546
  });
2227
2547
  }
2228
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
+ }
2229
2569
  if (toolPaths.agentsDir && opts.adoptExisting) {
2230
2570
  const result = await adoptExistingToolAgents({
2231
2571
  tool,
@@ -2279,6 +2619,20 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2279
2619
  });
2280
2620
  adoptedSkills.push(...result.map((item) => `${item.kind}:${item.name}`));
2281
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
+ }
2282
2636
  if (adoptedSkills.length > 0) {
2283
2637
  await buildIndex({
2284
2638
  homeDir: home,
@@ -2327,6 +2681,14 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2327
2681
  toolConfigPath: toolPaths.toolConfig,
2328
2682
  })
2329
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;
2330
2692
 
2331
2693
  const skillsBackup = toolPaths.skillsDir
2332
2694
  ? await backupPath(toolPaths.skillsDir, opts.now)
@@ -2357,6 +2719,15 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2357
2719
  toolPaths.toolConfig && toolConfigPreview?.managedConfig
2358
2720
  ? await backupPath(toolPaths.toolConfig, opts.now)
2359
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;
2360
2731
 
2361
2732
  if (toolPaths.skillsDir) {
2362
2733
  await ensureEmptyDir(toolPaths.skillsDir);
@@ -2378,6 +2749,7 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2378
2749
 
2379
2750
  if (toolPaths.mcpConfig) {
2380
2751
  await writeToolMcpConfig({
2752
+ homeDir: home,
2381
2753
  mcpConfigPath: toolPaths.mcpConfig,
2382
2754
  rootDir,
2383
2755
  tool,
@@ -2433,12 +2805,41 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2433
2805
  });
2434
2806
  }
2435
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
+
2436
2828
  state.tools[tool] = {
2437
2829
  tool,
2438
2830
  managedAt: nowIso(opts.now),
2439
2831
  skillsDir: toolPaths.skillsDir,
2440
2832
  mcpConfig: toolPaths.mcpConfig,
2441
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,
2442
2843
  automationDir: toolPaths.automationDir,
2443
2844
  toolHome: globalDocsPreview?.managedTargets.length
2444
2845
  ? toolPaths.toolHome
@@ -2460,6 +2861,8 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2460
2861
  skillsBackup,
2461
2862
  mcpBackup,
2462
2863
  agentsBackup,
2864
+ pluginsBackup,
2865
+ pluginMarketplaceBackup,
2463
2866
  globalAgentsBackup,
2464
2867
  globalAgentsOverrideBackup,
2465
2868
  rulesBackup,
@@ -2525,6 +2928,17 @@ export async function manageTool(tool: string, opts: ManageOptions = {}) {
2525
2928
  });
2526
2929
  }
2527
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
+
2528
2942
  await saveManagedState(state, home, rootDir);
2529
2943
 
2530
2944
  for (const name of adoptedSkills) {
@@ -2601,6 +3015,20 @@ export async function unmanageTool(tool: string, opts: ManageOptions = {}) {
2601
3015
  });
2602
3016
  }
2603
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
+
2604
3032
  if (entry.automationDir) {
2605
3033
  const automationTargets = Object.keys(entry.renderedTargets ?? {}).filter(
2606
3034
  (targetPath) => targetPath.startsWith(join(entry.automationDir!, ""))
@@ -2681,6 +3109,19 @@ async function repairManagedToolEntry(args: {
2681
3109
  const next: ManagedToolState = { ...args.entry };
2682
3110
  let changed = false;
2683
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
+
2684
3125
  if (
2685
3126
  !next.agentsDir &&
2686
3127
  toolPaths.agentsDir &&
@@ -2700,6 +3141,43 @@ async function repairManagedToolEntry(args: {
2700
3141
  changed = true;
2701
3142
  }
2702
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
+
2703
3181
  if (toolPaths.toolHome && !next.toolHome) {
2704
3182
  const preview = await syncToolGlobalDocs({
2705
3183
  homeDir,
@@ -2783,10 +3261,11 @@ async function planRenderedTargetConflicts(args: {
2783
3261
  entry: ManagedToolState;
2784
3262
  desiredWrites: string[];
2785
3263
  desiredRemoves: string[];
2786
- desiredContents: Map<string, string>;
3264
+ desiredContents: Map<string, ManagedTargetContent>;
2787
3265
  desiredSources: Map<string, string>;
2788
3266
  conflictMode?: "warn" | "overwrite";
2789
3267
  protectAllSources?: boolean;
3268
+ normalizeText?: boolean;
2790
3269
  }): Promise<RenderedApplyPlan> {
2791
3270
  if (args.conflictMode === "overwrite") {
2792
3271
  return {
@@ -2824,17 +3303,19 @@ async function planRenderedTargetConflicts(args: {
2824
3303
  }
2825
3304
 
2826
3305
  const prior = previous[targetPath];
2827
- const current = await readTextIfExists(targetPath);
2828
- if (current == null) {
3306
+ const currentHash = await readTargetHash(targetPath, {
3307
+ normalizeText: args.normalizeText,
3308
+ });
3309
+ if (currentHash == null) {
2829
3310
  if (args.desiredWrites.includes(targetPath)) {
2830
3311
  write.push(targetPath);
2831
3312
  }
2832
3313
  continue;
2833
3314
  }
2834
-
2835
- const currentHash = renderedHash(current);
2836
3315
  const desiredHash = args.desiredContents.get(targetPath)
2837
- ? renderedHash(args.desiredContents.get(targetPath)!)
3316
+ ? targetContentHash(args.desiredContents.get(targetPath)!, {
3317
+ normalizeText: args.normalizeText,
3318
+ })
2838
3319
  : null;
2839
3320
  if (prior?.hash) {
2840
3321
  if (
@@ -2907,7 +3388,7 @@ function logRenderedConflicts(
2907
3388
  }
2908
3389
 
2909
3390
  async function applyRenderedWrites(args: {
2910
- contents: Map<string, string>;
3391
+ contents: Map<string, ManagedTargetContent>;
2911
3392
  targets: string[];
2912
3393
  }) {
2913
3394
  for (const pathValue of args.targets) {
@@ -2918,7 +3399,9 @@ async function applyRenderedWrites(args: {
2918
3399
  await mkdir(dirname(pathValue), { recursive: true });
2919
3400
  await Bun.write(
2920
3401
  pathValue,
2921
- desired.endsWith("\n") ? desired : `${desired}\n`
3402
+ typeof desired === "string" && !desired.endsWith("\n")
3403
+ ? `${desired}\n`
3404
+ : desired
2922
3405
  );
2923
3406
  }
2924
3407
  }
@@ -2951,8 +3434,9 @@ function updateRenderedTargetState(args: {
2951
3434
  entry: ManagedToolState;
2952
3435
  writtenTargets: string[];
2953
3436
  removedTargets: string[];
2954
- contents: Map<string, string>;
3437
+ contents: Map<string, ManagedTargetContent>;
2955
3438
  sources: Map<string, string>;
3439
+ normalizeText?: boolean;
2956
3440
  }) {
2957
3441
  const next = { ...(args.entry.renderedTargets ?? {}) };
2958
3442
  for (const pathValue of args.removedTargets) {
@@ -2965,7 +3449,9 @@ function updateRenderedTargetState(args: {
2965
3449
  continue;
2966
3450
  }
2967
3451
  next[pathValue] = {
2968
- hash: renderedHash(contents),
3452
+ hash: targetContentHash(contents, {
3453
+ normalizeText: args.normalizeText,
3454
+ }),
2969
3455
  sourcePath,
2970
3456
  sourceKind: renderedSourceKindForPath(sourcePath),
2971
3457
  };
@@ -3012,6 +3498,8 @@ function logSyncDryRun({
3012
3498
  rulesConflicts,
3013
3499
  configPlan,
3014
3500
  configConflicts,
3501
+ pluginPlan,
3502
+ pluginConflicts,
3015
3503
  }: {
3016
3504
  tool: string;
3017
3505
  entry: ManagedToolState;
@@ -3027,6 +3515,8 @@ function logSyncDryRun({
3027
3515
  rulesConflicts: RenderedConflict[];
3028
3516
  configPlan: { write: boolean; remove: boolean; targetPath: string };
3029
3517
  configConflicts: RenderedConflict[];
3518
+ pluginPlan: { write: string[]; remove: string[] };
3519
+ pluginConflicts: RenderedConflict[];
3030
3520
  }) {
3031
3521
  for (const name of skillPlan.add) {
3032
3522
  console.log(`${tool}: would add skill ${name}`);
@@ -3069,6 +3559,13 @@ function logSyncDryRun({
3069
3559
  console.log(`${tool}: would remove tool config ${configPlan.targetPath}`);
3070
3560
  }
3071
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);
3072
3569
  if (mcpPlan.needsWrite && entry.mcpConfig) {
3073
3570
  console.log(`${tool}: would update mcp config ${entry.mcpConfig}`);
3074
3571
  }
@@ -3085,12 +3582,15 @@ function logSyncDryRun({
3085
3582
  rulesPlan.remove.length === 0 &&
3086
3583
  !configPlan.write &&
3087
3584
  !configPlan.remove &&
3585
+ pluginPlan.write.length === 0 &&
3586
+ pluginPlan.remove.length === 0 &&
3088
3587
  !mcpPlan.needsWrite &&
3089
3588
  agentConflicts.length === 0 &&
3090
3589
  automationConflicts.length === 0 &&
3091
3590
  globalDocsConflicts.length === 0 &&
3092
3591
  rulesConflicts.length === 0 &&
3093
- configConflicts.length === 0
3592
+ configConflicts.length === 0 &&
3593
+ pluginConflicts.length === 0
3094
3594
  ) {
3095
3595
  console.log(`${tool}: no changes`);
3096
3596
  }
@@ -3175,6 +3675,24 @@ async function repairManagedCanonicalContent(args: {
3175
3675
  adopted.push(...items.map((item) => `${item.kind}:${item.name}`));
3176
3676
  }
3177
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
+
3178
3696
  if (adopted.length > 0) {
3179
3697
  await buildIndex({
3180
3698
  homeDir: args.homeDir,
@@ -3186,6 +3704,289 @@ async function repairManagedCanonicalContent(args: {
3186
3704
  return adopted;
3187
3705
  }
3188
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
+
3189
3990
  async function syncManagedToolEntry({
3190
3991
  homeDir,
3191
3992
  tool,
@@ -3243,6 +4044,7 @@ async function syncManagedToolEntry({
3243
4044
 
3244
4045
  const mcpPlan = entry.mcpConfig
3245
4046
  ? await syncMcpConfig({
4047
+ homeDir,
3246
4048
  mcpConfigPath: entry.mcpConfig,
3247
4049
  rootDir,
3248
4050
  tool,
@@ -3302,6 +4104,15 @@ async function syncManagedToolEntry({
3302
4104
  managedConfig: false,
3303
4105
  targetPath: "",
3304
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() };
3305
4116
 
3306
4117
  const agentRendered = await planRenderedTargetConflicts({
3307
4118
  entry,
@@ -3355,6 +4166,16 @@ async function syncManagedToolEntry({
3355
4166
  desiredSources: configSources,
3356
4167
  conflictMode: builtinConflictMode,
3357
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
+ });
3358
4179
 
3359
4180
  if (dryRun) {
3360
4181
  logSyncDryRun({
@@ -3382,6 +4203,11 @@ async function syncManagedToolEntry({
3382
4203
  targetPath: configPlan.targetPath,
3383
4204
  },
3384
4205
  configConflicts: configRendered.conflicts,
4206
+ pluginPlan: {
4207
+ write: pluginRendered.write,
4208
+ remove: pluginRendered.remove,
4209
+ },
4210
+ pluginConflicts: pluginRendered.conflicts,
3385
4211
  });
3386
4212
  } else {
3387
4213
  await applyRenderedRemoves(agentRendered.remove);
@@ -3412,11 +4238,20 @@ async function syncManagedToolEntry({
3412
4238
  contents: configContents,
3413
4239
  targets: configRendered.write,
3414
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
+ }
3415
4249
  logRenderedConflicts(tool, agentRendered.conflicts);
3416
4250
  logRenderedConflicts(tool, automationRendered.conflicts);
3417
4251
  logRenderedConflicts(tool, globalDocsRendered.conflicts);
3418
4252
  logRenderedConflicts(tool, rulesRendered.conflicts);
3419
4253
  logRenderedConflicts(tool, configRendered.conflicts);
4254
+ logRenderedConflicts(tool, pluginRendered.conflicts);
3420
4255
 
3421
4256
  updateRenderedTargetState({
3422
4257
  entry,
@@ -3453,6 +4288,14 @@ async function syncManagedToolEntry({
3453
4288
  contents: configContents,
3454
4289
  sources: configSources,
3455
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
+ });
3456
4299
 
3457
4300
  for (const name of adoptedSkills) {
3458
4301
  console.log(