@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 CHANGED
@@ -14,559 +14,157 @@
14
14
  // Harnesses are addressed by their harness/<folder> name; several harnesses may
15
15
  // share one plugin.
16
16
 
17
- import {
18
- existsSync,
19
- mkdirSync,
20
- readFileSync,
21
- writeFileSync,
22
- renameSync,
23
- cpSync,
24
- readdirSync,
25
- lstatSync,
26
- rmSync,
27
- } from 'node:fs';
17
+ import { existsSync } from 'node:fs';
28
18
  import { join, dirname, resolve } from 'node:path';
29
19
  import { fileURLToPath } from 'node:url';
30
20
  import { spawnSync } from 'node:child_process';
31
21
  import * as p from '@clack/prompts';
32
- import { projectCodexArtifacts } from '../lib/codex/index.mjs';
22
+ import { materializeHarness, readJson as readJsonOrThrow } from '../lib/install/materialize.mjs';
23
+
24
+ // WHERE A HARNESS'S FILES LAND is not decided here — `lib/install/materialize.mjs` owns that
25
+ // mapping, because the Apollo push ships the same tree and the two must never diverge. This
26
+ // file owns the CLI around it: argument parsing, the interactive pick, and the reporting.
33
27
 
34
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
35
29
  const pkgRoot = resolve(__dirname, '..');
36
30
  const dataDir = join(pkgRoot, 'data');
37
31
 
38
- // Subfolders bundled inside each harness payload that map 1:1 onto
39
- // <project>/.claude/<name>/. Order matches Claude Code's own folder layout.
40
- // hooks/ carries the hook scripts + lib/ (and hooks.json, copied harmlessly —
41
- // the settings merge reads it from the bundle, not the project copy).
42
- const PLUGIN_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
43
-
44
32
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
45
33
 
46
34
  // True when both stdin and stdout are a terminal — clack prompts can't resolve
47
35
  // otherwise (CI, piped stdin), so the no-arg pick must fall back to an error.
48
36
  function isInteractive() {
49
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
37
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
50
38
  }
51
39
 
52
40
  // Boxed list of installable harnesses (name + description), for --help and error paths.
53
41
  function printHarnessList(harnesses) {
54
- const entries = Object.entries(harnesses);
55
- if (!entries.length) {
56
- p.log.warn('No harnesses are bundled. Run `npm run build:data` from the package root.');
57
- return;
58
- }
59
- const width = Math.max(...entries.map(([folder]) => folder.length));
60
- const lines = entries.map(
61
- ([folder, meta]) => `${folder.padEnd(width)}${meta?.description ? ` ${meta.description}` : ''}`,
62
- );
63
- p.note(lines.join('\n'), 'Available harnesses');
42
+ const entries = Object.entries(harnesses);
43
+ if (!entries.length) {
44
+ p.log.warn('No harnesses are bundled. Run `npm run build:data` from the package root.');
45
+ return;
46
+ }
47
+ const width = Math.max(...entries.map(([folder]) => folder.length));
48
+ const lines = entries.map(
49
+ ([folder, meta]) =>
50
+ `${folder.padEnd(width)}${meta?.description ? ` ${meta.description}` : ''}`,
51
+ );
52
+ p.note(lines.join('\n'), 'Available harnesses');
64
53
  }
65
54
 
66
55
  function loadHarnessIndex() {
67
- const path = join(dataDir, 'harness-index.json');
68
- if (!existsSync(path)) {
69
- die('harness-index.json missing — run `npm run build:data` from the package root.');
70
- }
71
- return JSON.parse(readFileSync(path, 'utf8'));
56
+ const path = join(dataDir, 'harness-index.json');
57
+ if (!existsSync(path)) {
58
+ die('harness-index.json missing — run `npm run build:data` from the package root.');
59
+ }
60
+ return readJson(path);
72
61
  }
73
62
 
74
63
  function die(msg, code = 1) {
75
- p.log.error(msg);
76
- process.exit(code);
77
- }
78
-
79
- function isSymlink(path) {
80
- try {
81
- return lstatSync(path).isSymbolicLink();
82
- } catch {
83
- return false;
84
- }
85
- }
86
-
87
- // Move <path> to <path>.bak.<timestamp> if it exists. Returns the backup path, or null.
88
- function backupIfExists(path) {
89
- if (!existsSync(path) && !isSymlink(path)) return null;
90
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
91
- const backup = `${path}.bak.${ts}`;
92
- renameSync(path, backup);
93
- return backup;
94
- }
95
-
96
- // Copy a directory to <path>.bak.<timestamp>, leaving the original in place. Returns the
97
- // backup path, or null. Used for .claude/, which is refreshed in place (not overwritten
98
- // wholesale), so the live tree must survive for the in-place update — snapshot, don't move.
99
- function snapshotDirIfExists(path) {
100
- if (!existsSync(path)) return null;
101
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
102
- const backup = `${path}.bak.${ts}`;
103
- cpSync(path, backup, { recursive: true });
104
- return backup;
64
+ p.log.error(msg);
65
+ process.exit(code);
105
66
  }
106
67
 
68
+ // The shared reader throws so it can serve the CI push too; the CLI turns that into its own
69
+ // one-line death rather than a stack trace.
107
70
  function readJson(path) {
108
- if (!existsSync(path)) return {};
109
- try {
110
- return JSON.parse(readFileSync(path, 'utf8'));
111
- } catch (e) {
112
- die(`failed to parse ${path}: ${e.message}`);
113
- }
114
- }
115
-
116
- // 2-space indent matches Claude Code's own writes to settings.json.
117
- function writeJson(path, value) {
118
- mkdirSync(dirname(path), { recursive: true });
119
- writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
120
- }
121
-
122
- // Copy the harness's bundled .claude/<subdir>/ contents into <project>/.claude/<subdir>/,
123
- // merging with whatever's already there. Existing same-named entries are overwritten so
124
- // re-running `etna` always refreshes harness-provided content. Returns count copied.
125
- function copyHarnessSubdir(bundledSubdir, projectSubdir) {
126
- if (!existsSync(bundledSubdir)) return 0;
127
- mkdirSync(projectSubdir, { recursive: true });
128
- let copied = 0;
129
- for (const entry of readdirSync(bundledSubdir, { withFileTypes: true })) {
130
- const src = join(bundledSubdir, entry.name);
131
- const dest = join(projectSubdir, entry.name);
132
- cpSync(src, dest, { recursive: true, force: true });
133
- copied += 1;
134
- }
135
- return copied;
136
- }
137
-
138
- // Clean up any leftover marketplace/enabledPlugins entries from a previous 0.1.x
139
- // install. After 0.2.0 we don't use the marketplace flow at all — leaving stale
140
- // entries causes Claude Code to also resolve the plugin from the global cache,
141
- // double-loading the same skills under a different namespace.
142
- function migrateFromPluginInstall(targetDir, pluginName) {
143
- const removed = [];
144
- const projectSettingsPath = join(targetDir, '.claude', 'settings.json');
145
- if (existsSync(projectSettingsPath)) {
146
- const s = readJson(projectSettingsPath);
147
- if (s.enabledPlugins) {
148
- const keys = Object.keys(s.enabledPlugins).filter((k) =>
149
- k.startsWith(`${pluginName}@`),
150
- );
151
- for (const k of keys) {
152
- delete s.enabledPlugins[k];
153
- removed.push(`enabledPlugins["${k}"] in ${projectSettingsPath}`);
154
- }
155
- if (Object.keys(s.enabledPlugins).length === 0) delete s.enabledPlugins;
156
- writeJson(projectSettingsPath, s);
157
- }
158
- }
159
- return removed;
160
- }
161
-
162
- // Hook scripts shipped by earlier harness versions (the legacy memory-ledger tier) and since retired.
163
- // mergeHooks is additive, so an upgrade must explicitly remove both the stale settings.json
164
- // entries pointing at these and the leftover script files they reference.
165
- const RETIRED_HOOKS = [
166
- 'session-stop.js',
167
- 'post-tool-failure.js',
168
- 'post-commit.js',
169
- 'generate-ownership-index.mjs', // folded into awareness.mjs — unretired, the index would inject twice
170
- ];
171
-
172
- // Remove settings.json hook entries and .claude/hooks/ files for retired hook scripts.
173
- // Bundle-aware: a name on the retired list is skipped while THIS bundle still ships it —
174
- // several harnesses share the list, and they retire the same script at different versions.
175
- function retireOldHooks(targetDir, bundledHarnessDir) {
176
- const removed = [];
177
- const bundledHooksDir = join(bundledHarnessDir, 'hooks');
178
- const stillShipped = new Set(
179
- existsSync(bundledHooksDir)
180
- ? readdirSync(bundledHooksDir, { withFileTypes: true })
181
- .filter((e) => e.isFile())
182
- .map((e) => e.name)
183
- : [],
184
- );
185
- const retired = RETIRED_HOOKS.filter((name) => !stillShipped.has(name));
186
- const settingsPath = join(targetDir, '.claude', 'settings.json');
187
- if (existsSync(settingsPath)) {
188
- const settings = readJson(settingsPath);
189
- const isRetired = (h) =>
190
- retired.some((name) => (h.command || '').includes(`/hooks/${name}`));
191
- let pruned = 0;
192
- for (const [event, groups] of Object.entries(settings.hooks || {})) {
193
- for (const group of groups) {
194
- const before = (group.hooks || []).length;
195
- group.hooks = (group.hooks || []).filter((h) => !isRetired(h));
196
- pruned += before - group.hooks.length;
197
- }
198
- settings.hooks[event] = groups.filter((g) => (g.hooks || []).length > 0);
199
- if (settings.hooks[event].length === 0) delete settings.hooks[event];
200
- }
201
- if (pruned > 0) {
202
- writeJson(settingsPath, settings);
203
- removed.push(`${pruned} retired hook entr${pruned === 1 ? 'y' : 'ies'} in settings.json`);
204
- }
205
- }
206
- for (const name of retired) {
207
- const file = join(targetDir, '.claude', 'hooks', name);
208
- if (existsSync(file)) {
209
- rmSync(file, { force: true });
210
- removed.push(`stale hook script ${name}`);
211
- }
212
- }
213
- return removed;
214
- }
215
-
216
- // Component subdirs whose membership is tracked so a removed-from-the-harness component
217
- // can be pruned on refresh. Hooks are excluded — retireOldHooks owns them (it must also
218
- // prune the matching settings.json entry, which a name-only diff can't reach).
219
- const PRUNABLE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts'];
220
-
221
- // The set of component names the bundle ships, per prunable subdir — the manifest of what
222
- // THIS harness owns. Written into the installed harness.json so the next refresh can diff.
223
- function bundledOwned(bundledHarnessDir) {
224
- const owned = {};
225
- for (const sub of PRUNABLE_SUBDIRS) {
226
- const dir = join(bundledHarnessDir, sub);
227
- owned[sub] = existsSync(dir) ? readdirSync(dir).sort() : [];
228
- }
229
- return owned;
230
- }
231
-
232
- // Read the `owned` manifest the PREVIOUS install recorded in the project's harness.json.
233
- // Absent (first install, or pre-pruning version) ⇒ {} ⇒ nothing is pruned this round.
234
- function readInstalledOwned(targetDir) {
235
- const path = join(targetDir, '.claude', 'harness.json');
236
- if (!existsSync(path)) return {};
237
- const owned = readJson(path).owned;
238
- return owned && typeof owned === 'object' ? owned : {};
239
- }
240
-
241
- // Remove vendored components a prior harness version owned but this one no longer ships
242
- // (e.g. a retired agent like harness-manager). Only names present in the PRIOR `owned`
243
- // manifest are eligible, so a project's own components — never in that manifest — survive.
244
- function pruneOrphanedComponents(targetDir, priorOwned, newOwned) {
245
- const removed = [];
246
- for (const sub of PRUNABLE_SUBDIRS) {
247
- const had = priorOwned[sub] || [];
248
- const has = new Set(newOwned[sub] || []);
249
- for (const name of had) {
250
- if (has.has(name)) continue;
251
- const target = join(targetDir, '.claude', sub, name);
252
- if (existsSync(target)) {
253
- rmSync(target, { recursive: true, force: true });
254
- removed.push(`orphaned ${sub.replace(/s$/, '')} ${name}`);
255
- }
256
- }
257
- }
258
- return removed;
259
- }
260
-
261
- // Every file inside dir, as paths relative to dir.
262
- function listFilesRecursive(dir, prefix = '') {
263
- const out = [];
264
- if (!existsSync(dir)) return out;
265
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
266
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
267
- if (entry.isDirectory()) out.push(...listFilesRecursive(join(dir, entry.name), rel));
268
- else out.push(rel);
269
- }
270
- return out;
271
- }
272
-
273
- // Remove directories left empty by a prune, depth-first.
274
- function removeEmptyDirs(dir) {
275
- if (!existsSync(dir)) return;
276
- for (const entry of readdirSync(dir, { withFileTypes: true }))
277
- if (entry.isDirectory()) removeEmptyDirs(join(dir, entry.name));
278
- if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
279
- }
280
-
281
- // Remove files inside an installed component that this bundle no longer ships. copyHarnessSubdir
282
- // merges, so a component that SURVIVES an upgrade while shedding a file keeps the stale copy
283
- // forever — that is how a retired reference file goes on being read as live doctrine. Scoped to
284
- // components the bundle still ships (a project's own components are never in that set, so they
285
- // are never touched), and needs no prior manifest, so one refresh clears orphans left by any
286
- // earlier version. Whole components that disappear are pruneOrphanedComponents' job.
287
- function pruneStaleComponentFiles(bundledSubdir, projectSubdir) {
288
- const removed = [];
289
- if (!existsSync(bundledSubdir)) return removed;
290
- for (const entry of readdirSync(bundledSubdir, { withFileTypes: true })) {
291
- if (!entry.isDirectory()) continue;
292
- const shipped = new Set(listFilesRecursive(join(bundledSubdir, entry.name)));
293
- const installedDir = join(projectSubdir, entry.name);
294
- for (const rel of listFilesRecursive(installedDir)) {
295
- if (shipped.has(rel)) continue;
296
- rmSync(join(installedDir, rel), { force: true });
297
- removed.push(`${entry.name}/${rel}`);
298
- }
299
- removeEmptyDirs(installedDir);
300
- }
301
- return removed;
302
- }
303
-
304
- // True if an event's matcher-group already carries a hook with this command string.
305
- function groupHasCommand(group, command) {
306
- return (group.hooks || []).some((h) => h.command === command);
307
- }
308
-
309
- // Deep-merge the bundle's hooks.json object into <project>/.claude/settings.json under
310
- // "hooks", preserving every existing key. Idempotent: for each event, an incoming entry
311
- // merges into the matcher-group of the same matcher, skipping any hook whose command
312
- // already exists there; a new matcher creates a new group. Returns count of hooks added.
313
- function mergeHooks(targetDir, bundledHooksJson) {
314
- if (!existsSync(bundledHooksJson)) return 0;
315
- const bundled = readJson(bundledHooksJson);
316
- const incoming = bundled.hooks || {};
317
- const settingsPath = join(targetDir, '.claude', 'settings.json');
318
- const settings = readJson(settingsPath);
319
- settings.hooks = settings.hooks || {};
320
- let added = 0;
321
- // Harness-shipped permission defaults (e.g. Edit on docs/** flows without prompts —
322
- // the deny hooks still hard-block frozen/protected paths underneath). Additive, idempotent.
323
- for (const rule of bundled.permissions?.allow || []) {
324
- settings.permissions = settings.permissions || {};
325
- const allowList = (settings.permissions.allow = settings.permissions.allow || []);
326
- if (!allowList.includes(rule)) {
327
- allowList.push(rule);
328
- added += 1;
329
- }
330
- }
331
- for (const [event, groups] of Object.entries(incoming)) {
332
- const existingGroups = (settings.hooks[event] = settings.hooks[event] || []);
333
- for (const group of groups) {
334
- const match = existingGroups.find((g) => g.matcher === group.matcher);
335
- if (!match) {
336
- existingGroups.push(group);
337
- added += (group.hooks || []).length;
338
- continue;
339
- }
340
- match.hooks = match.hooks || [];
341
- for (const hook of group.hooks || []) {
342
- if (groupHasCommand(match, hook.command)) continue;
343
- match.hooks.push(hook);
344
- added += 1;
345
- }
346
- }
347
- }
348
- if (added > 0) writeJson(settingsPath, settings);
349
- return added;
71
+ try {
72
+ return readJsonOrThrow(path);
73
+ } catch (e) {
74
+ die(e.message);
75
+ }
350
76
  }
351
77
 
352
78
  // Resolve the harness to install: the --name value, or — in a TTY — an interactive pick
353
79
  // showing each harness's description. Returns the chosen folder, or null on cancel.
354
80
  async function resolveHarnessName(name, harnesses) {
355
- const names = Object.keys(harnesses);
356
- if (name !== null) {
357
- if (!name || !harnesses[name]) {
358
- p.log.error(name ? `Unknown harness '${name}'.` : 'Empty --name value.');
359
- printHarnessList(harnesses);
360
- p.outro('Pass --name=<harness> with one of the names above.');
361
- process.exit(1);
362
- }
363
- return name;
364
- }
365
- if (!isInteractive()) {
366
- p.log.error('No --name given, and not running in an interactive terminal.');
367
- printHarnessList(harnesses);
368
- p.outro('Re-run as `npx @nurix/etna --name=<harness>`.');
369
- process.exit(1);
370
- }
371
- const choice = await p.select({
372
- message: 'Which harness do you want to install here?',
373
- options: names.map((folder) => ({
374
- value: folder,
375
- label: folder,
376
- hint: harnesses[folder]?.description || undefined,
377
- })),
378
- });
379
- if (p.isCancel(choice)) {
380
- p.cancel('Cancelled — nothing installed.');
381
- return null;
382
- }
383
- return choice;
81
+ const names = Object.keys(harnesses);
82
+ if (name !== null) {
83
+ if (!name || !harnesses[name]) {
84
+ p.log.error(name ? `Unknown harness '${name}'.` : 'Empty --name value.');
85
+ printHarnessList(harnesses);
86
+ p.outro('Pass --name=<harness> with one of the names above.');
87
+ process.exit(1);
88
+ }
89
+ return name;
90
+ }
91
+ if (!isInteractive()) {
92
+ p.log.error('No --name given, and not running in an interactive terminal.');
93
+ printHarnessList(harnesses);
94
+ p.outro('Re-run as `npx @nurix/etna --name=<harness>`.');
95
+ process.exit(1);
96
+ }
97
+ const choice = await p.select({
98
+ message: 'Which harness do you want to install here?',
99
+ options: names.map((folder) => ({
100
+ value: folder,
101
+ label: folder,
102
+ hint: harnesses[folder]?.description || undefined,
103
+ })),
104
+ });
105
+ if (p.isCancel(choice)) {
106
+ p.cancel('Cancelled — nothing installed.');
107
+ return null;
108
+ }
109
+ return choice;
384
110
  }
385
111
 
386
112
  // Copy one harness's bundled payload into the current directory. All file ops run
387
113
  // inside a spinner; the per-item detail + any warnings are emitted after it stops.
388
114
  async function installHarness(harnessName, harnesses) {
389
- const harnessFolder = harnessName;
390
- const bundledHarnessDir = join(dataDir, 'harnesses', harnessFolder);
391
- const bundledClaudeMd = join(bundledHarnessDir, 'CLAUDE.md');
392
- if (!existsSync(bundledClaudeMd)) {
393
- die(`Bundled CLAUDE.md missing for harness '${harnessFolder}'. Rebuild package data.`);
394
- }
395
-
396
- const targetDir = process.cwd();
397
- const claudeMdDest = join(targetDir, 'CLAUDE.md');
398
-
399
- // Bundled harness marker — names the version for the banner and carries the
400
- // `files` list (project-root config shipped outside the plugin route).
401
- const bundledHarnessJson = join(bundledHarnessDir, 'harness.json');
402
- const harnessMeta = existsSync(bundledHarnessJson) ? readJson(bundledHarnessJson) : {};
403
- const versionTag = harnessMeta.version ? ` v${harnessMeta.version}` : '';
404
- // The underlying plugin's name — may differ from the harness name, and several
405
- // harnesses may share it. Used only for legacy marketplace-entry cleanup.
406
- const pluginName = harnessMeta.name || harnesses[harnessName]?.plugin || harnessName;
407
-
408
- const spin = p.spinner();
409
- spin.start(`Installing ${harnessName}${versionTag} into ${targetDir}`);
410
-
411
- const installed = [];
412
- const notices = [];
413
- const warnings = [];
414
-
415
- // CLAUDE.md ships frontmatter-free, so it installs as a verbatim copy — no stripping.
416
- // It's harness-owned and overwritten in place. If the project is a git repo the old copy
417
- // is recoverable from history, so we skip the backup; if it isn't, there's no safety net,
418
- // so we back it up and tell the user. (Backup applies to CLAUDE.md only.)
419
- const claudeExisted = existsSync(claudeMdDest) || isSymlink(claudeMdDest);
420
- const projectHasGit = existsSync(join(targetDir, '.git'));
421
- if (claudeExisted) {
422
- if (projectHasGit) {
423
- notices.push('Overwrote existing CLAUDE.md (tracked by git — `git checkout CLAUDE.md` to restore).');
424
- } else {
425
- const claudeBackup = backupIfExists(claudeMdDest);
426
- warnings.push(
427
- `No .git here, so no version-control safety net — backed up existing CLAUDE.md → ${claudeBackup}`,
428
- );
429
- }
430
- }
431
- cpSync(bundledClaudeMd, claudeMdDest, { force: true });
432
- installed.push('CLAUDE.md → ./');
433
-
434
- // Same safety net for the .claude/ tree, mirroring the CLAUDE.md rule above. The tree is
435
- // refreshed in place — owned files overwritten, orphaned owned components pruned — so a
436
- // user's edits to owned files and any pruned component are the loss surface. A git repo
437
- // recovers them from history, so we skip the backup; without git there's no net, so we
438
- // snapshot the whole tree first and tell the user. (Snapshot, not move: the live tree has
439
- // to stay for the in-place refresh below.)
440
- const claudeDir = join(targetDir, '.claude');
441
- if (existsSync(claudeDir)) {
442
- if (projectHasGit) {
443
- notices.push('Refreshed existing .claude/ in place (tracked by git — `git checkout .claude` to restore).');
444
- } else {
445
- const claudeDirBackup = snapshotDirIfExists(claudeDir);
446
- if (claudeDirBackup) {
447
- warnings.push(
448
- `No .git here, so no version-control safety net — snapshotted existing .claude/ → ${claudeDirBackup}`,
449
- );
450
- }
451
- }
452
- }
453
-
454
- // What a prior install recorded as harness-owned — read before we overwrite the marker,
455
- // so we can prune anything this bundle has since dropped.
456
- const priorOwned = readInstalledOwned(targetDir);
457
-
458
- for (const sub of PLUGIN_SUBDIRS) {
459
- const count = copyHarnessSubdir(join(bundledHarnessDir, sub), join(targetDir, '.claude', sub));
460
- if (count > 0) installed.push(`${count} ${sub} → .claude/${sub}/`);
461
- }
462
-
463
- // Prune components a prior harness version owned but this bundle no longer ships (e.g. a
464
- // retired agent). Only prior-owned names are eligible, so the user's own files survive.
465
- const newOwned = bundledOwned(bundledHarnessDir);
466
- for (const r of pruneOrphanedComponents(targetDir, priorOwned, newOwned)) notices.push(`removed ${r}`);
467
-
468
- // Then prune stale files INSIDE components the bundle still ships — the case the
469
- // name-level diff above cannot see, since the component itself is still present.
470
- for (const sub of PRUNABLE_SUBDIRS) {
471
- const stale = pruneStaleComponentFiles(
472
- join(bundledHarnessDir, sub),
473
- join(targetDir, '.claude', sub),
474
- );
475
- for (const r of stale) notices.push(`removed stale ${sub.replace(/s$/, '')} file ${r}`);
476
- }
477
-
478
- // Dual-runtime: derive the repo's Codex artifacts (AGENTS.md, .agents/skills/,
479
- // .codex/agents/, .codex/hooks.json) from the just-installed Claude tree + bundle, so the
480
- // same project works under Codex too. Pure-derived — no MCP, no settings/config.toml
481
- // (owner decision). See docs/features/codex-support.md. The `codex` owned-list lets a
482
- // future version prune a Codex target it stops shipping.
483
- const codex = projectCodexArtifacts({
484
- targetDir,
485
- bundledHarnessDir,
486
- bundledClaudeMd,
487
- projectHasGit,
488
- priorCodexOwned: priorOwned.codex || [],
115
+ const bundledHarnessDir = join(dataDir, 'harnesses', harnessName);
116
+ const targetDir = process.cwd();
117
+
118
+ // Bundled harness marker — names the version for the banner. Everything else it carries
119
+ // (the `files` and `seeds` lists) is read by materializeHarness, which owns placement.
120
+ const harnessMeta = readJson(join(bundledHarnessDir, 'harness.json'));
121
+ const versionTag = harnessMeta.version ? ` v${harnessMeta.version}` : '';
122
+ // The underlying plugin name — may differ from the harness name, and several harnesses may
123
+ // share it. Used only for legacy marketplace-entry cleanup.
124
+ const pluginName = harnessMeta.name || harnesses[harnessName]?.plugin || harnessName;
125
+
126
+ const spin = p.spinner();
127
+ spin.start(`Installing ${harnessName}${versionTag} into ${targetDir}`);
128
+
129
+ let result;
130
+ try {
131
+ result = materializeHarness({
132
+ bundledHarnessDir,
133
+ targetDir,
134
+ pluginName,
135
+ projectHasGit: existsSync(join(targetDir, '.git')),
489
136
  });
490
- for (const line of codex.installed) installed.push(line);
491
- for (const n of codex.notices) notices.push(n);
492
- for (const w of codex.warnings) warnings.push(w);
493
- newOwned.codex = codex.owned;
494
-
495
- // Write the harness marker — plus the `owned` manifest the next refresh diffs against —
496
- // so the maintenance agent (baseline mode) can resolve the installed version.
497
- if (existsSync(bundledHarnessJson)) {
498
- const harnessJsonDest = join(targetDir, '.claude', 'harness.json');
499
- mkdirSync(dirname(harnessJsonDest), { recursive: true });
500
- const marker = readJson(bundledHarnessJson);
501
- marker.owned = newOwned;
502
- writeJson(harnessJsonDest, marker);
503
- }
504
-
505
- // Verbatim project-root files bundled outside the plugin route (.prettierrc.json,
506
- // .vscode/settings.json, …), listed in harness.json's `files`. They land at the project
507
- // root at their relative path — NOT under .claude/ — and overwrite unconditionally with no
508
- // backup: the harness owns this config, and re-running etna refreshes it.
509
- for (const rel of harnessMeta.files || []) {
510
- const src = join(bundledHarnessDir, rel);
511
- if (!existsSync(src)) continue;
512
- const dest = join(targetDir, rel);
513
- mkdirSync(dirname(dest), { recursive: true });
514
- cpSync(src, dest, { recursive: true, force: true });
515
- installed.push(`${rel} → ./${rel}`);
516
- }
517
-
518
- // Seed files listed in harness.json's `seeds` (e.g. a pre-baked docs/ scaffold). Same
519
- // project-root landing as `files`, opposite ownership: a seed is written only when its
520
- // path is absent. Once present it is the project's living copy — a refresh never
521
- // overwrites it and never prunes it.
522
- for (const rel of harnessMeta.seeds || []) {
523
- const src = join(bundledHarnessDir, rel);
524
- if (!existsSync(src)) continue;
525
- const dest = join(targetDir, rel);
526
- if (existsSync(dest)) continue;
527
- mkdirSync(dirname(dest), { recursive: true });
528
- cpSync(src, dest, { recursive: true });
529
- installed.push(`${rel} → ./${rel} (seeded)`);
530
- }
531
-
532
- // Retire hooks earlier harness versions shipped before merging, so the additive merge
533
- // never resurrects an entry pointing at a script this bundle no longer carries.
534
- for (const r of retireOldHooks(targetDir, bundledHarnessDir)) notices.push(`removed ${r}`);
535
-
536
- // Merge the bundle's hooks.json into the project's settings.json (idempotent).
537
- const added = mergeHooks(targetDir, join(bundledHarnessDir, 'hooks', 'hooks.json'));
538
- if (added > 0) installed.push(`${added} hook(s) → .claude/settings.json`);
137
+ } catch (error) {
138
+ spin.stop(`Could not install ${harnessName}`);
139
+ die(error.message);
140
+ }
539
141
 
540
- for (const entry of migrateFromPluginInstall(targetDir, pluginName)) {
541
- notices.push(`removed stale ${entry}`);
542
- }
543
-
544
- spin.stop(`Installed ${harnessName}${versionTag}`);
142
+ spin.stop(`Installed ${harnessName}${versionTag}`);
545
143
 
546
- p.note(installed.join('\n'), 'Written');
547
- for (const notice of notices) p.log.info(notice);
548
- for (const warning of warnings) p.log.warn(warning);
144
+ p.note(result.installed.join('\n'), 'Written');
145
+ for (const notice of result.notices) p.log.info(notice);
146
+ for (const warning of result.warnings) p.log.warn(warning);
549
147
 
550
- p.outro(
551
- `${harnessName}${versionTag} ready in ${targetDir}. ` +
552
- `Re-run \`npx @nurix/etna --name=${harnessName}\` anytime to refresh harness content.`,
553
- );
148
+ p.outro(
149
+ `${harnessName}${versionTag} ready in ${targetDir}. ` +
150
+ `Re-run \`npx @nurix/etna --name=${harnessName}\` anytime to refresh harness content.`,
151
+ );
554
152
  }
555
153
 
556
154
  // Parse argv. `--name=<harness>` / `--name <harness>` selects a harness; -h/--help shows
557
155
  // help; any other bare token is the dropped positional form (a breaking change in 0.18.0).
558
156
  function parseArgs(args) {
559
- let name = null;
560
- let help = false;
561
- let positional = null;
562
- for (let i = 0; i < args.length; i++) {
563
- const a = args[i];
564
- if (a === '-h' || a === '--help') help = true;
565
- else if (a.startsWith('--name=')) name = a.slice('--name='.length);
566
- else if (a === '--name') name = args[++i] ?? '';
567
- else if (!a.startsWith('-') && positional === null) positional = a;
568
- }
569
- return { name, help, positional };
157
+ let name = null;
158
+ let help = false;
159
+ let positional = null;
160
+ for (let i = 0; i < args.length; i++) {
161
+ const a = args[i];
162
+ if (a === '-h' || a === '--help') help = true;
163
+ else if (a.startsWith('--name=')) name = a.slice('--name='.length);
164
+ else if (a === '--name') name = args[++i] ?? '';
165
+ else if (!a.startsWith('-') && positional === null) positional = a;
166
+ }
167
+ return { name, help, positional };
570
168
  }
571
169
 
572
170
  // npx caches aggressively: a bare `npx @nurix/etna` can re-run a STALE cached copy
@@ -582,62 +180,62 @@ function parseArgs(args) {
582
180
  // falls through to running THIS copy. @nurix/etna publishes with public access, so the
583
181
  // `npm view` and the re-exec `npx` both resolve unauthenticated — no npm token needed.
584
182
  function selfUpdateOrReexec() {
585
- if (process.env.ETNA_SELFUPDATED || process.env.ETNA_NO_SELFUPDATE) return;
586
- const here = readJson(join(pkgRoot, 'package.json')).version;
587
- if (!here) return;
588
- const view = spawnSync('npm', ['view', '@nurix/etna', 'version', '--prefer-online'], {
589
- encoding: 'utf8',
590
- });
591
- const latest = view.status === 0 ? (view.stdout || '').trim() : null;
592
- if (!latest || latest === here) return; // current, offline, or unauthed → run this copy
593
- const child = spawnSync(
594
- 'npx',
595
- ['-y', '--prefer-online', `@nurix/etna@${latest}`, ...process.argv.slice(2)],
596
- { stdio: 'inherit', env: { ...process.env, ETNA_SELFUPDATED: '1' } },
597
- );
598
- if (!child.error) process.exit(child.status ?? 0); // re-exec ran → hand off complete
599
- // npx unavailable (child.error) → fall through and run this (older) copy.
183
+ if (process.env.ETNA_SELFUPDATED || process.env.ETNA_NO_SELFUPDATE) return;
184
+ const here = readJson(join(pkgRoot, 'package.json')).version;
185
+ if (!here) return;
186
+ const view = spawnSync('npm', ['view', '@nurix/etna', 'version', '--prefer-online'], {
187
+ encoding: 'utf8',
188
+ });
189
+ const latest = view.status === 0 ? (view.stdout || '').trim() : null;
190
+ if (!latest || latest === here) return; // current, offline, or unauthed → run this copy
191
+ const child = spawnSync(
192
+ 'npx',
193
+ ['-y', '--prefer-online', `@nurix/etna@${latest}`, ...process.argv.slice(2)],
194
+ { stdio: 'inherit', env: { ...process.env, ETNA_SELFUPDATED: '1' } },
195
+ );
196
+ if (!child.error) process.exit(child.status ?? 0); // re-exec ran → hand off complete
197
+ // npx unavailable (child.error) → fall through and run this (older) copy.
600
198
  }
601
199
 
602
200
  async function main(args) {
603
- const idx = loadHarnessIndex();
604
- const harnesses = idx.harnesses || {};
605
- const { name, help, positional } = parseArgs(args);
606
-
607
- if (help) {
608
- p.intro('@nurix/etna — Nurix harness installer');
609
- p.log.message(
610
- 'Usage:\n' +
611
- ' npx @nurix/etna pick a harness from the menu\n' +
612
- ' npx @nurix/etna --name=<harness> install that harness directly',
613
- );
614
- printHarnessList(harnesses);
615
- p.outro('No args → interactive menu. --name=<harness> → direct install.');
616
- return;
617
- }
201
+ const idx = loadHarnessIndex();
202
+ const harnesses = idx.harnesses || {};
203
+ const { name, help, positional } = parseArgs(args);
618
204
 
205
+ if (help) {
619
206
  p.intro('@nurix/etna — Nurix harness installer');
207
+ p.log.message(
208
+ 'Usage:\n' +
209
+ ' npx @nurix/etna pick a harness from the menu\n' +
210
+ ' npx @nurix/etna --name=<harness> install that harness directly',
211
+ );
212
+ printHarnessList(harnesses);
213
+ p.outro('No args → interactive menu. --name=<harness> → direct install.');
214
+ return;
215
+ }
216
+
217
+ p.intro('@nurix/etna — Nurix harness installer');
218
+
219
+ // Breaking change (0.18.0): the positional form `etna <harness>` was replaced by
220
+ // `--name=<harness>`. Fail loudly with the new syntax rather than silently misfiring.
221
+ if (positional && name === null) {
222
+ p.log.error(
223
+ `Positional arguments are no longer supported. Install with \`npx @nurix/etna --name=${positional}\`.`,
224
+ );
225
+ printHarnessList(harnesses);
226
+ p.outro('Use --name=<harness> to install, or no args to pick from the menu.');
227
+ process.exit(1);
228
+ }
620
229
 
621
- // Breaking change (0.18.0): the positional form `etna <harness>` was replaced by
622
- // `--name=<harness>`. Fail loudly with the new syntax rather than silently misfiring.
623
- if (positional && name === null) {
624
- p.log.error(
625
- `Positional arguments are no longer supported. Install with \`npx @nurix/etna --name=${positional}\`.`,
626
- );
627
- printHarnessList(harnesses);
628
- p.outro('Use --name=<harness> to install, or no args to pick from the menu.');
629
- process.exit(1);
630
- }
631
-
632
- const boot = p.spinner();
633
- boot.start('Starting up');
634
- await sleep(2000);
635
- boot.stop('Ready');
230
+ const boot = p.spinner();
231
+ boot.start('Starting up');
232
+ await sleep(2000);
233
+ boot.stop('Ready');
636
234
 
637
- const harnessName = await resolveHarnessName(name, harnesses);
638
- if (!harnessName) return; // user cancelled the interactive pick
235
+ const harnessName = await resolveHarnessName(name, harnesses);
236
+ if (!harnessName) return; // user cancelled the interactive pick
639
237
 
640
- await installHarness(harnessName, harnesses);
238
+ await installHarness(harnessName, harnesses);
641
239
  }
642
240
 
643
241
  // Before anything else: if a newer @nurix/etna is published, re-exec it (so a stale
@@ -645,6 +243,6 @@ async function main(args) {
645
243
  selfUpdateOrReexec();
646
244
 
647
245
  main(process.argv.slice(2)).catch((e) => {
648
- p.log.error(e?.stack || String(e));
649
- process.exit(1);
246
+ p.log.error(e?.stack || String(e));
247
+ process.exit(1);
650
248
  });