@ucsandman/legcli 0.8.0 → 0.10.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/NOTICE +8 -0
  3. package/README.md +639 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +43 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +205 -0
  8. package/docs/README.md +5 -1
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +22 -0
  11. package/docs/board-guide.md +33 -1
  12. package/docs/cli-contracts.md +36 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +23 -1
  15. package/docs/faq.md +19 -0
  16. package/docs/getting-started.md +272 -251
  17. package/docs/harness.md +319 -0
  18. package/docs/history.md +172 -0
  19. package/docs/runtime-tap.md +156 -0
  20. package/fixtures/verified.json +1 -1
  21. package/package.json +7 -3
  22. package/scripts/build-docs-site.mjs +18 -4
  23. package/scripts/check-branding.mjs +118 -0
  24. package/scripts/check-claims.mjs +1 -1
  25. package/scripts/license-sign.mjs +1 -1
  26. package/scripts/limits-table.mjs +1 -1
  27. package/scripts/live-limits.mjs +1 -1
  28. package/scripts/npm-publish-gate.mjs +114 -0
  29. package/scripts/probe.mjs +4 -3
  30. package/scripts/seed-fake-cards.mjs +4 -3
  31. package/scripts/seed-floor-board.mjs +5 -4
  32. package/scripts/seed-wes-board.mjs +5 -4
  33. package/scripts/stripe-setup.mjs +1 -1
  34. package/scripts/sync-harness-engine.mjs +159 -0
  35. package/scripts/sync-leg-agents.mjs +127 -0
  36. package/src/accounts.mjs +6 -4
  37. package/src/adapters/codex.mjs +1 -1
  38. package/src/attach.mjs +125 -23
  39. package/src/auth.mjs +2 -2
  40. package/src/board/board.css +23 -1
  41. package/src/board/board.js +17 -5
  42. package/src/board/history.js +377 -0
  43. package/src/board/index.html +33 -0
  44. package/src/board/sessions.js +95 -7
  45. package/src/bundle.mjs +54 -8
  46. package/src/chain.mjs +1 -1
  47. package/src/contract.mjs +4 -3
  48. package/src/fsx.mjs +5 -2
  49. package/src/handoff.mjs +6 -6
  50. package/src/harness/cli.mjs +281 -0
  51. package/src/harness/fingerprint.mjs +68 -0
  52. package/src/harness/index.mjs +407 -0
  53. package/src/harness/registry.mjs +124 -0
  54. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  55. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  56. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  57. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  74. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  75. package/src/history/cli.mjs +159 -0
  76. package/src/history/common.mjs +119 -0
  77. package/src/history/index.mjs +429 -0
  78. package/src/history/providers/agy.mjs +91 -0
  79. package/src/history/providers/claude.mjs +161 -0
  80. package/src/history/providers/codex.mjs +133 -0
  81. package/src/history/providers/copilot.mjs +94 -0
  82. package/src/history/providers/grok.mjs +138 -0
  83. package/src/history/worktrees.mjs +116 -0
  84. package/src/hook.mjs +49 -49
  85. package/src/land.mjs +7 -35
  86. package/src/launcher.mjs +38 -26
  87. package/src/ledger.mjs +6 -6
  88. package/src/license.mjs +10 -9
  89. package/src/live-capture.mjs +1 -1
  90. package/src/mergequeue.mjs +5 -5
  91. package/src/orchestrator.mjs +28 -4
  92. package/src/preferences.mjs +37 -3
  93. package/src/redact.mjs +24 -6
  94. package/src/resume.mjs +17 -15
  95. package/src/runner.mjs +2 -2
  96. package/src/scheduler.mjs +1 -1
  97. package/src/server.mjs +224 -18
  98. package/src/session-detail.mjs +15 -1
  99. package/src/sessions.mjs +15 -3
  100. package/src/share.mjs +2 -2
  101. package/src/stations/agent.mjs +1 -1
  102. package/src/sync/dashclaw.mjs +4 -4
  103. package/src/synthesis.mjs +165 -0
  104. package/src/taps/agy.mjs +2 -2
  105. package/src/taps/claude-usage.mjs +1 -1
  106. package/src/taps/claude.mjs +177 -170
  107. package/src/taps/codex.mjs +286 -286
  108. package/src/taps/grok.mjs +2 -2
  109. package/src/taps/mod.mjs +340 -0
  110. package/src/trust.mjs +205 -36
  111. package/src/usage.mjs +5 -1
  112. package/src/worktree.mjs +6 -5
  113. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  114. package/fixtures/live/agy/err.log +0 -0
  115. package/fixtures/live/agy/out.log +0 -1
  116. package/fixtures/live/agy/supervisor.log +0 -2
  117. package/fixtures/live/claude/err.log +0 -0
  118. package/fixtures/live/claude/out.log +0 -1
  119. package/fixtures/live/claude/supervisor.log +0 -2
  120. package/fixtures/live/codex/err.log +0 -1
  121. package/fixtures/live/codex/out.log +0 -8
  122. package/fixtures/live/codex/supervisor.log +0 -2
  123. package/fixtures/live/grok/err.log +0 -32
  124. package/fixtures/live/grok/out.log +0 -7
  125. package/fixtures/live/grok/supervisor.log +0 -2
@@ -0,0 +1,330 @@
1
+ /**
2
+ * engine/harness/sources/claude.cjs — capture the Claude Code harness into the
3
+ * client-neutral bundle.
4
+ *
5
+ * Claude Code is the canonical dialect (hook events, tool names, matcher syntax),
6
+ * so capture is a read + normalise, not a translation. Capture is COMPLETE: every
7
+ * hook, skill and server the client has lands in the bundle. What a given target
8
+ * refuses to carry is that target's decision, made in targets/<id>.cjs from
9
+ * core/port.json, so `explain` can name the client that dropped it.
10
+ *
11
+ * Nothing here is machine-specific: every path comes from `home` or from the
12
+ * expanded `claude` entry of core/templates/targets.json.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const common = require('../common.cjs');
18
+ const bundleLib = require('../bundle.cjs');
19
+
20
+ const ID = 'claude';
21
+ const EDIT_TOOLS = /^(?:Edit|Write|MultiEdit|NotebookEdit)$/;
22
+ /** How many levels of `@import` are inlined: the rules file's own, plus one more. */
23
+ const IMPORT_DEPTH = 2;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Paths
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** True when `child` is `parent` or lives under it (case- and separator-insensitive). */
30
+ function inside(child, parent) {
31
+ if (!child || !parent) return false;
32
+ const c = common.plainPath(child);
33
+ const p = common.plainPath(parent);
34
+ return c === p || c.startsWith(p.endsWith('/') ? p : `${p}/`);
35
+ }
36
+
37
+ /**
38
+ * The registry path when there is one, else the conventional one under `home`.
39
+ * loadRegistry() expands every entry against the home it is given, and a host
40
+ * may point a client at a config dir outside that home (CLAUDE_CONFIG_DIR):
41
+ * that path is the one to read, never a silent fallback to ~/.claude.
42
+ */
43
+ const pick = (registryPath, fallback) => (registryPath ? registryPath : fallback);
44
+
45
+ function surfaces(home, target = {}) {
46
+ const claudeDir = pick(target.home, path.join(home, '.claude'));
47
+ return {
48
+ claudeDir,
49
+ rulesFile: path.join(claudeDir, 'CLAUDE.md'),
50
+ identityFile: pick(target.traitsFile, path.join(claudeDir, 'SOUL.md')),
51
+ settingsFile: pick(target.hooksConfigFile, path.join(claudeDir, 'settings.json')),
52
+ agentsDir: pick(target.agentsDir, path.join(claudeDir, 'agents')),
53
+ commandsDir: pick(target.commandsDir, path.join(claudeDir, 'commands')),
54
+ skillsDir: pick(target.skillsDir, path.join(claudeDir, 'skills')),
55
+ mcpFile: pick(target.mcpConfigFile, path.join(home, '.claude.json')),
56
+ projectMcpFile: path.join(claudeDir, '.mcp.json'),
57
+ };
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Rules: CLAUDE.md with every @import inlined
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** `@~/x`, `@C:/x`, `@./x`, `@x` -> an absolute path, resolved against the importing file's directory. */
65
+ function resolveImport(spec, home, baseDir) {
66
+ if (spec === '~') return home;
67
+ if (spec.startsWith('~/') || spec.startsWith('~\\')) return path.join(home, spec.slice(2));
68
+ if (path.isAbsolute(spec)) return path.resolve(spec);
69
+ return path.resolve(baseDir, spec);
70
+ }
71
+
72
+ /**
73
+ * Drop the header a generated file opens with: the `# Title` line and, when the
74
+ * next non-blank line announces it, the `GENERATED ...` provenance line. The
75
+ * bundle carries one agreement, not a stack of per-file title pages.
76
+ */
77
+ function stripGeneratedHeader(text) {
78
+ const lines = String(text).split(/\r?\n/);
79
+ if (!/^#\s+\S/.test(lines[0] || '')) return String(text);
80
+ let i = 1;
81
+ while (i < lines.length && lines[i].trim() === '') i++;
82
+ if (i < lines.length && lines[i].includes('GENERATED')) {
83
+ i++;
84
+ while (i < lines.length && lines[i].trim() === '') i++;
85
+ }
86
+ return lines.slice(i).join('\n');
87
+ }
88
+
89
+ /**
90
+ * Inline `@<path>` import lines. An import is followed only when the resolved
91
+ * file lives inside `home` or inside the repo: an agreement is allowed to be
92
+ * assembled from the user's own harness and from this repo, and from nothing
93
+ * else, because the result is copied verbatim into every other client.
94
+ */
95
+ function inlineImports(text, { home, baseDir, level, warnings, where }) {
96
+ const out = [];
97
+ for (const line of String(text).split(/\r?\n/)) {
98
+ const m = line.match(/^@(\S+)[ \t]*$/);
99
+ if (!m) { out.push(line); continue; }
100
+ const spec = m[1];
101
+ const resolved = resolveImport(spec, home, baseDir);
102
+ if (level <= 0) {
103
+ warnings.push(`${where}: import ${spec} is nested deeper than ${IMPORT_DEPTH} levels; the line was dropped`);
104
+ continue;
105
+ }
106
+ if (!inside(resolved, home) && !common.importRoots().some((root) => inside(resolved, root))) {
107
+ warnings.push(`${where}: import ${spec} resolves outside the home directory and outside the allowed import roots; the line was dropped`);
108
+ continue;
109
+ }
110
+ const raw = common.readText(resolved);
111
+ if (raw == null) {
112
+ warnings.push(`${where}: import ${spec} does not exist (${common.tildePath(resolved, home)}); the line was dropped`);
113
+ continue;
114
+ }
115
+ const body = inlineImports(stripGeneratedHeader(raw), {
116
+ home,
117
+ baseDir: path.dirname(resolved),
118
+ level: level - 1,
119
+ warnings,
120
+ where: common.tildePath(resolved, home),
121
+ });
122
+ out.push(body.trim());
123
+ }
124
+ return out.join('\n');
125
+ }
126
+
127
+ function captureRules(paths, home, warnings) {
128
+ const raw = common.readText(paths.rulesFile);
129
+ if (raw == null) throw new Error(`Claude Code rules file not found: ${paths.rulesFile}`);
130
+ let body = inlineImports(raw, {
131
+ home,
132
+ baseDir: path.dirname(paths.rulesFile),
133
+ level: IMPORT_DEPTH,
134
+ warnings,
135
+ where: common.tildePath(paths.rulesFile, home),
136
+ });
137
+ body = body.replace(/^#\s*CLAUDE\.md[^\n]*\r?\n+/m, '');
138
+ return body.replace(/\n{3,}/g, '\n\n').trim();
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Hooks: settings.json -> the canonical dialect, copied as-is
143
+ // ---------------------------------------------------------------------------
144
+ function captureHooks(settings, warnings) {
145
+ const events = {};
146
+ const src = (settings && settings.hooks) || {};
147
+ if (Array.isArray(src) || typeof src !== 'object') {
148
+ warnings.push('settings.json: hooks is not an object; no hooks captured');
149
+ return events;
150
+ }
151
+ for (const [event, groups] of Object.entries(src)) {
152
+ if (!Array.isArray(groups)) {
153
+ warnings.push(`settings.json: hooks.${event} is not an array; skipped`);
154
+ continue;
155
+ }
156
+ const kept = [];
157
+ for (const group of groups) {
158
+ const handlers = [];
159
+ for (const h of (group && group.hooks) || []) {
160
+ if (!h || h.type !== 'command' || typeof h.command !== 'string' || !h.command.trim()) continue;
161
+ const out = { type: 'command', command: h.command };
162
+ if (h.timeout != null) out.timeout = h.timeout;
163
+ if (h.statusMessage) out.statusMessage = h.statusMessage;
164
+ if (h.async) out.async = true;
165
+ handlers.push(out);
166
+ }
167
+ if (!handlers.length) continue;
168
+ const out = {};
169
+ if (group.matcher != null && group.matcher !== '') out.matcher = group.matcher;
170
+ out.hooks = handlers;
171
+ kept.push(out);
172
+ }
173
+ if (kept.length) events[event] = kept;
174
+ }
175
+ return events;
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // MCP: ~/.claude.json (+ ~/.claude/.mcp.json) -> the neutral server shape
180
+ // ---------------------------------------------------------------------------
181
+ const TRANSPORTS = ['stdio', 'http', 'sse'];
182
+
183
+ function captureServers(source, into, warnings, origin) {
184
+ for (const [server, raw] of Object.entries(source || {})) {
185
+ if (!raw || typeof raw !== 'object') continue;
186
+ if (Object.prototype.hasOwnProperty.call(into, server)) {
187
+ warnings.push(`MCP server ${server}: also defined in ${origin}; the first definition wins`);
188
+ continue;
189
+ }
190
+ const declared = String(raw.type || '').toLowerCase();
191
+ const transport = TRANSPORTS.includes(declared) ? declared : (raw.url ? 'http' : 'stdio');
192
+ if (raw.type && !TRANSPORTS.includes(declared)) {
193
+ warnings.push(`MCP server ${server}: unknown type "${raw.type}"; captured as ${transport}`);
194
+ }
195
+ // A value that looks like a credential never enters the bundle: the bundle is
196
+ // copied into every client's config, so one token here becomes N tokens on disk.
197
+ const scrub = (obj, kind) => {
198
+ if (!obj || typeof obj !== 'object') return undefined;
199
+ const out = {};
200
+ for (const [k, v] of Object.entries(obj)) {
201
+ if (common.looksSecret(k, v)) {
202
+ out[k] = `\${${k}}`;
203
+ warnings.push(`MCP server ${server}: ${kind} ${k} looks like a secret; it stays out of the bundle, export ${k} in the environment of each client`);
204
+ } else {
205
+ out[k] = v;
206
+ }
207
+ }
208
+ return Object.keys(out).length ? out : undefined;
209
+ };
210
+ const entry = { transport };
211
+ if (transport === 'stdio') {
212
+ if (raw.command) entry.command = raw.command;
213
+ if (Array.isArray(raw.args) && raw.args.length) entry.args = raw.args.slice();
214
+ if (raw.cwd) entry.cwd = raw.cwd;
215
+ } else if (raw.url) {
216
+ entry.url = raw.url;
217
+ }
218
+ const env = scrub(raw.env, 'env');
219
+ if (env) entry.env = env;
220
+ const headers = scrub(raw.headers, 'header');
221
+ if (headers) entry.headers = headers;
222
+ into[server] = entry;
223
+ }
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Agents, commands, skills, permissions
228
+ // ---------------------------------------------------------------------------
229
+ function markdownFiles(dir, skip = []) {
230
+ let entries;
231
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return []; }
232
+ return entries
233
+ .filter((e) => !e.isDirectory() && e.name.endsWith('.md') && !skip.includes(e.name))
234
+ .map((e) => e.name)
235
+ .sort();
236
+ }
237
+
238
+ function captureAgents(dir, warnings) {
239
+ const agents = [];
240
+ for (const file of markdownFiles(dir)) {
241
+ const { meta, body } = common.parseFrontmatter(common.readText(path.join(dir, file)) || '');
242
+ const name = meta.name || file.replace(/\.md$/, '');
243
+ const tools = typeof meta.tools === 'string' ? meta.tools.trim() : '';
244
+ const toolList = tools ? tools.split(',').map((t) => t.trim()).filter(Boolean) : [];
245
+ // A missing `tools` field means "every tool", which includes the edit tools.
246
+ const readonly = toolList.length > 0 && !toolList.some((t) => EDIT_TOOLS.test(t));
247
+ if (!meta.description) warnings.push(`agent ${name}: no description in the frontmatter; the agent name is used instead`);
248
+ const out = {
249
+ name,
250
+ description: meta.description || name,
251
+ model: bundleLib.modelTier(meta.model || 'inherit'),
252
+ };
253
+ if (tools) out.tools = tools;
254
+ if (readonly) out.readonly = true; // omitted when false: `readonly: false` reloads as the string "false"
255
+ agents.push({ name, meta: out, body: String(body).trim() });
256
+ }
257
+ return agents;
258
+ }
259
+
260
+ function captureCommands(dir) {
261
+ const commands = [];
262
+ for (const file of markdownFiles(dir, ['README.md'])) {
263
+ const { meta, body } = common.parseFrontmatter(common.readText(path.join(dir, file)) || '');
264
+ const out = {};
265
+ if (meta.description) out.description = meta.description;
266
+ if (meta['argument-hint']) out['argument-hint'] = meta['argument-hint'];
267
+ commands.push({ name: file.replace(/\.md$/, ''), meta: out, body: String(body).trim() });
268
+ }
269
+ return commands;
270
+ }
271
+
272
+ function captureSkills(dir, warnings) {
273
+ const out = [];
274
+ for (const skill of common.listSkills(dir)) {
275
+ // Targets link the REAL directory: a chain of links through one client's
276
+ // skills dir dies the moment that client is uninstalled.
277
+ let real = skill.path;
278
+ try { real = fs.realpathSync(skill.path).replace(/^\\\\\?\\/, ''); } catch (err) {
279
+ warnings.push(`skill ${skill.name}: could not resolve its real directory (${err.message}); using ${skill.path}`);
280
+ }
281
+ out.push({ name: skill.name, path: real });
282
+ }
283
+ return out;
284
+ }
285
+
286
+ const stringList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
287
+
288
+ // ---------------------------------------------------------------------------
289
+ function capture({ home, target = {}, port } = {}) {
290
+ void port; // capture is complete; exclusions are a per-target apply decision
291
+ if (!home) throw new Error('capture({ home }) needs a home directory');
292
+ const warnings = [];
293
+ const paths = surfaces(home, target);
294
+ const b = bundleLib.createBundle(ID, home);
295
+
296
+ b.rules = captureRules(paths, home, warnings);
297
+
298
+ const identity = common.readText(paths.identityFile);
299
+ if (identity != null) b.identity = identity.trim();
300
+
301
+ const settings = common.readJSON(paths.settingsFile);
302
+ if (settings === undefined) warnings.push(`${common.tildePath(paths.settingsFile, home)} is not valid JSON; no hooks or permissions captured`);
303
+ b.hooks.events = captureHooks(settings, warnings);
304
+
305
+ const rootMcp = common.readJSON(paths.mcpFile);
306
+ if (rootMcp === undefined) warnings.push(`${common.tildePath(paths.mcpFile, home)} is not valid JSON; no MCP servers captured from it`);
307
+ captureServers(rootMcp && rootMcp.mcpServers, b.mcp.servers, warnings, common.tildePath(paths.mcpFile, home));
308
+ const projectMcp = common.readJSON(paths.projectMcpFile);
309
+ if (projectMcp === undefined) warnings.push(`${common.tildePath(paths.projectMcpFile, home)} is not valid JSON; skipped`);
310
+ captureServers(projectMcp && projectMcp.mcpServers, b.mcp.servers, warnings, common.tildePath(paths.projectMcpFile, home));
311
+
312
+ b.agents = captureAgents(paths.agentsDir, warnings);
313
+ b.commands = captureCommands(paths.commandsDir);
314
+ b.skills.sourceDir = paths.skillsDir;
315
+ b.skills.skills = captureSkills(paths.skillsDir, warnings);
316
+
317
+ const permissions = (settings && settings.permissions) || {};
318
+ b.permissions = {
319
+ allow: stringList(permissions.allow),
320
+ deny: stringList(permissions.deny),
321
+ ask: stringList(permissions.ask),
322
+ };
323
+
324
+ // Report, never throw: capture must show what it found even when one surface is
325
+ // malformed, or the operator cannot see which surface to fix.
326
+ for (const problem of bundleLib.validate(b)) warnings.push(`bundle: ${problem}`);
327
+ return { bundle: b, warnings };
328
+ }
329
+
330
+ module.exports = { id: ID, capture };
@@ -0,0 +1,314 @@
1
+ /**
2
+ * engine/harness/sources/codex.cjs — capture the Codex CLI harness into the bundle.
3
+ *
4
+ * Reads the live ~/.codex config, never a memory of it:
5
+ *
6
+ * AGENTS.md the agreement (a generated file is stripped back to its body)
7
+ * config.toml hooks.<Event> groups and mcp_servers.<name>
8
+ * agents/*.toml subagents (model slug -> the tier that owns it in core/port.json)
9
+ * prompts/*.md slash commands
10
+ * skills/ real skill dirs and links, resolved to their real paths
11
+ * rules/*.rules prefix_rule approvals -> Claude permission patterns
12
+ *
13
+ * Codex's hook dialect is a near clone of Claude Code's, which is the bundle's
14
+ * dialect, so hooks come across almost verbatim; only the tool matcher tokens
15
+ * differ (`apply_patch` is Codex's edit tool).
16
+ *
17
+ * Never throws on a missing optional surface. Throws only when AGENTS.md is
18
+ * missing, because without the agreement there is no harness to port.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const common = require('../common.cjs');
24
+ const bundleLib = require('../bundle.cjs');
25
+ const toml = require('../toml.cjs');
26
+
27
+ // Codex matcher token -> the Claude tool names the bundle speaks. null = drop.
28
+ const MATCHER_BACK = {
29
+ apply_patch: 'Edit|Write',
30
+ spawn_agent: null,
31
+ wait_agent: null,
32
+ };
33
+
34
+ const HANDLER_FIELDS = ['timeout', 'statusMessage', 'async'];
35
+
36
+ const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Rules and identity
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * A generated AGENTS.md is `<preamble> --- # The agreement <body> --- # Identity
44
+ * (from SOUL.md) <soul> <!-- generated ... -->`. Give back the two halves with
45
+ * the generated scaffolding removed; a hand-written AGENTS.md comes back whole.
46
+ */
47
+ function splitAgentsMd(text) {
48
+ const head = String(text).split(/\r?\n/).slice(0, 10).join('\n');
49
+ // Any generator's claim counts (this repo's, a host product's, the legacy sync): the scaffolding has one shape.
50
+ if (!/GENERATED by |harness-sync/i.test(head)) return { rules: String(text).trim(), identity: '' };
51
+
52
+ let body = String(text);
53
+ const agreement = body.search(/^# The agreement[ \t]*$/m);
54
+ if (agreement !== -1) body = body.slice(agreement).replace(/^# The agreement[ \t]*\r?\n/, '');
55
+
56
+ let identity = '';
57
+ const identityAt = body.search(/^# Identity\b/m);
58
+ if (identityAt !== -1) {
59
+ identity = body.slice(identityAt).replace(/^# Identity[^\n]*\r?\n/, '');
60
+ body = body.slice(0, identityAt);
61
+ }
62
+ const stamp = /<!--[^>]*generated by[^>]*-->/i;
63
+ identity = identity.replace(stamp, '');
64
+ body = body.replace(stamp, '');
65
+ // The generator separates the sections with a `---` rule; drop the trailing one.
66
+ const trim = (s) => s.replace(/^\s*(?:---[ \t]*\r?\n)?/, '').replace(/\s*(?:\r?\n---[ \t]*)?\s*$/, '').trim();
67
+ return { rules: trim(body), identity: trim(identity) };
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Hooks
72
+ // ---------------------------------------------------------------------------
73
+
74
+ function matcherBack(matcher) {
75
+ if (matcher == null || matcher === '' || matcher === '*') return undefined;
76
+ const out = [];
77
+ for (const token of String(matcher).split('|')) {
78
+ const mapped = Object.prototype.hasOwnProperty.call(MATCHER_BACK, token) ? MATCHER_BACK[token] : token;
79
+ if (!mapped) continue;
80
+ for (const t of String(mapped).split('|')) if (!out.includes(t)) out.push(t);
81
+ }
82
+ return out.length ? out.join('|') : null;
83
+ }
84
+
85
+ function captureHooks(config, warnings) {
86
+ const events = {};
87
+ for (const [event, groups] of Object.entries((config && config.hooks) || {})) {
88
+ // `hooks.state` holds Codex's trust table, not hook definitions.
89
+ if (!Array.isArray(groups)) continue;
90
+ for (const group of groups) {
91
+ if (!isPlainObject(group) || !Array.isArray(group.hooks)) continue;
92
+ const matcher = matcherBack(group.matcher);
93
+ if (matcher === null) { warnings.push(`hooks.${event} [${group.matcher}]: every Codex tool token is Codex-only; group skipped`); continue; }
94
+ const handlers = [];
95
+ for (const h of group.hooks) {
96
+ if (!isPlainObject(h) || h.type !== 'command' || typeof h.command !== 'string' || !h.command) continue;
97
+ const out = { type: 'command', command: h.command };
98
+ for (const f of HANDLER_FIELDS) if (h[f] !== undefined && h[f] !== null) out[f] = h[f];
99
+ handlers.push(out);
100
+ }
101
+ if (!handlers.length) continue;
102
+ const entry = matcher === undefined ? { hooks: handlers } : { matcher, hooks: handlers };
103
+ (events[event] = events[event] || []).push(entry);
104
+ }
105
+ }
106
+ return events;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // MCP servers
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function captureMcp(config, warnings) {
114
+ const servers = {};
115
+ for (const [name, raw] of Object.entries((config && config.mcp_servers) || {})) {
116
+ if (!isPlainObject(raw)) continue;
117
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) { warnings.push(`mcp server "${name}" is not a safe identifier; skipped`); continue; }
118
+
119
+ const scrub = (bag, kind) => {
120
+ const out = {};
121
+ for (const [k, v] of Object.entries(bag || {})) {
122
+ if (common.looksSecret(k, v)) {
123
+ out[k] = `\${${k}}`;
124
+ warnings.push(`mcp.${name}.${kind}.${k} looked like a live credential; captured as \${${k}} — export it yourself`);
125
+ } else {
126
+ out[k] = typeof v === 'string' ? v : String(v);
127
+ }
128
+ }
129
+ return out;
130
+ };
131
+
132
+ if (raw.url) {
133
+ const headers = scrub(raw.http_headers, 'headers');
134
+ // `env_http_headers` maps a header name to the env var holding its value.
135
+ for (const [header, envVar] of Object.entries(raw.env_http_headers || {})) headers[header] = `\${${envVar}}`;
136
+ const server = { transport: 'http', url: String(raw.url) };
137
+ if (Object.keys(headers).length) server.headers = headers;
138
+ servers[name] = server;
139
+ continue;
140
+ }
141
+ if (!raw.command) { warnings.push(`mcp.${name} has neither a command nor a url; skipped`); continue; }
142
+
143
+ const env = scrub(raw.env, 'env');
144
+ // `env_vars` names variables Codex forwards from the ambient environment.
145
+ for (const v of raw.env_vars || []) env[String(v)] = `\${${v}}`;
146
+ const server = { transport: 'stdio', command: String(raw.command) };
147
+ if (Array.isArray(raw.args) && raw.args.length) server.args = raw.args.map(String);
148
+ if (Object.keys(env).length) server.env = env;
149
+ if (raw.cwd) server.cwd = String(raw.cwd);
150
+ servers[name] = server;
151
+ }
152
+ return servers;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Agents
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /** The tier whose ladder entry in core/port.json owns this Codex model slug. */
160
+ function tierForModel(port, slug) {
161
+ const ladder = ((port.agents && port.agents.modelLadder) || {}).codex || {};
162
+ for (const [tier, entry] of Object.entries(ladder)) {
163
+ if (Array.isArray(entry) && String(entry[0]) === String(slug)) return tier;
164
+ }
165
+ return slug;
166
+ }
167
+
168
+ function captureAgents(dir, port, warnings) {
169
+ const agents = [];
170
+ let files;
171
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.toml')).sort(); } catch (_) { return agents; }
172
+ for (const file of files) {
173
+ const text = common.readText(path.join(dir, file));
174
+ if (text == null) continue;
175
+ const { data, warnings: tomlWarnings } = toml.parse(text);
176
+ for (const w of tomlWarnings) warnings.push(`agents/${file}: ${w}`);
177
+ const name = String(data.name || file.replace(/\.toml$/, ''));
178
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { warnings.push(`agent "${name}" (${file}) is not kebab-case; skipped`); continue; }
179
+
180
+ // The generator prefixes the instructions with a "Generated from ... by ..."
181
+ // paragraph describing the Codex port. That is scaffolding, not the role.
182
+ let body = String(data.developer_instructions || '').replace(/^\s+/, '');
183
+ if (/^Generated from /.test(body)) body = body.replace(/^Generated from [\s\S]*?(?:\r?\n[ \t]*\r?\n|$)/, '');
184
+
185
+ agents.push({
186
+ name,
187
+ meta: {
188
+ name,
189
+ description: String(data.description || name),
190
+ model: tierForModel(port, data.model || 'inherit'),
191
+ readonly: data.sandbox_mode === 'read-only' ? 'true' : undefined,
192
+ },
193
+ body: body.trim(),
194
+ });
195
+ }
196
+ return agents;
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Commands, skills, permissions
201
+ // ---------------------------------------------------------------------------
202
+
203
+ function captureCommands(dir) {
204
+ const commands = [];
205
+ let files;
206
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'README.md').sort(); } catch (_) { return commands; }
207
+ for (const file of files) {
208
+ const text = common.readText(path.join(dir, file));
209
+ if (text == null) continue;
210
+ const { meta, body } = common.parseFrontmatter(text);
211
+ const name = file.replace(/\.md$/, '');
212
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) continue;
213
+ commands.push({
214
+ name,
215
+ meta: { description: meta.description || name, 'argument-hint': meta['argument-hint'] },
216
+ body: body.trim(),
217
+ });
218
+ }
219
+ return commands;
220
+ }
221
+
222
+ function captureSkills(dir, warnings) {
223
+ const skills = [];
224
+ for (const skill of common.listSkills(dir)) {
225
+ if (!/^[A-Za-z0-9_.-]+$/.test(skill.name)) { warnings.push(`skill "${skill.name}" is not a safe directory name; skipped`); continue; }
226
+ let real = skill.path;
227
+ try { real = fs.realpathSync(skill.path); } catch (_) { /* keep the entry path */ }
228
+ skills.push({ name: skill.name, path: real });
229
+ }
230
+ return skills;
231
+ }
232
+
233
+ const RULE_DECISION = { allow: 'allow', forbidden: 'deny', prompt: 'ask' };
234
+ const ABSOLUTE = /^(?:[A-Za-z]:[\\/]|\/)/;
235
+
236
+ /**
237
+ * `prefix_rule(pattern=["git", "status"], decision="allow")` -> `Bash(git status *)`.
238
+ *
239
+ * Codex also records every one-off shell approval here, as an absolute path to
240
+ * the interpreter plus the whole command line. Those are per-invocation grants
241
+ * for one machine, not policy, and there are hundreds of them, so a pattern
242
+ * whose first token is an absolute path is counted and skipped.
243
+ */
244
+ function capturePermissions(dir, warnings) {
245
+ const permissions = { allow: [], deny: [], ask: [] };
246
+ let files;
247
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.rules')).sort(); } catch (_) { return permissions; }
248
+ let skippedAbsolute = 0;
249
+ for (const file of files) {
250
+ const text = common.readText(path.join(dir, file));
251
+ if (text == null) continue;
252
+ for (const line of text.split(/\r?\n/)) {
253
+ const m = /^prefix_rule\(pattern=(\[[\s\S]*\]),\s*decision="([a-z_]+)"\)\s*$/.exec(line.trim());
254
+ if (!m) continue;
255
+ const bucket = RULE_DECISION[m[2]];
256
+ if (!bucket) continue;
257
+ let pattern;
258
+ try { pattern = JSON.parse(m[1]); } catch (_) { continue; }
259
+ if (!Array.isArray(pattern) || !pattern.length || typeof pattern[0] !== 'string') continue;
260
+ if (ABSOLUTE.test(pattern[0])) { skippedAbsolute++; continue; }
261
+ const entry = `Bash(${pattern.join(' ')} *)`;
262
+ if (!permissions[bucket].includes(entry)) permissions[bucket].push(entry);
263
+ }
264
+ }
265
+ if (skippedAbsolute) {
266
+ warnings.push(`permissions: skipped ${skippedAbsolute} prefix_rule entr(ies) whose pattern starts with an absolute executable path (Codex records per-invocation shell approvals there; they are not portable policy)`);
267
+ }
268
+ return permissions;
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+
273
+ function capture({ home, target, port } = {}) {
274
+ const policy = port || {};
275
+ const codexHome = common.expandPath((target && target.home) || '~/.codex', home);
276
+ const at = (field, fallback) => common.expandPath((target && target[field]) || fallback, home);
277
+
278
+ const rulesFile = at('rulesFile', path.join(codexHome, 'AGENTS.md'));
279
+ const rulesText = common.readText(rulesFile);
280
+ if (rulesText == null) throw new Error(`Codex rules file not found: ${rulesFile}`);
281
+
282
+ const warnings = [];
283
+ const bundle = bundleLib.createBundle('codex', codexHome);
284
+
285
+ const split = splitAgentsMd(rulesText);
286
+ bundle.rules = split.rules;
287
+ bundle.identity = split.identity;
288
+
289
+ const configFile = at('hooksConfigFile', path.join(codexHome, 'config.toml'));
290
+ const configText = common.readText(configFile);
291
+ let config = {};
292
+ if (configText == null) warnings.push(`${common.tildePath(configFile, home)} not found; no hooks and no MCP servers captured`);
293
+ else {
294
+ const parsed = toml.parse(configText);
295
+ config = parsed.data;
296
+ for (const w of parsed.warnings) warnings.push(`config.toml: ${w}`);
297
+ }
298
+
299
+ bundle.hooks = { dialect: 'claude', events: captureHooks(config, warnings) };
300
+ bundle.mcp = { servers: captureMcp(config, warnings) };
301
+ bundle.agents = captureAgents(at('agentsDir', path.join(codexHome, 'agents')), policy, warnings);
302
+ bundle.commands = captureCommands(at('commandsDir', path.join(codexHome, 'prompts')));
303
+
304
+ const skillsDir = at('skillsDir', path.join(codexHome, 'skills'));
305
+ bundle.skills = { sourceDir: skillsDir, skills: captureSkills(skillsDir, warnings) };
306
+
307
+ const permissionsFile = (target && target.permissionsFile) ? common.expandPath(target.permissionsFile, home) : path.join(codexHome, 'rules', 'x.rules');
308
+ bundle.permissions = capturePermissions(path.dirname(permissionsFile), warnings);
309
+
310
+ bundle.manifest.components = bundleLib.counts(bundle);
311
+ return { bundle, warnings };
312
+ }
313
+
314
+ module.exports = { id: 'codex', capture, splitAgentsMd, matcherBack, tierForModel };