@nurix/etna 0.19.12 → 0.19.14

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 (30) hide show
  1. package/README.md +8 -6
  2. package/bin/etna.js +18 -0
  3. package/data/harness-index.json +1 -1
  4. package/data/harnesses/code/agents/maintenance.md +1 -1
  5. package/data/harnesses/code/codex/agents/brainstorm.toml +142 -0
  6. package/data/harnesses/code/codex/agents/change-review.toml +250 -0
  7. package/data/harnesses/code/codex/agents/lld-mode.toml +52 -0
  8. package/data/harnesses/code/codex/agents/maintenance.toml +216 -0
  9. package/data/harnesses/code/codex/agents/performance-review.toml +222 -0
  10. package/data/harnesses/code/harness.json +1 -1
  11. package/data/harnesses/discussion/agents/discussion-baseline.md +1 -1
  12. package/data/harnesses/discussion/harness.json +1 -1
  13. package/data/harnesses/etna/agents/maintenance.md +1 -1
  14. package/data/harnesses/etna/codex/agents/brainstorm.toml +142 -0
  15. package/data/harnesses/etna/codex/agents/change-review.toml +250 -0
  16. package/data/harnesses/etna/codex/agents/lld-mode.toml +52 -0
  17. package/data/harnesses/etna/codex/agents/maintenance.toml +216 -0
  18. package/data/harnesses/etna/codex/agents/performance-review.toml +222 -0
  19. package/data/harnesses/etna/harness.json +1 -1
  20. package/data/harnesses/hr/CLAUDE.md +1 -1
  21. package/data/harnesses/hr/harness.json +1 -1
  22. package/lib/codex/README.md +31 -0
  23. package/lib/codex/agents-md.mjs +35 -0
  24. package/lib/codex/gen-agents.mjs +156 -0
  25. package/lib/codex/hooks.mjs +33 -0
  26. package/lib/codex/index.mjs +62 -0
  27. package/lib/codex/plugin-manifest.mjs +36 -0
  28. package/lib/codex/skills.mjs +26 -0
  29. package/lib/codex/subagents.mjs +26 -0
  30. package/package.json +4 -2
@@ -0,0 +1,222 @@
1
+ # dropped Claude tools: Read, Glob, Grep, Bash
2
+ name = "performance-review"
3
+ description = "Combined dependency + code health audit. Part A audits the dependency supply chain — security CVEs, outdated packages, license compliance, abandoned packages, unwanted transitive dependencies, install size. Part B audits code-level health — bundle size, tree-shaking issues, performance anti-patterns, code-quality flags. Run weekly or before releases. Merges the former `dep-health` and `review` agents so the package tree and the code that uses it are audited together."
4
+ developer_instructions = '''
5
+
6
+ # Performance-Review Agent
7
+
8
+ You audit project health on two fronts that reinforce each other: the **dependency supply chain** (Part A) and the **code that consumes it** (Part B). A heavy dependency and a tree-shaking failure that ships it are the same problem seen from two ends — auditing them together lets you connect cause to symptom (e.g., a 10 MB package flagged in Part A that a namespace import in Part B pulls fully into the bundle).
9
+
10
+ ## Before Starting
11
+
12
+ 1. If `./docs/memory/performance-review.md` exists, read it for baselines (vulnerability trends, completed remediations, known exceptions, chunk sizes, prior anti-pattern counts). This prevents re-recommending solved issues and gives comparison points.
13
+ 2. Read `./package.json` for direct dependencies, entry points, and scripts.
14
+ 3. Detect the package manager by checking for lockfiles: `pnpm-lock.yaml` (pnpm), `package-lock.json` (npm), `yarn.lock` (yarn). Use the matching commands throughout.
15
+ 4. If `.claude/rules/general.instructions.md` (or `.claude/rules/general.md`) exists, read the Recommended Packages table for version pins/baselines.
16
+
17
+ ---
18
+
19
+ # Part A — Dependency Health
20
+
21
+ ## Step 1: Security Vulnerabilities
22
+
23
+ Run the audit command for the detected PM:
24
+
25
+ ```bash
26
+ # pnpm
27
+ pnpm audit --json 2>/dev/null || pnpm audit
28
+
29
+ # npm
30
+ npm audit --json 2>/dev/null || npm audit
31
+
32
+ # yarn
33
+ yarn npm audit --json 2>/dev/null || yarn audit
34
+ ```
35
+
36
+ Parse results by severity (critical, high, moderate, low). For each critical/high CVE report:
37
+
38
+ - Package name and installed version
39
+ - CVE ID
40
+ - Affected version range
41
+ - Fixed version (if available)
42
+ - Which direct dependency pulls it in
43
+
44
+ Compare against the previous scan in `docs/memory/performance-review.md` — flag new vulnerabilities since last audit.
45
+
46
+ ## Step 2: Outdated Dependencies
47
+
48
+ ```bash
49
+ pnpm outdated --format json 2>/dev/null || pnpm outdated
50
+ # or: npm outdated --json
51
+ # or: yarn outdated
52
+ ```
53
+
54
+ Classify into:
55
+
56
+ - **Major behind** (2+ majors) — highest risk
57
+ - **Minor behind** — moderate risk
58
+ - **Patch behind** — low risk
59
+
60
+ Cross-reference against the Recommended Packages table in the project rules — flag packages where the installed version is behind the pinned version. Report the top 10 most outdated by semver distance.
61
+
62
+ ## Step 3: License Compliance
63
+
64
+ ```bash
65
+ pnpm licenses list --json 2>/dev/null || pnpm licenses list
66
+ # npm: npx license-checker --json
67
+ ```
68
+
69
+ Flag non-permissive licenses in the dependency tree: GPL, AGPL, SSPL, EUPL, or other copyleft. For each: package name, license type, direct or transitive.
70
+
71
+ ## Step 4: Abandoned Packages
72
+
73
+ For each direct dependency, check npm registry metadata:
74
+
75
+ ```bash
76
+ npm view {package} time --json 2>/dev/null
77
+ ```
78
+
79
+ Flag packages with no publish in 18+ months. Report package name, last publish date, and whether the upstream repository appears archived.
80
+
81
+ ## Step 5: Unwanted Transitive Dependencies
82
+
83
+ Check for known-bloat packages in the dependency tree:
84
+
85
+ - `moment` (use date-fns instead)
86
+ - `lodash` (use subpath imports)
87
+ - `underscore`
88
+ - `request` (deprecated)
89
+ - `node-fetch` (when runtime has native fetch)
90
+
91
+ For each found:
92
+
93
+ ```bash
94
+ pnpm why {package}
95
+ # or: npm ls {package}
96
+ ```
97
+
98
+ Report which direct dependency pulls it in and the installed size. Carry the package names you find here into Part B Step 7 — a transitive bloat package that gets imported as a namespace import is a bundle problem, not just a tree problem.
99
+
100
+ ## Step 6: Dependency Size Impact
101
+
102
+ ```bash
103
+ du -sh node_modules/.pnpm/*/ 2>/dev/null | sort -rh | head -15
104
+ # or for npm: du -sh node_modules/*/ | sort -rh | head -15
105
+ ```
106
+
107
+ List the 15 largest packages by installed size. Flag any single package over 10 MB. Compare against the previous snapshot.
108
+
109
+ ---
110
+
111
+ # Part B — Code Health
112
+
113
+ ## Step 7: Bundle Size Analysis
114
+
115
+ 1. Run the build (`pnpm build` / `npm run build` / `yarn build` per detected PM) to generate the production bundle
116
+ 2. Parse build output to extract chunk sizes (raw and gzip)
117
+ 3. Identify the top 10 largest chunks by raw size
118
+ 4. Compare against known baselines in `docs/memory/performance-review.md`
119
+ 5. Flag any chunk that grew >50 KB since last review
120
+
121
+ ### Common bundle-bloat patterns
122
+
123
+ Search for anti-patterns that defeat tree-shaking:
124
+
125
+ - `import * as` namespace imports from large libraries (lucide-react, lodash, etc.)
126
+ - Dynamic string-based icon/component resolution (`Icons[name]`, `Components[type]`)
127
+ - Barrel file re-exports pulling in unused code
128
+ - Missing `React.lazy()` for heavy components (Monaco, Uppy, D3, Plotly, Mermaid)
129
+ - Full library imports where subpath imports exist (`import _ from 'lodash'` vs `import get from 'lodash/get'`)
130
+
131
+ ```bash
132
+ # Find namespace imports from large libraries
133
+ grep -rn "import \* as" src/ --include="*.jsx" --include="*.tsx" --include="*.js" --include="*.ts"
134
+
135
+ # Find dynamic component/icon resolution
136
+ grep -rn "Icons\[" src/ --include="*.jsx" --include="*.tsx"
137
+ ```
138
+
139
+ ## Step 8: Performance Anti-Patterns
140
+
141
+ Scan for React performance issues:
142
+
143
+ ### Missing memoization
144
+
145
+ - Components rendering large lists without `React.memo`
146
+ - Expensive computations without `useMemo`
147
+ - Callback props without `useCallback` causing child re-renders
148
+ - Focus on components in tables, lists, grids, repeated rows
149
+
150
+ ### Request waterfalls
151
+
152
+ - Sequential `await` calls that could be `Promise.all`
153
+ - `useEffect` chains where one fetch triggers another
154
+ - Component trees where parent fetches block child renders
155
+
156
+ ### Missing virtualization
157
+
158
+ - Lists rendering >50 items without react-window or similar
159
+ - Tables with unbounded row counts
160
+ - Grids without lazy loading
161
+
162
+ ## Step 9: Import Health
163
+
164
+ 1. Check for circular dependencies using import chains
165
+ 2. Identify unused exports (files exporting functions not imported elsewhere)
166
+
167
+ ## Step 10: Code-Quality Flags
168
+
169
+ - `dangerouslySetInnerHTML` usage without sanitization context
170
+ - `// TODO` and `// FIXME` comments older than 30 days
171
+ - `console.log` / `console.warn` / `console.error` left in production code
172
+ - Hardcoded API URLs, credentials, or environment-specific values
173
+ - Missing error boundaries around async components
174
+
175
+ ```bash
176
+ grep -rn "console\.\(log\|warn\|error\)" src/ --include="*.jsx" --include="*.tsx" --include="*.js" --include="*.ts" | grep -v "// eslint"
177
+ grep -rn "TODO\|FIXME\|HACK\|XXX" src/ --include="*.jsx" --include="*.tsx" --include="*.js" --include="*.ts"
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Step 11: Report & Track
183
+
184
+ ### Report format
185
+
186
+ **Dependencies**
187
+
188
+ - **Vulnerabilities**: critical/high CVEs, new since last audit
189
+ - **Outdated**: top outdated packages by semver distance
190
+ - **Licenses**: non-permissive license flags
191
+ - **Abandoned**: packages with no recent publishes
192
+ - **Transitive bloat**: unwanted packages pulled in transitively
193
+ - **Dependency size**: largest installed packages
194
+
195
+ **Code**
196
+
197
+ - **Bundle size**: total vendor size, top offenders, growth since last review
198
+ - **Tree-shaking issues**: namespace imports, barrel-file problems (cross-referenced with the heavy/bloat packages from Part A)
199
+ - **Performance**: missing memoization, request waterfalls, missing virtualization
200
+ - **Code quality**: console statements, TODOs, hardcoded values
201
+
202
+ **Recommendations**: a single prioritized list spanning both fronts, with estimated impact. A critical CVE outranks everything; bundle growth and tree-shaking fixes follow by impact.
203
+
204
+ ### Update tracking
205
+
206
+ 1. Write a snapshot to `docs/memory/performance-review.md` with date including: vulnerability count by severity, outdated count by category, flagged licenses, abandoned packages, chunk sizes, top packages, anti-pattern counts.
207
+ 2. Append new items to `docs/history/backlog.md`:
208
+ - `- **[P0]** Critical CVE in \`{package}\`: {CVE-ID} — discovered by performance-review`
209
+ - `- **[P2]** Abandoned dependency: \`{package}\` (last published {date}) — discovered by performance-review`
210
+ - `- **[P2]** Bundle grew by 80KB in chunk \`{name}\` — discovered by performance-review`
211
+ 3. Report summary to the user
212
+
213
+ ## Rules
214
+
215
+ - Read-only for source code — never modify `src/` files
216
+ - Never run `npm install`, `pnpm install`, `yarn install`, or any command that modifies dependencies
217
+ - May write to `docs/memory/performance-review.md` and `docs/history/backlog.md`
218
+ - Be specific: include package names, byte sizes, CVE IDs, version numbers, file paths, line numbers
219
+ - Prioritize by impact — a critical CVE matters more than a patch update; trends matter more than absolutes when baselines exist
220
+ - If a command isn't available or applicable (e.g., audit unsupported, build fails on a non-JS project), skip that step and note it in the report
221
+ - A clean report is valid — do not invent issues
222
+ '''
@@ -7,5 +7,5 @@
7
7
  ".prettierignore",
8
8
  ".vscode/settings.json"
9
9
  ],
10
- "digest": "ac886b2bc4b1"
10
+ "digest": "e4e80329bda9"
11
11
  }
@@ -21,7 +21,7 @@ Pick the mode from what the user hands you:
21
21
 
22
22
  ## Step 0 — first run
23
23
 
24
- If the `interview-eval` skill isn't resolvable (no `.claude/skills/interview-eval/`), this project isn't set up — tell the user to run `npx @nurix/etna --name=hr` (or point you at the skill) before continuing. Confirm `python3` is on PATH; the extractor is stdlib-only and needs nothing else.
24
+ If the `interview-eval` skill isn't resolvable (no `.claude/skills/interview-eval/`), this project isn't set up — tell the user to run `npx --yes @nurix/etna --name=hr` (or point you at the skill) before continuing. Confirm `python3` is on PATH; the extractor is stdlib-only and needs nothing else.
25
25
 
26
26
  ## Step 1 — intake interview
27
27
 
@@ -7,5 +7,5 @@
7
7
  ".prettierignore",
8
8
  ".vscode/settings.json"
9
9
  ],
10
- "digest": "6d11004d6849"
10
+ "digest": "1d5cfc7eb67e"
11
11
  }
@@ -0,0 +1,31 @@
1
+ # `lib/codex` — Claude→Codex projection
2
+
3
+ Turns a harness's already-materialized Claude artifacts into their OpenAI Codex counterparts, so one harnessed repo works under **both** Claude Code and Codex. Consumed by both shippers — the installer (`bin/etna.js`) and the marketplace publisher (`.github/scripts/publish.mjs`) — through one module, so the two channels can't drift (the same "keep in agreement" rule as the `evals/`/`fixtures/` ship-filter).
4
+
5
+ Owning doc: [`docs/features/codex-support.md`](../../../../docs/features/codex-support.md).
6
+
7
+ ## What it emits
8
+
9
+ | Source | Target | File |
10
+ | --- | --- | --- |
11
+ | `CLAUDE.md` | `AGENTS.md` (exact copy) | `agents-md.mjs` |
12
+ | `.claude/skills/` | `.agents/skills/` (mirror) | `skills.mjs` |
13
+ | bundle `codex/agents/*.toml` | `.codex/agents/*.toml` (copy) | `subagents.mjs` |
14
+ | `hooks.json` | `.codex/hooks.json` (`type:"command"` only) | `hooks.mjs` |
15
+ | `.claude-plugin/*` | `.codex-plugin/plugin.json` + `.agents/plugins/marketplace.json` | `plugin-manifest.mjs` |
16
+
17
+ `index.mjs` orchestrates the install-time projectors and returns `{installed, notices, warnings, owned}`; `owned` is recorded in `.claude/harness.json` → `owned.codex` for prune-on-refresh.
18
+
19
+ **Out of scope (owner decision 2026-07-13):** MCP projection and any Codex `settings.json`/`config.toml` equivalent. There is no user-editable Codex file the harness co-owns, so every Codex path is a clean full-overwrite — no managed-block merge.
20
+
21
+ ## Subagents are authored, not derived at install
22
+
23
+ `.claude/agents/*.md → .codex/agents/*.toml` is lossy/semantic, so it is done by the **real Codex CLI** at authoring time, not a parser at install:
24
+
25
+ ```bash
26
+ node packages/cli/lib/codex/gen-agents.mjs # shared catalog (agents/*.md), stale/missing only
27
+ node packages/cli/lib/codex/gen-agents.mjs --force # regenerate all shared agents
28
+ node packages/cli/lib/codex/gen-agents.mjs --include-plugins # + plugins/*/agents/*.md
29
+ ```
30
+
31
+ `gen-agents.mjs` runs `codex exec` per agent and writes a committed `agents/<name>.codex.toml` sibling. It runs **only on a laptop with `codex` installed + authenticated — never in CI**. `build-data.js` and `publish.mjs` then ship those committed TOML; the installer copies them, so **consumers need no Codex CLI**. Regenerate an agent's TOML in the same change that edits its `.md` (source↔generated parity).
@@ -0,0 +1,35 @@
1
+ // packages/cli/lib/codex/agents-md.mjs
2
+ // CLAUDE.md → AGENTS.md — an exact copy (Codex's confirmed CLAUDE.md analog).
3
+ // Mirrors the installer's CLAUDE.md safety net: overwrite in place when git can
4
+ // recover it, otherwise back up the existing file and warn.
5
+
6
+ import { existsSync, lstatSync, renameSync, cpSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ function isSymlink(path) {
10
+ try {
11
+ return lstatSync(path).isSymbolicLink();
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export function projectAgentsMd({ targetDir, bundledClaudeMd, projectHasGit }) {
18
+ if (!existsSync(bundledClaudeMd)) return null;
19
+ const dest = join(targetDir, 'AGENTS.md');
20
+ const existed = existsSync(dest) || isSymlink(dest);
21
+ let notice = null;
22
+ let warning = null;
23
+ if (existed) {
24
+ if (projectHasGit) {
25
+ notice = 'Overwrote existing AGENTS.md (tracked by git — `git checkout AGENTS.md` to restore).';
26
+ } else {
27
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
28
+ const backup = `${dest}.bak.${ts}`;
29
+ renameSync(dest, backup);
30
+ warning = `No .git here, so no version-control safety net — backed up existing AGENTS.md → ${backup}`;
31
+ }
32
+ }
33
+ cpSync(bundledClaudeMd, dest, { force: true });
34
+ return { installed: 'AGENTS.md → ./', notice, warning };
35
+ }
@@ -0,0 +1,156 @@
1
+ // packages/cli/lib/codex/gen-agents.mjs
2
+ // AUTHORING-TIME generator — runs ON A LAPTOP with the Codex CLI installed and
3
+ // authenticated, NEVER in CI. For each Claude subagent (agents/<name>.md in the
4
+ // shared catalog, plus plugins/*/agents/*.md with --include-plugins) it runs
5
+ // `codex exec` to translate the markdown into an OpenAI Codex subagent TOML and
6
+ // writes a committed sibling agents/<name>.codex.toml. The shippers (build-data.js,
7
+ // publish.mjs) then carry those committed files; the installer copies them into a
8
+ // consumer's .codex/agents/ with no Codex CLI required downstream.
9
+ //
10
+ // Usage:
11
+ // node packages/cli/lib/codex/gen-agents.mjs # shared catalog, only stale/missing
12
+ // node packages/cli/lib/codex/gen-agents.mjs --force # regenerate all shared agents
13
+ // node packages/cli/lib/codex/gen-agents.mjs --include-plugins # + plugins/*/agents/*.md
14
+
15
+ import {
16
+ existsSync,
17
+ readdirSync,
18
+ readFileSync,
19
+ writeFileSync,
20
+ statSync,
21
+ } from 'node:fs';
22
+ import { dirname, join, resolve, basename } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { spawnSync } from 'node:child_process';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const repoRoot = resolve(__dirname, '..', '..', '..', '..');
28
+
29
+ // Pinned conversion prompt — deterministic instructions so re-runs stay stable.
30
+ // Codex does the semantic md→toml; the body is copied verbatim, tools are dropped
31
+ // (no Codex equivalent) and recorded in a leading comment.
32
+ const CONVERSION_PROMPT = [
33
+ 'Convert the Claude Code subagent definition provided on stdin into an OpenAI Codex subagent TOML document.',
34
+ 'Output ONLY the raw TOML — no prose, no markdown code fences, nothing before or after.',
35
+ 'Set `name` from the frontmatter `name`.',
36
+ 'Set `description` as a TOML basic string (double quotes) from the frontmatter `description`, escaping any interior double-quote as \\".',
37
+ 'Put the ENTIRE markdown body (everything after the closing frontmatter ---) VERBATIM into a TOML multi-line',
38
+ "LITERAL string delimited by triple SINGLE quotes: developer_instructions = '''<body>'''.",
39
+ 'A literal string is verbatim — do NOT escape anything inside it, and do NOT use triple double-quotes (backslashes like \\s in the body must survive unchanged).',
40
+ 'If frontmatter `model` is a real model id, set `model`; if it is `inherit`, omit `model`.',
41
+ 'If frontmatter has a `tools:` line, do NOT translate it — instead put a single comment line',
42
+ '`# dropped Claude tools: <the list>` as the first line of the file.',
43
+ 'Emit no keys other than name, description, model (optional), developer_instructions.',
44
+ ].join(' ');
45
+
46
+ const args = process.argv.slice(2);
47
+ const force = args.includes('--force');
48
+ const includePlugins = args.includes('--include-plugins');
49
+
50
+ function die(msg) {
51
+ console.error(`gen-codex-agents: ${msg}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ // Codex must be present + authenticated — this step cannot run in CI.
56
+ function assertCodexReady() {
57
+ const which = spawnSync('codex', ['--version'], { encoding: 'utf8' });
58
+ if (which.status !== 0) {
59
+ die('the `codex` CLI is not installed / not on PATH. This generator runs only on an authoring laptop.');
60
+ }
61
+ }
62
+
63
+ // Every agents/<name>.md to convert: the shared catalog, plus plugin-owned agents on request.
64
+ function collectAgentFiles() {
65
+ const files = [];
66
+ const shared = join(repoRoot, 'agents');
67
+ if (existsSync(shared)) {
68
+ for (const f of readdirSync(shared)) {
69
+ if (f.endsWith('.md')) files.push(join(shared, f));
70
+ }
71
+ }
72
+ if (includePlugins) {
73
+ const pluginsDir = join(repoRoot, 'plugins');
74
+ if (existsSync(pluginsDir)) {
75
+ for (const plugin of readdirSync(pluginsDir)) {
76
+ const agentsDir = join(pluginsDir, plugin, 'agents');
77
+ if (!existsSync(agentsDir)) continue;
78
+ for (const f of readdirSync(agentsDir)) {
79
+ if (f.endsWith('.md')) files.push(join(agentsDir, f));
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return files.sort();
85
+ }
86
+
87
+ // A regen is due when forced, the toml is missing, or the md is newer than the toml.
88
+ function needsRegen(mdPath, tomlPath) {
89
+ if (force || !existsSync(tomlPath)) return true;
90
+ return statSync(mdPath).mtimeMs > statSync(tomlPath).mtimeMs;
91
+ }
92
+
93
+ // Strip an accidental ```toml fence if the model wrapped its output despite the prompt.
94
+ function stripFences(text) {
95
+ const trimmed = text.trim();
96
+ const fence = trimmed.match(/^```(?:toml)?\n([\s\S]*?)\n```$/);
97
+ return (fence ? fence[1] : trimmed) + '\n';
98
+ }
99
+
100
+ // Validate that the produced TOML parses. Uses python3's tomllib when present
101
+ // (returns true/error string); returns null when python3 is unavailable so the
102
+ // caller proceeds best-effort rather than hard-failing on a missing dev tool.
103
+ function tomlError(toml) {
104
+ const res = spawnSync('python3', ['-c', 'import tomllib,sys; tomllib.loads(sys.stdin.read())'], {
105
+ input: toml,
106
+ encoding: 'utf8',
107
+ });
108
+ if (res.error && res.error.code === 'ENOENT') return null;
109
+ return res.status === 0 ? '' : (res.stderr || 'invalid TOML').trim();
110
+ }
111
+
112
+ function convert(mdPath) {
113
+ const md = readFileSync(mdPath, 'utf8');
114
+ const res = spawnSync('codex', ['exec', '--sandbox', 'read-only', CONVERSION_PROMPT], {
115
+ input: md,
116
+ encoding: 'utf8',
117
+ maxBuffer: 32 * 1024 * 1024,
118
+ });
119
+ if (res.status !== 0) {
120
+ throw new Error(`codex exec failed for ${mdPath}: ${(res.stderr || '').slice(-400)}`);
121
+ }
122
+ const toml = stripFences(res.stdout || '');
123
+ if (!/^\s*name\s*=/m.test(toml)) {
124
+ throw new Error(`codex output for ${mdPath} has no \`name =\` key — refusing to write. Got:\n${toml.slice(0, 200)}`);
125
+ }
126
+ const err = tomlError(toml);
127
+ if (err) {
128
+ throw new Error(`codex output for ${mdPath} is not valid TOML — refusing to write:\n${err.slice(0, 300)}`);
129
+ }
130
+ return toml;
131
+ }
132
+
133
+ assertCodexReady();
134
+ const files = collectAgentFiles();
135
+ if (!files.length) die('no agents/*.md found to convert.');
136
+
137
+ let wrote = 0;
138
+ let skipped = 0;
139
+ for (const mdPath of files) {
140
+ const name = basename(mdPath, '.md');
141
+ const tomlPath = join(dirname(mdPath), `${name}.codex.toml`);
142
+ if (!needsRegen(mdPath, tomlPath)) {
143
+ skipped += 1;
144
+ continue;
145
+ }
146
+ process.stdout.write(`converting ${name} … `);
147
+ try {
148
+ writeFileSync(tomlPath, convert(mdPath));
149
+ wrote += 1;
150
+ console.log('ok');
151
+ } catch (e) {
152
+ console.log('FAILED');
153
+ console.error(` ${e.message}`);
154
+ }
155
+ }
156
+ console.log(`\ngen-codex-agents: wrote ${wrote}, skipped ${skipped} (up-to-date) of ${files.length} agent(s).`);
@@ -0,0 +1,33 @@
1
+ // packages/cli/lib/codex/hooks.mjs
2
+ // .claude/hooks/hooks.json → .codex/hooks.json. Same JSON shape Codex documents;
3
+ // filtered to type:"command" (the only hook type Codex executes — "prompt"/"agent"
4
+ // entries are parsed but skipped). A standalone file, not a config.toml [hooks]
5
+ // table, since the settings/config surface is out of scope for Codex here.
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+
10
+ export function projectHooks({ targetDir, bundledHarnessDir }) {
11
+ const srcJson = join(bundledHarnessDir, 'hooks', 'hooks.json');
12
+ if (!existsSync(srcJson)) return null;
13
+ let parsed;
14
+ try {
15
+ parsed = JSON.parse(readFileSync(srcJson, 'utf8'));
16
+ } catch {
17
+ return null;
18
+ }
19
+ const out = {};
20
+ for (const [event, groups] of Object.entries(parsed.hooks || {})) {
21
+ const kept = [];
22
+ for (const group of groups) {
23
+ const hooks = (group.hooks || []).filter((h) => h.type === 'command');
24
+ if (hooks.length) kept.push({ ...group, hooks });
25
+ }
26
+ if (kept.length) out[event] = kept;
27
+ }
28
+ if (!Object.keys(out).length) return null;
29
+ const dest = join(targetDir, '.codex', 'hooks.json');
30
+ mkdirSync(dirname(dest), { recursive: true });
31
+ writeFileSync(dest, JSON.stringify({ hooks: out }, null, 2) + '\n');
32
+ return { installed: '.codex/hooks.json' };
33
+ }
@@ -0,0 +1,62 @@
1
+ // packages/cli/lib/codex/index.mjs
2
+ // Claude→Codex projection entry. Derives a repo's Codex-native artifacts
3
+ // (AGENTS.md, .agents/skills/, .codex/agents/, .codex/hooks.json) from the
4
+ // just-installed Claude tree + the harness bundle. Consumed by BOTH shippers —
5
+ // the installer (bin/etna.js) and the marketplace publisher (publish.mjs) — so
6
+ // the two distribution channels can never diverge (the same "keep in agreement"
7
+ // discipline as the evals/fixtures ship-filter). MCP and a settings/config.toml
8
+ // equivalent are deliberately OUT of scope (owner decision 2026-07-13); see
9
+ // docs/features/codex-support.md.
10
+
11
+ import { existsSync, rmSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { projectAgentsMd } from './agents-md.mjs';
14
+ import { projectSkills } from './skills.mjs';
15
+ import { copyCodexAgents } from './subagents.mjs';
16
+ import { projectHooks } from './hooks.mjs';
17
+
18
+ // Project every Codex artifact into targetDir. Returns the same {installed,
19
+ // notices, warnings} accounting shape the installer already collects, plus
20
+ // `owned` — the top-level Codex targets, recorded in harness.json's
21
+ // `owned.codex` so a later version can prune one it stops shipping.
22
+ export function projectCodexArtifacts({
23
+ targetDir,
24
+ bundledHarnessDir,
25
+ bundledClaudeMd,
26
+ projectHasGit,
27
+ priorCodexOwned = [],
28
+ }) {
29
+ const installed = [];
30
+ const notices = [];
31
+ const warnings = [];
32
+ const owned = [];
33
+
34
+ const collect = (result, ownedPath) => {
35
+ if (!result) return;
36
+ if (result.installed) installed.push(result.installed);
37
+ if (result.notice) notices.push(result.notice);
38
+ if (result.warning) warnings.push(result.warning);
39
+ owned.push(ownedPath);
40
+ };
41
+
42
+ // AGENTS.md — exact copy of CLAUDE.md, with the CLAUDE.md backup-if-no-git rule.
43
+ collect(projectAgentsMd({ targetDir, bundledClaudeMd, projectHasGit }), 'AGENTS.md');
44
+ // .agents/skills — full-rebuilt mirror of the installed (already ship-filtered) .claude/skills.
45
+ collect(projectSkills({ targetDir, projectHasGit }), '.agents/skills');
46
+ // .codex/agents — copy the shipped, pre-generated *.toml (see gen-agents.mjs).
47
+ collect(copyCodexAgents({ targetDir, bundledHarnessDir, projectHasGit }), '.codex/agents');
48
+ // .codex/hooks.json — Claude hooks.json filtered to type:"command".
49
+ collect(projectHooks({ targetDir, bundledHarnessDir }), '.codex/hooks.json');
50
+
51
+ // Prune a Codex target a prior version owned but this bundle no longer emits.
52
+ for (const rel of priorCodexOwned) {
53
+ if (owned.includes(rel)) continue;
54
+ const target = join(targetDir, rel);
55
+ if (existsSync(target)) {
56
+ rmSync(target, { recursive: true, force: true });
57
+ notices.push(`removed orphaned codex ${rel}`);
58
+ }
59
+ }
60
+
61
+ return { installed, notices, warnings, owned };
62
+ }
@@ -0,0 +1,36 @@
1
+ // packages/cli/lib/codex/plugin-manifest.mjs
2
+ // Pure builders for the Codex plugin + marketplace manifests, emitted by the
3
+ // marketplace shipper (.github/scripts/publish.mjs) alongside the Claude ones.
4
+ // A Codex plugin reuses the same skills/ and hooks/ dirs as a Claude plugin — only
5
+ // the manifest differs (.codex-plugin/plugin.json) — plus a Codex marketplace
6
+ // catalog (.agents/plugins/marketplace.json). Codex also reads the legacy
7
+ // .claude-plugin/marketplace.json but for DISCOVERY only, so native ones are still emitted.
8
+
9
+ // The required Codex plugin.json fields + component pointers. `hasHooks` adds the
10
+ // hooks pointer (Codex auto-detects hooks/hooks.json, but naming it is explicit).
11
+ export function buildCodexPluginJson(fm, { hasHooks = false } = {}) {
12
+ const out = {
13
+ name: fm.name,
14
+ version: fm.version,
15
+ description: fm.description,
16
+ skills: './skills/',
17
+ };
18
+ if (hasHooks) out.hooks = './hooks/hooks.json';
19
+ return out;
20
+ }
21
+
22
+ // The repo marketplace catalog Codex reads at .agents/plugins/marketplace.json.
23
+ // Each plugin is a local source; the first keyword (if any) seeds a category.
24
+ export function buildCodexMarketplace(plugins) {
25
+ return {
26
+ name: 'nurixlabs',
27
+ interface: { displayName: 'Nurix Labs' },
28
+ plugins: [...plugins]
29
+ .sort((a, b) => a.name.localeCompare(b.name))
30
+ .map((p) => ({
31
+ name: p.name,
32
+ source: `./plugins/${p.name}`,
33
+ ...(p.frontmatter?.keywords?.length ? { category: p.frontmatter.keywords[0] } : {}),
34
+ })),
35
+ };
36
+ }
@@ -0,0 +1,26 @@
1
+ // packages/cli/lib/codex/skills.mjs
2
+ // .claude/skills/ → .agents/skills/ — Codex's official skill-scan path. A full
3
+ // rebuild each run from the installed .claude/skills tree, which the bundle
4
+ // already ship-filtered (evals/, fixtures/ stripped at build time), so this is a
5
+ // straight recursive copy. Both runtimes require exactly name+description in
6
+ // SKILL.md frontmatter, so the mirrored files are valid for Codex unchanged.
7
+
8
+ import { existsSync, cpSync, rmSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ export function projectSkills({ targetDir, projectHasGit }) {
12
+ const src = join(targetDir, '.claude', 'skills');
13
+ if (!existsSync(src)) return null;
14
+ const dest = join(targetDir, '.agents', 'skills');
15
+ let warning = null;
16
+ if (existsSync(dest)) {
17
+ if (!projectHasGit) {
18
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
19
+ cpSync(dest, `${dest}.bak.${ts}`, { recursive: true });
20
+ warning = 'No .git here — snapshotted existing .agents/skills/ before rebuild.';
21
+ }
22
+ rmSync(dest, { recursive: true, force: true });
23
+ }
24
+ cpSync(src, dest, { recursive: true });
25
+ return { installed: '.agents/skills/ (mirror of .claude/skills)', warning };
26
+ }
@@ -0,0 +1,26 @@
1
+ // packages/cli/lib/codex/subagents.mjs
2
+ // Copy the harness's pre-generated Codex subagent TOML — bundle codex/agents/*.toml,
3
+ // authored on a Codex-authenticated laptop by gen-agents.mjs — into the project's
4
+ // .codex/agents/. Full rebuild each run. The consumer needs NO Codex CLI: the TOML
5
+ // ships ready, so a missing local Codex never blocks an install.
6
+
7
+ import { existsSync, readdirSync, cpSync, rmSync, mkdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ export function copyCodexAgents({ targetDir, bundledHarnessDir, projectHasGit }) {
11
+ const src = join(bundledHarnessDir, 'codex', 'agents');
12
+ if (!existsSync(src)) return null;
13
+ const tomls = readdirSync(src).filter((f) => f.endsWith('.toml'));
14
+ if (!tomls.length) return null;
15
+ const dest = join(targetDir, '.codex', 'agents');
16
+ if (existsSync(dest)) {
17
+ if (!projectHasGit) {
18
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
19
+ cpSync(dest, `${dest}.bak.${ts}`, { recursive: true });
20
+ }
21
+ rmSync(dest, { recursive: true, force: true });
22
+ }
23
+ mkdirSync(dest, { recursive: true });
24
+ for (const f of tomls) cpSync(join(src, f), join(dest, f), { force: true });
25
+ return { installed: `${tomls.length} codex agent(s) → .codex/agents/` };
26
+ }