@nurix/etna 0.19.13 → 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.
- package/README.md +2 -2
- package/bin/etna.js +18 -0
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/codex/agents/brainstorm.toml +142 -0
- package/data/harnesses/code/codex/agents/change-review.toml +250 -0
- package/data/harnesses/code/codex/agents/lld-mode.toml +52 -0
- package/data/harnesses/code/codex/agents/maintenance.toml +216 -0
- package/data/harnesses/code/codex/agents/performance-review.toml +222 -0
- package/data/harnesses/code/harness.json +1 -1
- package/data/harnesses/etna/codex/agents/brainstorm.toml +142 -0
- package/data/harnesses/etna/codex/agents/change-review.toml +250 -0
- package/data/harnesses/etna/codex/agents/lld-mode.toml +52 -0
- package/data/harnesses/etna/codex/agents/maintenance.toml +216 -0
- package/data/harnesses/etna/codex/agents/performance-review.toml +222 -0
- package/data/harnesses/etna/harness.json +1 -1
- package/lib/codex/README.md +31 -0
- package/lib/codex/agents-md.mjs +35 -0
- package/lib/codex/gen-agents.mjs +156 -0
- package/lib/codex/hooks.mjs +33 -0
- package/lib/codex/index.mjs +62 -0
- package/lib/codex/plugin-manifest.mjs +36 -0
- package/lib/codex/skills.mjs +26 -0
- package/lib/codex/subagents.mjs +26 -0
- 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
|
+
'''
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.14",
|
|
4
4
|
"description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin/",
|
|
14
|
+
"lib/",
|
|
14
15
|
"data/",
|
|
15
16
|
"README.md"
|
|
16
17
|
],
|
|
@@ -19,7 +20,8 @@
|
|
|
19
20
|
},
|
|
20
21
|
"scripts": {
|
|
21
22
|
"build:data": "node scripts/build-data.js",
|
|
22
|
-
"prepack": "node scripts/build-data.js"
|
|
23
|
+
"prepack": "node scripts/build-data.js",
|
|
24
|
+
"gen:codex-agents": "node lib/codex/gen-agents.mjs"
|
|
23
25
|
},
|
|
24
26
|
"repository": {
|
|
25
27
|
"type": "git",
|