@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.
- package/README.md +83 -0
- package/dist/codex-adapter.d.ts +226 -0
- package/dist/codex-adapter.d.ts.map +1 -0
- package/dist/codex-adapter.js +967 -0
- package/dist/codex-adapter.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/scan-local-skills.d.ts +11 -0
- package/dist/scan-local-skills.d.ts.map +1 -0
- package/dist/scan-local-skills.js +92 -0
- package/dist/scan-local-skills.js.map +1 -0
- package/package.json +43 -0
|
@@ -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
|