@nurix/etna 0.19.64 → 0.19.65
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/bin/etna.js +155 -557
- package/data/harness-index.json +1 -1
- package/data/harnesses/apollo/harness.json +1 -1
- package/data/harnesses/apollo/hooks/approved-packages.md +2 -0
- package/data/harnesses/apollo/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/code/harness.json +1 -1
- package/data/harnesses/code/hooks/approved-packages.md +2 -0
- package/data/harnesses/code/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/delivery/harness.json +1 -1
- package/data/harnesses/delivery/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/etna/harness.json +1 -1
- package/data/harnesses/etna/skills/documentation/references/style-guide.md +34 -1
- package/lib/install/materialize.mjs +402 -0
- package/lib/install/plan.mjs +271 -0
- package/package.json +2 -2
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// packages/cli/lib/install/plan.mjs
|
|
2
|
+
// THE HARNESS COMPILER: one bundled harness definition in, the exact file list a fresh install
|
|
3
|
+
// produces out. Pure — it reads the bundle and returns data, and writes nothing.
|
|
4
|
+
//
|
|
5
|
+
// This is the single authority on WHERE a harness's files land and WHAT is in them. Both shippers
|
|
6
|
+
// consume it and neither re-derives it:
|
|
7
|
+
//
|
|
8
|
+
// push-harnesses.mjs → publishes the array to Apollo verbatim
|
|
9
|
+
// materializeHarness → writes the array to a project, applying each entry's policy
|
|
10
|
+
//
|
|
11
|
+
// They diverged once, and that is the whole reason this file exists. The Apollo push used to walk
|
|
12
|
+
// the BUNDLE directory and send those paths, so a project downloaded from Apollo Studio extracted
|
|
13
|
+
// with its instruction tree at the archive root where no runtime looks for it, no AGENTS.md, and no
|
|
14
|
+
// hooks wired into settings.json — while the export manifest claimed the folder was ready to open.
|
|
15
|
+
// Nothing failed: every file was present, only its location was wrong.
|
|
16
|
+
//
|
|
17
|
+
// PURE, AND THAT IS THE POINT. Deriving the payload by installing into a temp directory and reading
|
|
18
|
+
// it back works, but it is disk I/O to compute a mapping — and it hides the mapping inside a
|
|
19
|
+
// sequence of file operations where no test can see it. As data, the mapping is a value you can
|
|
20
|
+
// assert on.
|
|
21
|
+
//
|
|
22
|
+
// EVERY ENTRY CARRIES A POLICY, because "what a fresh install produces" and "what a refresh writes
|
|
23
|
+
// over an existing project" differ for exactly two kinds of file. A payload flattens all three (an
|
|
24
|
+
// empty target takes every write); an install honours them.
|
|
25
|
+
//
|
|
26
|
+
// own — harness-owned. Overwritten on every refresh.
|
|
27
|
+
// seed — one-time scaffolding. Written only where the path is ABSENT; once present it is the
|
|
28
|
+
// project's living copy, never refreshed and never pruned.
|
|
29
|
+
// merge — folded into whatever the project already has, key by key. Only `.claude/settings.json`,
|
|
30
|
+
// which carries the user's own hooks and permissions beside the harness's.
|
|
31
|
+
|
|
32
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
33
|
+
import { join, relative, sep } from 'node:path';
|
|
34
|
+
import { buildCodexAdapter } from '../codex/agents-md.mjs';
|
|
35
|
+
import { toCodexCommand } from '../codex/hooks.mjs';
|
|
36
|
+
|
|
37
|
+
// Subfolders bundled inside each harness payload that map 1:1 onto <project>/.claude/<name>/.
|
|
38
|
+
// Order matches Claude Code's own folder layout. hooks/ carries the hook scripts + lib/ (and
|
|
39
|
+
// hooks.json, shipped harmlessly — the settings merge reads it from the bundle, not this copy).
|
|
40
|
+
export const PLUGIN_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
|
|
41
|
+
|
|
42
|
+
// Component subdirs whose membership is tracked so a removed-from-the-harness component can be
|
|
43
|
+
// pruned on refresh. Hooks are excluded — retireOldHooks owns them, because it must also prune the
|
|
44
|
+
// matching settings.json entry, which a name-only diff cannot reach.
|
|
45
|
+
export const PRUNABLE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts'];
|
|
46
|
+
|
|
47
|
+
// Codex targets rebuilt wholesale on every install rather than merged, so a refresh must delete
|
|
48
|
+
// them first. Recorded in harness.json's `owned.codex` so a later version can prune one it drops.
|
|
49
|
+
export const CODEX_REBUILT_DIRS = ['.agents/skills', '.codex/agents'];
|
|
50
|
+
|
|
51
|
+
// Every file under dir, as posix paths relative to it. Posix regardless of host, because these
|
|
52
|
+
// become ZIP entry names on Apollo's side and a backslash is a legal filename character on the
|
|
53
|
+
// systems that extract them.
|
|
54
|
+
function filesUnder(dir, prefix = '') {
|
|
55
|
+
const out = [];
|
|
56
|
+
if (!existsSync(dir)) return out;
|
|
57
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
58
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
59
|
+
if (entry.isDirectory()) out.push(...filesUnder(join(dir, entry.name), rel));
|
|
60
|
+
else if (entry.isFile()) out.push(rel);
|
|
61
|
+
// A dirent that is neither is almost certainly a symlink. Refusing beats skipping: Apollo
|
|
62
|
+
// stores content, so there is no link to follow on the other side, and a silently short
|
|
63
|
+
// payload is the exact failure mode this module exists to prevent.
|
|
64
|
+
else
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${join(dir, entry.name)} is neither a file nor a directory — a harness may not ship one`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One bundle path → one or more entries. A `files:`/`seeds:` entry may legally name a directory
|
|
73
|
+
// (the installer copies it with `recursive: true`), so a single declared path can expand.
|
|
74
|
+
function entriesFor(bundleDir, rel, policy) {
|
|
75
|
+
const source = join(bundleDir, rel);
|
|
76
|
+
if (!existsSync(source)) return [];
|
|
77
|
+
if (statSync(source).isDirectory()) {
|
|
78
|
+
return filesUnder(source).map((child) => ({
|
|
79
|
+
path: `${rel}/${child}`,
|
|
80
|
+
content: readFileSync(join(source, child), 'utf8'),
|
|
81
|
+
policy,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
return [{ path: rel.split(sep).join('/'), content: readFileSync(source, 'utf8'), policy }];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2-space indent + trailing newline matches Claude Code's own writes to settings.json, and the
|
|
88
|
+
// installed bytes must equal the payload's bytes or the two shippers disagree on content.
|
|
89
|
+
function asJson(value) {
|
|
90
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sorted base names of *.<ext> files directly under dir (e.g. rules/*.md → rule names).
|
|
94
|
+
function listByExt(dir, ext) {
|
|
95
|
+
if (!existsSync(dir)) return [];
|
|
96
|
+
return readdirSync(dir)
|
|
97
|
+
.filter((f) => f.endsWith(ext))
|
|
98
|
+
.map((f) => f.slice(0, -ext.length))
|
|
99
|
+
.sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The manifest of what THIS harness owns, per prunable subdir — raw top-level entry names, which
|
|
103
|
+
// is what the next refresh diffs against to find components the harness has since dropped.
|
|
104
|
+
function bundledOwned(bundleDir) {
|
|
105
|
+
const owned = {};
|
|
106
|
+
for (const sub of PRUNABLE_SUBDIRS) {
|
|
107
|
+
const dir = join(bundleDir, sub);
|
|
108
|
+
owned[sub] = existsSync(dir) ? readdirSync(dir).sort() : [];
|
|
109
|
+
}
|
|
110
|
+
return owned;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `.claude/settings.json` as a FRESH install writes it: the bundle's hooks.json folded into an
|
|
114
|
+
// empty settings object. `hooks` is created before the permissions loop, so it comes first in key
|
|
115
|
+
// order — and key order is part of the bytes, so it is part of the contract.
|
|
116
|
+
function freshSettings(bundleDir) {
|
|
117
|
+
const hooksJson = join(bundleDir, 'hooks', 'hooks.json');
|
|
118
|
+
if (!existsSync(hooksJson)) return null;
|
|
119
|
+
const bundled = JSON.parse(readFileSync(hooksJson, 'utf8'));
|
|
120
|
+
const settings = { hooks: {} };
|
|
121
|
+
for (const rule of bundled.permissions?.allow || []) {
|
|
122
|
+
settings.permissions = settings.permissions || {};
|
|
123
|
+
settings.permissions.allow = settings.permissions.allow || [];
|
|
124
|
+
if (!settings.permissions.allow.includes(rule)) settings.permissions.allow.push(rule);
|
|
125
|
+
}
|
|
126
|
+
for (const [event, groups] of Object.entries(bundled.hooks || {})) {
|
|
127
|
+
settings.hooks[event] = groups;
|
|
128
|
+
}
|
|
129
|
+
if (!Object.keys(settings.hooks).length && !settings.permissions) return null;
|
|
130
|
+
return asJson(settings);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// `.codex/hooks.json` — the Claude hooks.json filtered to type:"command", the only kind Codex
|
|
134
|
+
// executes, with each command prefixed so `$CLAUDE_PROJECT_DIR` resolves under a runtime that does
|
|
135
|
+
// not export it. The translation itself is `toCodexCommand`, imported rather than restated.
|
|
136
|
+
function codexHooks(bundleDir) {
|
|
137
|
+
const hooksJson = join(bundleDir, 'hooks', 'hooks.json');
|
|
138
|
+
if (!existsSync(hooksJson)) return null;
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(readFileSync(hooksJson, 'utf8'));
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const [event, groups] of Object.entries(parsed.hooks || {})) {
|
|
147
|
+
const kept = [];
|
|
148
|
+
for (const group of groups) {
|
|
149
|
+
const hooks = (group.hooks || [])
|
|
150
|
+
.filter((h) => h.type === 'command')
|
|
151
|
+
.map((h) => ({ ...h, command: toCodexCommand(h.command) }));
|
|
152
|
+
if (hooks.length) kept.push({ ...group, hooks });
|
|
153
|
+
}
|
|
154
|
+
if (kept.length) out[event] = kept;
|
|
155
|
+
}
|
|
156
|
+
return Object.keys(out).length ? asJson({ hooks: out }) : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compile one bundled harness into the file list a fresh install produces.
|
|
161
|
+
*
|
|
162
|
+
* Returns `{ files, owned, meta }` — `files` sorted by path, each with a policy; `owned` the
|
|
163
|
+
* manifest that goes into `.claude/harness.json`; `meta` the parsed bundle marker.
|
|
164
|
+
*
|
|
165
|
+
* Throws when the bundle has no CLAUDE.md: that is the one file every harness must carry, and a
|
|
166
|
+
* harness without a policy is not a harness.
|
|
167
|
+
*/
|
|
168
|
+
export function planHarnessFiles(bundleDir) {
|
|
169
|
+
const claudeMdPath = join(bundleDir, 'CLAUDE.md');
|
|
170
|
+
if (!existsSync(claudeMdPath)) {
|
|
171
|
+
throw new Error(`Bundled CLAUDE.md missing at ${claudeMdPath}. Rebuild package data.`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const files = [];
|
|
175
|
+
const claudeMd = readFileSync(claudeMdPath, 'utf8');
|
|
176
|
+
files.push({ path: 'CLAUDE.md', content: claudeMd, policy: 'own' });
|
|
177
|
+
|
|
178
|
+
// The component subtrees. This is the mapping the whole module exists for: a bundle says
|
|
179
|
+
// `skills/design/SKILL.md`, a project says `.claude/skills/design/SKILL.md`.
|
|
180
|
+
for (const sub of PLUGIN_SUBDIRS) {
|
|
181
|
+
for (const rel of filesUnder(join(bundleDir, sub))) {
|
|
182
|
+
files.push({
|
|
183
|
+
path: `.claude/${sub}/${rel}`,
|
|
184
|
+
content: readFileSync(join(bundleDir, sub, rel), 'utf8'),
|
|
185
|
+
policy: 'own',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Dual-runtime: the Codex artifacts the CLI derives at install time. A ZIP extract runs no
|
|
191
|
+
// installer, so if these are not in the payload a downloaded project has no Codex support at
|
|
192
|
+
// all and no way to generate it.
|
|
193
|
+
const owned = bundledOwned(bundleDir);
|
|
194
|
+
const codexOwned = [];
|
|
195
|
+
|
|
196
|
+
const adapter = buildCodexAdapter(bundleDir);
|
|
197
|
+
const separator = claudeMd.endsWith('\n') ? '\n' : '\n\n';
|
|
198
|
+
files.push({
|
|
199
|
+
path: 'AGENTS.md',
|
|
200
|
+
content: adapter ? claudeMd + separator + adapter : claudeMd,
|
|
201
|
+
policy: 'own',
|
|
202
|
+
});
|
|
203
|
+
codexOwned.push('AGENTS.md');
|
|
204
|
+
|
|
205
|
+
// `.agents/skills/` is Codex's official scan path and a byte mirror of `.claude/skills/`. Both
|
|
206
|
+
// runtimes require exactly name+description in SKILL.md frontmatter, so the mirrored files are
|
|
207
|
+
// valid for Codex unchanged.
|
|
208
|
+
const skillFiles = filesUnder(join(bundleDir, 'skills'));
|
|
209
|
+
if (skillFiles.length) {
|
|
210
|
+
for (const rel of skillFiles) {
|
|
211
|
+
files.push({
|
|
212
|
+
path: `.agents/skills/${rel}`,
|
|
213
|
+
content: readFileSync(join(bundleDir, 'skills', rel), 'utf8'),
|
|
214
|
+
policy: 'own',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
codexOwned.push('.agents/skills');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const tomls = listByExt(join(bundleDir, 'codex', 'agents'), '.toml');
|
|
221
|
+
if (tomls.length) {
|
|
222
|
+
for (const name of tomls) {
|
|
223
|
+
files.push({
|
|
224
|
+
path: `.codex/agents/${name}.toml`,
|
|
225
|
+
content: readFileSync(join(bundleDir, 'codex', 'agents', `${name}.toml`), 'utf8'),
|
|
226
|
+
policy: 'own',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
codexOwned.push('.codex/agents');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const hooks = codexHooks(bundleDir);
|
|
233
|
+
if (hooks) {
|
|
234
|
+
files.push({ path: '.codex/hooks.json', content: hooks, policy: 'own' });
|
|
235
|
+
codexOwned.push('.codex/hooks.json');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
owned.codex = codexOwned;
|
|
239
|
+
|
|
240
|
+
// The marker, at the path the CLI writes and the maintenance agent resolves the installed
|
|
241
|
+
// version from — never at the bundle root, where nothing reads it.
|
|
242
|
+
const markerPath = join(bundleDir, 'harness.json');
|
|
243
|
+
const meta = existsSync(markerPath) ? JSON.parse(readFileSync(markerPath, 'utf8')) : {};
|
|
244
|
+
if (existsSync(markerPath)) {
|
|
245
|
+
files.push({
|
|
246
|
+
path: '.claude/harness.json',
|
|
247
|
+
content: asJson({ ...meta, owned }),
|
|
248
|
+
policy: 'own',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Project-root config the harness owns outright (.prettierrc.json, .vscode/settings.json, …),
|
|
253
|
+
// then the one-time scaffolding it seeds. Same landing, opposite ownership.
|
|
254
|
+
for (const rel of meta.files || []) files.push(...entriesFor(bundleDir, rel, 'own'));
|
|
255
|
+
for (const rel of meta.seeds || []) files.push(...entriesFor(bundleDir, rel, 'seed'));
|
|
256
|
+
|
|
257
|
+
const settings = freshSettings(bundleDir);
|
|
258
|
+
if (settings) files.push({ path: '.claude/settings.json', content: settings, policy: 'merge' });
|
|
259
|
+
|
|
260
|
+
// Sorted so the plan is byte-stable across machines — readdir order is not guaranteed, and an
|
|
261
|
+
// unstable order would make two identical builds look different to anything comparing them.
|
|
262
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
263
|
+
|
|
264
|
+
const seen = new Set();
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
if (seen.has(file.path)) throw new Error(`the harness plans ${file.path} twice`);
|
|
267
|
+
seen.add(file.path);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { files, owned, meta };
|
|
271
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.65",
|
|
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": {
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"agent",
|
|
43
43
|
"plugin"
|
|
44
44
|
],
|
|
45
|
-
"etnaFingerprint": "
|
|
45
|
+
"etnaFingerprint": "b0d947332bf0"
|
|
46
46
|
}
|