@ucsandman/legcli 0.8.0 → 0.9.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/CHANGELOG.md +45 -0
- package/NOTICE +8 -0
- package/README.md +601 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +21 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +71 -0
- package/docs/README.md +2 -0
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +21 -0
- package/docs/board-guide.md +13 -0
- package/docs/cli-contracts.md +22 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +22 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +1 -2
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +75 -19
- package/src/auth.mjs +2 -2
- package/src/board/board.js +3 -3
- package/src/board/sessions.js +77 -3
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- package/src/hook.mjs +49 -49
- package/src/land.mjs +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +1 -1
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +38 -10
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +6 -3
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +170 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +5 -4
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- 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 };
|