@pulsemcp/air-adapter-codex 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,967 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, copyFileSync, rmSync, statSync, } from "fs";
3
+ import { join, dirname, relative } from "path";
4
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
5
+ import { buildManifest, deleteManifest, diffManifest, getManifestPath, loadManifest, writeManifest, parseQualifiedId, resolveReference, } from "@pulsemcp/air-core";
6
+ import { scanLocalSkills } from "./scan-local-skills.js";
7
+ /** Matches an env/header value that is exactly a single `${VAR}` reference. */
8
+ const WHOLE_VAR_RE = /^\$\{([^}]+)\}$/;
9
+ /** Matches a value that contains a `${VAR}` reference anywhere within it. */
10
+ const CONTAINS_VAR_RE = /\$\{[^}]+\}/;
11
+ export class CodexAdapter {
12
+ name = "codex";
13
+ displayName = "OpenAI Codex";
14
+ /**
15
+ * Map AIR lifecycle event names to Codex `config.toml` hook event names.
16
+ *
17
+ * Accepts both snake_case AIR names and PascalCase Codex lifecycle names as
18
+ * identity mappings, so hook authors targeting the Codex runtime can write
19
+ * Codex-native event names directly without translating to snake_case.
20
+ *
21
+ * AIR events without a Codex equivalent (session_end, subagent_stop,
22
+ * pre_compact, notification) are intentionally absent — `reconcileConfigHooks`
23
+ * warns and skips registration when it encounters an unrecognized event.
24
+ * Codex's PermissionRequest event has no AIR analog and is therefore not
25
+ * generated by AIR (but user-authored PermissionRequest hooks are preserved).
26
+ */
27
+ static AIR_TO_CODEX_EVENT = {
28
+ // snake_case AIR names
29
+ session_start: "SessionStart",
30
+ pre_tool_call: "PreToolUse",
31
+ post_tool_call: "PostToolUse",
32
+ user_prompt_submit: "UserPromptSubmit",
33
+ stop: "Stop",
34
+ // PascalCase Codex event names (identity — hook authors targeting Codex
35
+ // often write these directly).
36
+ SessionStart: "SessionStart",
37
+ PreToolUse: "PreToolUse",
38
+ PostToolUse: "PostToolUse",
39
+ UserPromptSubmit: "UserPromptSubmit",
40
+ Stop: "Stop",
41
+ PermissionRequest: "PermissionRequest",
42
+ };
43
+ async isAvailable() {
44
+ try {
45
+ execSync("which codex", { stdio: "pipe" });
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ generateConfig(artifacts, root, _workDir) {
53
+ const pluginActivations = root?.default_plugins
54
+ ? this.resolveActivations(artifacts.plugins, root.default_plugins, "plugin")
55
+ : [];
56
+ const plugins = {};
57
+ for (const a of pluginActivations)
58
+ plugins[a.qualified] = artifacts.plugins[a.qualified];
59
+ // Merge plugin-declared MCP servers and skills into root defaults (additive).
60
+ // All incoming IDs are qualified (post-canonicalization at composition time),
61
+ // so we deduplicate on qualified IDs.
62
+ const mcpQualSet = new Set(root?.default_mcp_servers ?? []);
63
+ const skillQualSet = new Set(root?.default_skills ?? []);
64
+ for (const plugin of Object.values(plugins)) {
65
+ if (plugin.mcp_servers) {
66
+ for (const id of plugin.mcp_servers)
67
+ mcpQualSet.add(id);
68
+ }
69
+ if (plugin.skills) {
70
+ for (const id of plugin.skills)
71
+ skillQualSet.add(id);
72
+ }
73
+ }
74
+ const mcpActivations = this.resolveActivations(artifacts.mcp, [...mcpQualSet], "MCP server");
75
+ const skillActivations = this.resolveActivations(artifacts.skills, [...skillQualSet], "skill");
76
+ const mcpServers = {};
77
+ for (const a of mcpActivations)
78
+ mcpServers[a.short] = artifacts.mcp[a.qualified];
79
+ const mcpConfig = this.translateMcpServersByShort(mcpServers);
80
+ const pluginConfigs = pluginActivations.map((a) => this.translatePlugin(a.short, artifacts.plugins[a.qualified]));
81
+ const skillPaths = skillActivations.map((a) => artifacts.skills[a.qualified].path);
82
+ return {
83
+ agent: "codex",
84
+ mcpConfig,
85
+ pluginConfigs,
86
+ skillPaths,
87
+ env: {},
88
+ };
89
+ }
90
+ buildStartCommand(config) {
91
+ // Codex auto-discovers `.codex/config.toml`, `.codex/hooks/`, and
92
+ // `.agents/skills/` relative to its working root (trusted projects only).
93
+ // Pointing the working root at the prepared directory loads everything;
94
+ // no extra flags are required.
95
+ return {
96
+ command: "codex",
97
+ args: [],
98
+ env: config.env,
99
+ cwd: config.workDir,
100
+ };
101
+ }
102
+ /**
103
+ * Prepare a working directory for an OpenAI Codex CLI session.
104
+ *
105
+ * Writes `.codex/config.toml` (MCP servers under `[mcp_servers.*]` and hook
106
+ * registrations under `[hooks.*]`), injects skills + references into
107
+ * `.agents/skills/<name>/`, copies path-based hooks into `.codex/hooks/<name>/`,
108
+ * and returns the start command.
109
+ *
110
+ * Inputs to activation lists (root defaults, overrides, plugin-declared
111
+ * primitives) are accepted as either qualified (`@scope/id`) or short form;
112
+ * ambiguous short forms are rejected. Filesystem materialization uses
113
+ * shortnames — Codex's `config.toml`, `.agents/skills/`, `.codex/hooks/`,
114
+ * and the manifest are scope-naive. Two activated qualified IDs that share
115
+ * a shortname hard-fail with a clear "add one to exclude" message.
116
+ *
117
+ * NOTE: `configFiles` is intentionally returned empty. Codex's config is
118
+ * TOML, which is outside AIR's JSON-based transform/validation pipeline.
119
+ * Whole-value, same-named secret references (`${VAR}`) in MCP env/headers are
120
+ * mapped to Codex-native host-env forwarding (`env_vars`, `env_http_headers`)
121
+ * at translation time. Renamed or partial refs that can't be forwarded fall
122
+ * through to the literal table and emit a warning (see `warnUnforwardableSecret`),
123
+ * since the TOML never passes through the `${VAR}` transform pipeline.
124
+ */
125
+ async prepareSession(artifacts, targetDir, options) {
126
+ const root = options?.root;
127
+ const skillPaths = [];
128
+ const hookPaths = [];
129
+ const prevManifest = loadManifest(targetDir);
130
+ // 1. Resolve which artifacts to activate (overrides take precedence over root defaults)
131
+ let mcpServerIds = options?.mcpServerOverrides ?? root?.default_mcp_servers ?? undefined;
132
+ let skillIds = options?.skillOverrides ?? root?.default_skills ?? [];
133
+ let hookIds = options?.hookOverrides ?? root?.default_hooks ?? [];
134
+ // 1b. Merge subagent roots' artifacts if applicable.
135
+ const subagentRoots = this.resolveSubagentRoots(root, artifacts, options);
136
+ if (subagentRoots.length > 0) {
137
+ const merged = this.mergeSubagentArtifacts(subagentRoots, mcpServerIds, skillIds);
138
+ if (!options?.mcpServerOverrides) {
139
+ mcpServerIds = merged.mcpServerIds;
140
+ }
141
+ if (!options?.skillOverrides) {
142
+ skillIds = merged.skillIds;
143
+ }
144
+ }
145
+ // 1c. Resolve plugins and merge their declared artifacts (additive)
146
+ const pluginIds = options?.pluginOverrides ?? root?.default_plugins ?? undefined;
147
+ const pluginActivations = pluginIds?.length
148
+ ? this.resolveActivations(artifacts.plugins, pluginIds, "plugin")
149
+ : [];
150
+ const plugins = {};
151
+ for (const a of pluginActivations)
152
+ plugins[a.qualified] = artifacts.plugins[a.qualified];
153
+ const mcpSet = new Set(mcpServerIds ?? []);
154
+ const skillSet = new Set(skillIds);
155
+ const hookSet = new Set(hookIds);
156
+ for (const plugin of Object.values(plugins)) {
157
+ if (plugin.mcp_servers)
158
+ for (const id of plugin.mcp_servers)
159
+ mcpSet.add(id);
160
+ if (plugin.skills)
161
+ for (const id of plugin.skills)
162
+ skillSet.add(id);
163
+ if (plugin.hooks)
164
+ for (const id of plugin.hooks)
165
+ hookSet.add(id);
166
+ }
167
+ if (mcpSet.size > 0 || mcpServerIds !== undefined)
168
+ mcpServerIds = [...mcpSet];
169
+ skillIds = [...skillSet];
170
+ hookIds = [...hookSet];
171
+ // 2. Resolve activations: qualified ID + shortname per artifact.
172
+ // Throws on unknown IDs, ambiguous shortnames, and shortname collisions.
173
+ const skillActs = this.resolveActivations(artifacts.skills, skillIds, "skill");
174
+ const mcpActs = mcpServerIds
175
+ ? this.resolveActivations(artifacts.mcp, mcpServerIds, "MCP server")
176
+ : [];
177
+ const hookActs = this.resolveActivations(artifacts.hooks, hookIds, "hook");
178
+ const skillShortIds = skillActs.map((a) => a.short);
179
+ const hookShortIds = hookActs.map((a) => a.short);
180
+ const mcpShortIds = mcpActs.map((a) => a.short);
181
+ // 3. Reconcile against prior manifest using shortnames — those are the keys
182
+ // used for filesystem materialization and stored in the manifest.
183
+ const diff = diffManifest(prevManifest, {
184
+ skills: skillShortIds,
185
+ hooks: hookShortIds,
186
+ mcpServers: mcpShortIds,
187
+ });
188
+ for (const staleSkillId of diff.staleSkills) {
189
+ const staleDir = join(targetDir, ".agents", "skills", staleSkillId);
190
+ if (existsSync(staleDir)) {
191
+ rmSync(staleDir, { recursive: true, force: true });
192
+ }
193
+ }
194
+ for (const staleHookId of diff.staleHooks) {
195
+ const staleDir = join(targetDir, ".codex", "hooks", staleHookId);
196
+ if (existsSync(staleDir)) {
197
+ rmSync(staleDir, { recursive: true, force: true });
198
+ }
199
+ }
200
+ // 4. Inject skills + references into .agents/skills/<short>/
201
+ const materializedSkillShortIds = [];
202
+ for (const a of skillActs) {
203
+ const skill = artifacts.skills[a.qualified];
204
+ const skillTargetDir = join(targetDir, ".agents", "skills", a.short);
205
+ if (existsSync(skillTargetDir)) {
206
+ materializedSkillShortIds.push(a.short);
207
+ continue;
208
+ }
209
+ const skillSourceDir = skill.path;
210
+ if (!existsSync(skillSourceDir)) {
211
+ console.warn(this.missingSourceDirMessage("skill", a.qualified, skillSourceDir));
212
+ continue;
213
+ }
214
+ this.copyDirRecursive(skillSourceDir, skillTargetDir);
215
+ skillPaths.push(skillTargetDir);
216
+ materializedSkillShortIds.push(a.short);
217
+ if (skill.references && skill.references.length > 0) {
218
+ this.copyReferences(skill.references, skillTargetDir, artifacts);
219
+ }
220
+ }
221
+ // 5. Validate and inject path-based hooks into .codex/hooks/<short>/.
222
+ const prevHookIds = new Set(prevManifest?.hooks ?? []);
223
+ const registeredHookShortIds = [];
224
+ const registeredHookActivations = [];
225
+ for (const a of hookActs) {
226
+ const hook = artifacts.hooks[a.qualified];
227
+ const hookTargetDir = join(targetDir, ".codex", "hooks", a.short);
228
+ const alreadyExists = existsSync(hookTargetDir);
229
+ if (alreadyExists && !prevHookIds.has(a.short)) {
230
+ continue;
231
+ }
232
+ if (!alreadyExists) {
233
+ const hookSourceDir = hook.path;
234
+ if (!existsSync(hookSourceDir)) {
235
+ console.warn(this.missingSourceDirMessage("hook", a.qualified, hookSourceDir));
236
+ continue;
237
+ }
238
+ this.copyDirRecursive(hookSourceDir, hookTargetDir);
239
+ if (hook.references && hook.references.length > 0) {
240
+ this.copyReferences(hook.references, hookTargetDir, artifacts);
241
+ }
242
+ }
243
+ hookPaths.push(hookTargetDir);
244
+ registeredHookShortIds.push(a.short);
245
+ registeredHookActivations.push({ short: a.short, qualified: a.qualified });
246
+ }
247
+ // 6. Write `.codex/config.toml`: AIR-managed MCP servers and hook
248
+ // registrations, merged into any user-authored config (full replacement
249
+ // of AIR-owned keys; user keys preserved; stale AIR keys removed).
250
+ const translatedServers = {};
251
+ for (const a of mcpActs)
252
+ translatedServers[a.short] = artifacts.mcp[a.qualified];
253
+ const managedHookIds = new Set([
254
+ ...diff.staleHooks,
255
+ ...registeredHookShortIds,
256
+ ]);
257
+ this.writeCodexConfig(targetDir, this.translateMcpServersByShort(translatedServers), diff.staleMcpServers, hookPaths, managedHookIds);
258
+ // 7. Persist the updated manifest (shortnames — keyed by filesystem dir).
259
+ // Only record skills/hooks that were actually materialized so the manifest
260
+ // does not claim ownership of artifacts AIR skipped (e.g. a missing source dir).
261
+ writeManifest(buildManifest(targetDir, {
262
+ adapter: this.name,
263
+ skills: materializedSkillShortIds,
264
+ hooks: registeredHookShortIds,
265
+ mcpServers: mcpShortIds,
266
+ }));
267
+ // 8. Generate ephemeral subagent context for the session.
268
+ // Codex has no `--append-system-prompt` equivalent, so this is surfaced
269
+ // to the caller via `subagentContext` rather than the start command.
270
+ let subagentContext;
271
+ if (subagentRoots.length > 0) {
272
+ subagentContext = this.buildSubagentContext(subagentRoots);
273
+ }
274
+ // 9. Build start command (working root = prepared directory).
275
+ const config = this.generateConfig(artifacts, undefined, targetDir);
276
+ const startCommand = this.buildStartCommand({
277
+ ...config,
278
+ workDir: targetDir,
279
+ });
280
+ return {
281
+ // Empty by design — see method doc: Codex's TOML config is outside AIR's
282
+ // JSON transform pipeline, and whole-value `${VAR}` references are mapped
283
+ // to Codex-native env forwarding at translation time, so there is nothing
284
+ // for the pipeline to transform or validate. (Unforwardable renamed/partial
285
+ // refs warn at translation time.) The `.codex/config.toml` we wrote above
286
+ // is deliberately not surfaced as a config file.
287
+ configFiles: [],
288
+ skillPaths,
289
+ hookPaths,
290
+ hookActivations: registeredHookActivations,
291
+ startCommand,
292
+ subagentContext,
293
+ };
294
+ }
295
+ /**
296
+ * Enumerate skills checked into `<targetDir>/.agents/skills/`. Codex loads
297
+ * these directly from the filesystem regardless of AIR's involvement, so
298
+ * they're always active and must not be overwritten or removed. The TUI uses
299
+ * this list to surface them as read-only entries.
300
+ */
301
+ async listLocalArtifacts(targetDir) {
302
+ return { skills: scanLocalSkills(targetDir) };
303
+ }
304
+ /**
305
+ * Remove every artifact AIR has previously written into `targetDir`.
306
+ *
307
+ * Reads the per-target manifest, deletes each tracked skill / hook
308
+ * directory, and removes tracked MCP server keys + AIR-managed hook entries
309
+ * from `.codex/config.toml`. When every category is cleaned, the manifest
310
+ * itself is deleted; partial cleans (any `keep*` flag set) update the
311
+ * manifest with the kept entries so future runs continue to track them.
312
+ *
313
+ * Items in the manifest that no longer exist on disk are silently skipped
314
+ * — the manifest can drift if a user removed files manually between runs.
315
+ */
316
+ async cleanSession(targetDir, options) {
317
+ const dryRun = options?.dryRun ?? false;
318
+ const cleanSkills = !(options?.keepSkills ?? false);
319
+ const cleanHooks = !(options?.keepHooks ?? false);
320
+ const cleanMcpServers = !(options?.keepMcpServers ?? false);
321
+ const fullClean = cleanSkills && cleanHooks && cleanMcpServers;
322
+ const manifestPath = getManifestPath(targetDir);
323
+ const manifestFileExists = existsSync(manifestPath);
324
+ const manifest = loadManifest(targetDir);
325
+ if (!manifest) {
326
+ let corruptManifestRemoved = false;
327
+ if (manifestFileExists && fullClean && !dryRun) {
328
+ corruptManifestRemoved = deleteManifest(targetDir);
329
+ }
330
+ return {
331
+ removedSkills: [],
332
+ removedHooks: [],
333
+ removedMcpServers: [],
334
+ mcpConfigPath: null,
335
+ settingsPath: null,
336
+ manifestPath,
337
+ manifestExisted: manifestFileExists,
338
+ manifestRemoved: corruptManifestRemoved,
339
+ };
340
+ }
341
+ const configPath = join(targetDir, ".codex", "config.toml");
342
+ const removedSkills = [];
343
+ if (cleanSkills) {
344
+ for (const id of manifest.skills) {
345
+ const dir = join(targetDir, ".agents", "skills", id);
346
+ if (!existsSync(dir))
347
+ continue;
348
+ if (!dryRun)
349
+ rmSync(dir, { recursive: true, force: true });
350
+ removedSkills.push(id);
351
+ }
352
+ }
353
+ const removedHooks = [];
354
+ if (cleanHooks) {
355
+ for (const id of manifest.hooks) {
356
+ const dir = join(targetDir, ".codex", "hooks", id);
357
+ if (!existsSync(dir))
358
+ continue;
359
+ if (!dryRun)
360
+ rmSync(dir, { recursive: true, force: true });
361
+ removedHooks.push(id);
362
+ }
363
+ }
364
+ // Both MCP servers and hooks live in `.codex/config.toml`. Prune AIR-owned
365
+ // entries in a single read/modify/write, preserving user-authored keys.
366
+ const removedMcpServers = [];
367
+ let mcpConfigPath = null;
368
+ if ((cleanMcpServers && manifest.mcpServers.length > 0) ||
369
+ (cleanHooks && manifest.hooks.length > 0)) {
370
+ if (existsSync(configPath)) {
371
+ const presentMcpIds = cleanMcpServers
372
+ ? this.mcpServerIdsPresent(configPath, manifest.mcpServers)
373
+ : [];
374
+ const willTouch = presentMcpIds.length > 0 || (cleanHooks && manifest.hooks.length > 0);
375
+ if (willTouch) {
376
+ if (!dryRun) {
377
+ mcpConfigPath = this.pruneCodexConfig(configPath, cleanMcpServers ? presentMcpIds : [], cleanHooks ? new Set(manifest.hooks) : new Set());
378
+ }
379
+ else {
380
+ mcpConfigPath = configPath;
381
+ }
382
+ for (const id of presentMcpIds)
383
+ removedMcpServers.push(id);
384
+ }
385
+ }
386
+ }
387
+ let manifestRemoved = false;
388
+ if (fullClean) {
389
+ if (!dryRun) {
390
+ manifestRemoved = deleteManifest(targetDir);
391
+ }
392
+ else {
393
+ manifestRemoved = manifestFileExists;
394
+ }
395
+ }
396
+ else if (!dryRun) {
397
+ writeManifest(buildManifest(targetDir, {
398
+ adapter: manifest.adapter ?? this.name,
399
+ skills: cleanSkills ? [] : manifest.skills,
400
+ hooks: cleanHooks ? [] : manifest.hooks,
401
+ mcpServers: cleanMcpServers ? [] : manifest.mcpServers,
402
+ }));
403
+ }
404
+ return {
405
+ removedSkills,
406
+ removedHooks,
407
+ removedMcpServers,
408
+ // `.codex/config.toml` carries both MCP servers and hooks; report it as
409
+ // the MCP config path. `settingsPath` stays null — Codex has no separate
410
+ // settings file.
411
+ mcpConfigPath,
412
+ settingsPath: null,
413
+ manifestPath,
414
+ manifestExisted: true,
415
+ manifestRemoved,
416
+ };
417
+ }
418
+ /**
419
+ * Read `.codex/config.toml` and return the subset of `ids` whose key is
420
+ * actually present under `[mcp_servers]`. Returns an empty list if the file
421
+ * can't be parsed — we'd rather under-report than claim to have removed
422
+ * entries we never touched.
423
+ */
424
+ mcpServerIdsPresent(configPath, ids) {
425
+ const existing = this.readToml(configPath);
426
+ const servers = existing.mcp_servers ?? {};
427
+ return ids.filter((id) => Object.prototype.hasOwnProperty.call(servers, id));
428
+ }
429
+ /**
430
+ * Resolve subagent roots from the root's default_subagent_roots.
431
+ * IDs are already qualified after composition-time canonicalization.
432
+ */
433
+ resolveSubagentRoots(root, artifacts, options) {
434
+ if (options?.skipSubagentMerge)
435
+ return [];
436
+ if (!root?.default_subagent_roots?.length)
437
+ return [];
438
+ const resolved = [];
439
+ for (const id of root.default_subagent_roots) {
440
+ const res = resolveReference(artifacts.roots, id, undefined);
441
+ if (res.status === "ok") {
442
+ resolved.push(artifacts.roots[res.qualified]);
443
+ }
444
+ }
445
+ return resolved;
446
+ }
447
+ /**
448
+ * Merge subagent roots' default_mcp_servers and default_skills into the
449
+ * parent's activated sets (union, preserving order with parent first).
450
+ */
451
+ mergeSubagentArtifacts(subagentRoots, parentMcpServerIds, parentSkillIds) {
452
+ const mcpSet = new Set(parentMcpServerIds ?? []);
453
+ const skillSet = new Set(parentSkillIds);
454
+ for (const sub of subagentRoots) {
455
+ if (sub.default_mcp_servers) {
456
+ for (const id of sub.default_mcp_servers)
457
+ mcpSet.add(id);
458
+ }
459
+ if (sub.default_skills) {
460
+ for (const id of sub.default_skills)
461
+ skillSet.add(id);
462
+ }
463
+ }
464
+ return {
465
+ mcpServerIds: parentMcpServerIds !== undefined || mcpSet.size > 0 ? [...mcpSet] : undefined,
466
+ skillIds: [...skillSet],
467
+ };
468
+ }
469
+ /**
470
+ * Build a system prompt section describing the subagent root dependencies.
471
+ */
472
+ buildSubagentContext(subagentRoots) {
473
+ const lines = [
474
+ "## Subagent Root Dependencies",
475
+ "",
476
+ "This session includes capabilities from the following subagent roots.",
477
+ "Their skills and MCP servers have been merged into your session.",
478
+ "",
479
+ ];
480
+ for (const sub of subagentRoots) {
481
+ lines.push(`### ${sub.display_name || "Subagent"}`);
482
+ lines.push("");
483
+ lines.push(`**Description**: ${sub.description}`);
484
+ if (sub.default_mcp_servers?.length) {
485
+ lines.push(`**MCP Servers**: ${sub.default_mcp_servers.join(", ")}`);
486
+ }
487
+ if (sub.default_skills?.length) {
488
+ lines.push(`**Skills**: ${sub.default_skills.join(", ")}`);
489
+ }
490
+ if (sub.subdirectory) {
491
+ lines.push(`**Subdirectory**: ${sub.subdirectory}`);
492
+ }
493
+ lines.push("");
494
+ }
495
+ return lines.join("\n");
496
+ }
497
+ /**
498
+ * Translate a shortname-keyed MCP server map into Codex's `[mcp_servers.*]`
499
+ * shape (as a plain object ready for TOML serialization). Callers convert
500
+ * qualified IDs to shortnames before invoking this — Codex's config is
501
+ * scope-naive.
502
+ *
503
+ * Secret handling is Codex-native: an env value that is exactly `${VAR}`
504
+ * and whose key matches `VAR` becomes an `env_vars` forward (Codex injects
505
+ * the host's `VAR` at launch); any other value is written literally into the
506
+ * `[mcp_servers.<name>.env]` table. For remote servers, a header value of
507
+ * `${VAR}` becomes an `env_http_headers` entry; other header values are
508
+ * written into `http_headers`.
509
+ *
510
+ * Codex's native forwarding only expresses *whole-value* refs: `env_vars`
511
+ * forwards a host var to an env key of the same name, and `env_http_headers`
512
+ * forwards a host var as a whole header value. A *renamed* whole-value ref
513
+ * (`KEY = "${OTHER}"`) or a *partial* value (`"Bearer ${TOKEN}"`) can't be
514
+ * expressed either way, so it falls through to the literal table — and since
515
+ * the TOML never passes through AIR's `${VAR}` transform pipeline, Codex
516
+ * would inject the literal `${…}` string at runtime. We warn loudly in that
517
+ * case rather than silently shipping a broken secret.
518
+ */
519
+ translateMcpServersByShort(servers) {
520
+ const out = {};
521
+ for (const [name, server] of Object.entries(servers)) {
522
+ out[name] = this.translateMcpServer(name, server);
523
+ }
524
+ return out;
525
+ }
526
+ translateMcpServer(name, server) {
527
+ if (server.type === "stdio") {
528
+ const out = { command: server.command };
529
+ if (server.args && server.args.length > 0)
530
+ out.args = server.args;
531
+ const envTable = {};
532
+ const envVars = [];
533
+ for (const [key, value] of Object.entries(server.env ?? {})) {
534
+ const m = WHOLE_VAR_RE.exec(value);
535
+ if (m && m[1] === key) {
536
+ // `${KEY}` referencing the host var of the same name → forward it.
537
+ envVars.push(key);
538
+ }
539
+ else {
540
+ if (CONTAINS_VAR_RE.test(value)) {
541
+ this.warnUnforwardableSecret(name, `env["${key}"]`, value);
542
+ }
543
+ envTable[key] = value;
544
+ }
545
+ }
546
+ if (envVars.length > 0)
547
+ out.env_vars = envVars;
548
+ if (Object.keys(envTable).length > 0)
549
+ out.env = envTable;
550
+ return out;
551
+ }
552
+ // Remote server (sse or streamable-http). Codex models both as a URL-based
553
+ // streamable HTTP server and auto-detects the transport from the URL.
554
+ const out = { url: server.url };
555
+ const httpHeaders = {};
556
+ const envHttpHeaders = {};
557
+ for (const [key, value] of Object.entries(server.headers ?? {})) {
558
+ const m = WHOLE_VAR_RE.exec(value);
559
+ if (m) {
560
+ envHttpHeaders[key] = m[1];
561
+ }
562
+ else {
563
+ if (CONTAINS_VAR_RE.test(value)) {
564
+ this.warnUnforwardableSecret(name, `headers["${key}"]`, value);
565
+ }
566
+ httpHeaders[key] = value;
567
+ }
568
+ }
569
+ if (Object.keys(httpHeaders).length > 0)
570
+ out.http_headers = httpHeaders;
571
+ if (Object.keys(envHttpHeaders).length > 0)
572
+ out.env_http_headers = envHttpHeaders;
573
+ // NOTE: AIR's detailed OAuth config (clientId/scopes/redirectUri/…) has no
574
+ // static Codex equivalent — Codex performs interactive OAuth via
575
+ // `codex mcp login <name>`. The gap is documented in the adapter README.
576
+ return out;
577
+ }
578
+ /**
579
+ * Warn that a secret reference can't be expressed via Codex's native host-env
580
+ * forwarding and will be written to `.codex/config.toml` as a literal `${…}`
581
+ * string. Codex would then inject that literal text at runtime — a silently
582
+ * broken secret. Only whole-value, same-named refs forward cleanly; renamed
583
+ * (`KEY = "${OTHER}"`) and partial (`"Bearer ${TOKEN}"`) refs land here.
584
+ */
585
+ warnUnforwardableSecret(serverName, field, value) {
586
+ console.warn(`[air-adapter-codex] MCP server "${serverName}" ${field} = "${value}" contains a ` +
587
+ `\${VAR} reference that Codex cannot forward natively. Codex's env_vars / ` +
588
+ `env_http_headers only express whole-value refs to a host var of the same name, ` +
589
+ `so this value is written to .codex/config.toml verbatim and Codex will inject the ` +
590
+ `literal "\${…}" string at runtime. Rewrite it as a whole-value, same-named ref ` +
591
+ `(e.g. ${field.includes("headers") ? `Authorization = "\${AUTHORIZATION}"` : `KEY = "\${KEY}"`}) ` +
592
+ `or set the value directly.`);
593
+ }
594
+ /**
595
+ * Translate an AIR plugin to a Codex-facing descriptor.
596
+ *
597
+ * Codex's marketplace plugins are remote-installed (`codex plugin add`) and
598
+ * have no local package file an adapter can materialize. AIR therefore treats
599
+ * plugins as composition sugar: a plugin's declared MCP servers / skills /
600
+ * hooks are expanded into the activation set and materialized as their
601
+ * underlying Codex-native artifacts. This descriptor is informational only.
602
+ */
603
+ translatePlugin(shortId, plugin) {
604
+ return {
605
+ name: shortId,
606
+ description: plugin.description,
607
+ ...(plugin.version && { version: plugin.version }),
608
+ };
609
+ }
610
+ /**
611
+ * Resolve a list of activation IDs (each qualified or short) into qualified
612
+ * IDs paired with shortnames suitable for filesystem materialization.
613
+ *
614
+ * Throws on:
615
+ * - unknown IDs (after attempting both qualified and short-form lookup)
616
+ * - ambiguous short references (multiple scopes contribute the shortname)
617
+ * - shortname collisions in the activation set itself (two qualified IDs
618
+ * with the same shortname can't share a single materialization dir)
619
+ */
620
+ resolveActivations(pool, ids, artifactType) {
621
+ const acts = [];
622
+ const errors = [];
623
+ const shortToQualified = new Map();
624
+ for (const id of ids) {
625
+ const res = resolveReference(pool, id, undefined);
626
+ if (res.status === "missing") {
627
+ errors.push(`Unknown ${artifactType} ID "${id}". Available: ${this.formatPoolKeys(pool)}.`);
628
+ continue;
629
+ }
630
+ if (res.status === "ambiguous") {
631
+ errors.push(`${artifactType} reference "${id}" is ambiguous — candidates: ` +
632
+ `${res.candidates.join(", ")}. Use the qualified form to disambiguate.`);
633
+ continue;
634
+ }
635
+ const qualified = res.qualified;
636
+ const { id: short } = parseQualifiedId(qualified);
637
+ const prior = shortToQualified.get(short);
638
+ if (prior !== undefined && prior !== qualified) {
639
+ errors.push(`${artifactType} shortname collision: both "${prior}" and "${qualified}" ` +
640
+ `are activated and would write to the same target name "${short}". ` +
641
+ `Add one to air.json#exclude or activate only one of them.`);
642
+ continue;
643
+ }
644
+ if (prior === qualified)
645
+ continue; // dedup
646
+ shortToQualified.set(short, qualified);
647
+ acts.push({ qualified, short });
648
+ }
649
+ if (errors.length > 0) {
650
+ throw new Error(errors.length === 1 ? errors[0] : `Activation errors:\n - ${errors.join("\n - ")}`);
651
+ }
652
+ return acts;
653
+ }
654
+ /**
655
+ * Build the warning emitted when a registered artifact's `path` does not
656
+ * exist on disk at materialization time. The qualified ID encodes the
657
+ * declaring catalog's scope, so a reviewer can trace the offending entry
658
+ * back to its index file. Materialization is skipped for this artifact and
659
+ * the rest of the session proceeds.
660
+ */
661
+ missingSourceDirMessage(artifactType, qualified, resolvedPath) {
662
+ return (`warning: ${artifactType} "${qualified}" declares path "${resolvedPath}" but that directory does not exist — skipping. ` +
663
+ `The catalog that contributed "${qualified}" registered a path AIR cannot materialize. ` +
664
+ `Fix the \`path\` field in the catalog's index file (or exclude the artifact in air.json) to restore the ${artifactType}.`);
665
+ }
666
+ formatPoolKeys(pool) {
667
+ const keys = Object.keys(pool);
668
+ if (keys.length === 0)
669
+ return "(none)";
670
+ if (keys.length > 8) {
671
+ return `${keys.slice(0, 8).join(", ")}, … (${keys.length} total)`;
672
+ }
673
+ return keys.join(", ");
674
+ }
675
+ /**
676
+ * Copy referenced documents into a references/ subdirectory of the target.
677
+ * `refIds` are qualified IDs (post-canonicalization).
678
+ */
679
+ copyReferences(refIds, targetDir, artifacts) {
680
+ const refsTargetDir = join(targetDir, "references");
681
+ for (const refId of refIds) {
682
+ const ref = artifacts.references[refId];
683
+ if (!ref)
684
+ continue;
685
+ const refSourcePath = ref.path;
686
+ if (existsSync(refSourcePath)) {
687
+ const refTargetPath = join(refsTargetDir, ref.path.split("/").pop() || ref.path);
688
+ mkdirSync(dirname(refTargetPath), { recursive: true });
689
+ copyFileSync(refSourcePath, refTargetPath);
690
+ }
691
+ }
692
+ }
693
+ // ============================================================
694
+ // `.codex/config.toml` read / merge / write
695
+ // ============================================================
696
+ /** Parse an existing `config.toml`, returning `{}` when absent/unparseable. */
697
+ readToml(path) {
698
+ if (!existsSync(path))
699
+ return {};
700
+ try {
701
+ return parseToml(readFileSync(path, "utf-8"));
702
+ }
703
+ catch {
704
+ return {};
705
+ }
706
+ }
707
+ /**
708
+ * Merge AIR-managed MCP servers and hook registrations into
709
+ * `.codex/config.toml`, preserving any user-authored config.
710
+ *
711
+ * - MCP: keys in `staleMcpIds` are removed; keys in `translatedServers`
712
+ * are set/replaced; other servers and top-level keys pass through.
713
+ * - Hooks: AIR-owned entries (tagged with `_air_hook_id`) whose ID is in
714
+ * `managedHookIds` are pruned, then the current selection is registered.
715
+ *
716
+ * Returns the path written.
717
+ */
718
+ writeCodexConfig(targetDir, translatedServers, staleMcpIds, newHookPaths, managedHookIds) {
719
+ const configPath = join(targetDir, ".codex", "config.toml");
720
+ const config = this.readToml(configPath);
721
+ // --- MCP servers ---
722
+ const servers = config.mcp_servers ?? {};
723
+ for (const id of staleMcpIds)
724
+ delete servers[id];
725
+ for (const [id, cfg] of Object.entries(translatedServers))
726
+ servers[id] = cfg;
727
+ if (Object.keys(servers).length > 0) {
728
+ config.mcp_servers = servers;
729
+ }
730
+ else {
731
+ delete config.mcp_servers;
732
+ }
733
+ // --- Hooks ---
734
+ this.reconcileConfigHooks(config, targetDir, newHookPaths, managedHookIds);
735
+ // Nothing to persist: don't leave an empty `.codex/config.toml` behind, and
736
+ // remove a now-empty one if a prior run (or user edit) emptied it out.
737
+ if (Object.keys(config).length === 0) {
738
+ if (existsSync(configPath))
739
+ rmSync(configPath, { force: true });
740
+ return configPath;
741
+ }
742
+ mkdirSync(dirname(configPath), { recursive: true });
743
+ writeFileSync(configPath, stringifyToml(config) + "\n");
744
+ return configPath;
745
+ }
746
+ /**
747
+ * Reconcile the `[hooks.*]` tables on the in-memory config object.
748
+ *
749
+ * Codex represents hooks as `hooks.<Event> = [ { matcher, hooks: [ {type,
750
+ * command, timeout?, statusMessage?} ] } ]`. AIR-owned matcher groups are
751
+ * identified by an `_air_hook_id` marker on the inner hook entry (Codex
752
+ * ignores unrecognized keys unless `--strict-config` is set). Groups whose ID
753
+ * is in `managedHookIds` are removed, then the current `newHookPaths` are
754
+ * registered for their mapped events.
755
+ */
756
+ reconcileConfigHooks(config, targetDir, newHookPaths, managedHookIds) {
757
+ const hooks = config.hooks ?? {};
758
+ for (const event of Object.keys(hooks)) {
759
+ const matcherGroups = hooks[event];
760
+ if (!Array.isArray(matcherGroups))
761
+ continue;
762
+ const prunedGroups = [];
763
+ for (const group of matcherGroups) {
764
+ if (!group || typeof group !== "object") {
765
+ prunedGroups.push(group);
766
+ continue;
767
+ }
768
+ const g = group;
769
+ const inner = Array.isArray(g.hooks) ? g.hooks : [];
770
+ const keptInner = inner.filter((h) => !this.isManagedHookEntry(h, managedHookIds));
771
+ if (keptInner.length === 0)
772
+ continue;
773
+ prunedGroups.push({ ...g, hooks: keptInner });
774
+ }
775
+ if (prunedGroups.length === 0) {
776
+ delete hooks[event];
777
+ }
778
+ else {
779
+ hooks[event] = prunedGroups;
780
+ }
781
+ }
782
+ for (const hookPath of newHookPaths) {
783
+ const hookJsonPath = join(hookPath, "HOOK.json");
784
+ if (!existsSync(hookJsonPath))
785
+ continue;
786
+ let hookJson;
787
+ try {
788
+ hookJson = JSON.parse(readFileSync(hookJsonPath, "utf-8"));
789
+ }
790
+ catch {
791
+ continue;
792
+ }
793
+ const hookId = hookPath.split(/[\\/]/).filter(Boolean).pop() || "";
794
+ const rawEvent = hookJson.event;
795
+ const codexEvent = typeof rawEvent === "string"
796
+ ? CodexAdapter.AIR_TO_CODEX_EVENT[rawEvent]
797
+ : undefined;
798
+ if (!codexEvent) {
799
+ if (rawEvent !== undefined) {
800
+ console.warn(`warning: hook "${hookId}" declares unrecognized event "${String(rawEvent)}" — skipping registration in .codex/config.toml. ` +
801
+ `Supported events: ${this.supportedEventList()}.`);
802
+ }
803
+ continue;
804
+ }
805
+ if (!hookJson.command)
806
+ continue;
807
+ const hookRelDir = relative(targetDir, hookPath);
808
+ const command = this.buildHookCommand(hookRelDir, hookPath, hookJson.command, hookJson.args);
809
+ const hookEntry = {
810
+ type: "command",
811
+ command,
812
+ _air_hook_id: hookId,
813
+ };
814
+ if (hookJson.timeout_seconds != null) {
815
+ hookEntry.timeout = hookJson.timeout_seconds;
816
+ }
817
+ const matcherGroup = {
818
+ matcher: hookJson.matcher ?? "",
819
+ hooks: [hookEntry],
820
+ };
821
+ if (!hooks[codexEvent]) {
822
+ hooks[codexEvent] = [];
823
+ }
824
+ hooks[codexEvent].push(matcherGroup);
825
+ }
826
+ if (Object.keys(hooks).length > 0) {
827
+ config.hooks = hooks;
828
+ }
829
+ else {
830
+ delete config.hooks;
831
+ }
832
+ }
833
+ isManagedHookEntry(entry, managedHookIds) {
834
+ if (!entry || typeof entry !== "object")
835
+ return false;
836
+ const id = entry._air_hook_id;
837
+ if (typeof id !== "string")
838
+ return false;
839
+ return managedHookIds.has(id);
840
+ }
841
+ /**
842
+ * Remove `mcpIds` from `[mcp_servers]` and AIR-managed hook entries (matched
843
+ * by `_air_hook_id` ∈ `managedHookIds`) from `[hooks.*]` in
844
+ * `.codex/config.toml`, preserving user-authored entries and other top-level
845
+ * fields. Returns the path of the file that was rewritten, or null if the
846
+ * file became empty and was deleted.
847
+ */
848
+ pruneCodexConfig(configPath, mcpIds, managedHookIds) {
849
+ const config = this.readToml(configPath);
850
+ const servers = config.mcp_servers ?? {};
851
+ for (const id of mcpIds)
852
+ delete servers[id];
853
+ if (Object.keys(servers).length > 0) {
854
+ config.mcp_servers = servers;
855
+ }
856
+ else {
857
+ delete config.mcp_servers;
858
+ }
859
+ if (managedHookIds.size > 0) {
860
+ this.reconcileConfigHooks(config, dirname(dirname(configPath)), [], managedHookIds);
861
+ }
862
+ if (Object.keys(config).length === 0) {
863
+ rmSync(configPath, { force: true });
864
+ return null;
865
+ }
866
+ writeFileSync(configPath, stringifyToml(config) + "\n");
867
+ return configPath;
868
+ }
869
+ /**
870
+ * Build a shell command string from HOOK.json's command and args fields.
871
+ *
872
+ * Hook authors write paths relative to their own hook directory. Codex
873
+ * invokes hooks with a working directory that is not guaranteed to be the
874
+ * project root, so hook-relative paths are anchored to the repository root
875
+ * via `"$(git rev-parse --show-toplevel)/.codex/hooks/<id>/<path>"` — the
876
+ * idiom used in Codex's own hook documentation. The double quotes keep the
877
+ * path safe for project directories with spaces.
878
+ *
879
+ * - `command` is anchored if it starts with `./` (explicit hook-relative).
880
+ * - Each `args` entry is anchored if it looks like a path AND the file
881
+ * exists under the hook's installed directory.
882
+ *
883
+ * Args that are not rewritten and contain shell metacharacters are
884
+ * single-quoted for safety.
885
+ */
886
+ buildHookCommand(hookRelDir, hookAbsDir, command, args) {
887
+ let cmd;
888
+ if (command.startsWith("./")) {
889
+ cmd = this.anchorHookPath(join(hookRelDir, command.slice(2)));
890
+ }
891
+ else {
892
+ cmd = command;
893
+ }
894
+ if (args && args.length > 0) {
895
+ const rewritten = args.map((a) => this.rewriteHookArgPath(a, hookRelDir, hookAbsDir));
896
+ const escaped = rewritten.map((r) => r.anchored
897
+ ? this.anchorHookPath(r.value)
898
+ : /[\s;&|`$"'\\]/.test(r.value)
899
+ ? `'${r.value.replace(/'/g, "'\\''")}'`
900
+ : r.value);
901
+ cmd += " " + escaped.join(" ");
902
+ }
903
+ return cmd;
904
+ }
905
+ /**
906
+ * Wrap a project-root-relative path in `"$(git rev-parse --show-toplevel)/..."`
907
+ * so the resolved path is independent of the cwd at hook invocation. Double
908
+ * quotes are required so the command substitution runs; characters that are
909
+ * special inside double quotes (`$`, `` ` ``, `"`, `\`) are escaped in the
910
+ * path component so an unusual hook ID or filename can't break out of the
911
+ * quoting.
912
+ */
913
+ anchorHookPath(projectRelPath) {
914
+ const safe = projectRelPath.replace(/[\\"$`]/g, "\\$&");
915
+ return `"$(git rev-parse --show-toplevel)/${safe}"`;
916
+ }
917
+ /**
918
+ * If `arg` is a hook-relative path that points at a real file under the
919
+ * hook's installed directory, return its project-root-relative form
920
+ * (`.codex/hooks/<id>/<path>`) flagged as anchored so the caller can wrap it.
921
+ * Otherwise return `arg` unchanged with `anchored: false`.
922
+ */
923
+ rewriteHookArgPath(arg, hookRelDir, hookAbsDir) {
924
+ if (!arg)
925
+ return { value: arg, anchored: false };
926
+ if (arg.startsWith("-") || arg.startsWith("/") || arg.startsWith("~")) {
927
+ return { value: arg, anchored: false };
928
+ }
929
+ const hasExplicitPrefix = arg.startsWith("./");
930
+ const candidate = hasExplicitPrefix ? arg.slice(2) : arg;
931
+ if (!hasExplicitPrefix && !candidate.includes("/")) {
932
+ return { value: arg, anchored: false };
933
+ }
934
+ if (!existsSync(join(hookAbsDir, candidate))) {
935
+ return { value: arg, anchored: false };
936
+ }
937
+ return { value: join(hookRelDir, candidate), anchored: true };
938
+ }
939
+ /** Human-readable list of the supported AIR hook event names (snake_case only). */
940
+ supportedEventList() {
941
+ const seen = new Set();
942
+ const names = [];
943
+ for (const [key, value] of Object.entries(CodexAdapter.AIR_TO_CODEX_EVENT)) {
944
+ if (key === value)
945
+ continue; // skip PascalCase identity entries
946
+ if (seen.has(key))
947
+ continue;
948
+ seen.add(key);
949
+ names.push(key);
950
+ }
951
+ return names.join(", ");
952
+ }
953
+ copyDirRecursive(src, dest) {
954
+ mkdirSync(dest, { recursive: true });
955
+ for (const entry of readdirSync(src)) {
956
+ const srcPath = join(src, entry);
957
+ const destPath = join(dest, entry);
958
+ if (statSync(srcPath).isDirectory()) {
959
+ this.copyDirRecursive(srcPath, destPath);
960
+ }
961
+ else {
962
+ copyFileSync(srcPath, destPath);
963
+ }
964
+ }
965
+ }
966
+ }
967
+ //# sourceMappingURL=codex-adapter.js.map