@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.
@@ -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.64",
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": "6b7cd94d8ed9"
45
+ "etnaFingerprint": "b0d947332bf0"
46
46
  }