@nurix/etna 0.17.0 → 0.17.1

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
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // packages/cli/bin/etna.js
3
- // `npx @nurix/etna <harness-name>` — copy a Nurix harness into the current
4
- // directory. Drops the harness's CLAUDE.md at the project root and copies its
5
- // skills, agents, rules, and scripts into <project>/.claude/. No marketplace,
6
- // no plugin enablement — files live in the project, version-controlled and
7
- // editable per project. Re-run `etna` to refresh. Harnesses are addressed by
8
- // their harness/<folder> name; several harnesses may share one plugin.
3
+ // `npx @nurix/etna [harness-name]` — copy a Nurix harness into the current
4
+ // directory. Given a name it installs straight away; with no name (in a TTY) it
5
+ // prompts you to pick one. Drops the harness's CLAUDE.md + project-root config
6
+ // files (.prettierrc.json, .vscode/settings.json, …) at the project root, and
7
+ // copies its skills, agents, rules, scripts, and hooks into <project>/.claude/.
8
+ // No marketplace, no plugin enablement — files live in the project,
9
+ // version-controlled and editable per project. Re-run `etna` to refresh.
10
+ // Harnesses are addressed by their harness/<folder> name; several harnesses may
11
+ // share one plugin.
9
12
 
10
13
  import {
11
14
  existsSync,
@@ -20,6 +23,7 @@ import {
20
23
  } from 'node:fs';
21
24
  import { join, dirname, resolve } from 'node:path';
22
25
  import { fileURLToPath } from 'node:url';
26
+ import * as p from '@clack/prompts';
23
27
 
24
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
29
  const pkgRoot = resolve(__dirname, '..');
@@ -31,29 +35,37 @@ const dataDir = join(pkgRoot, 'data');
31
35
  // the settings merge reads it from the bundle, not the project copy).
32
36
  const PLUGIN_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
33
37
 
34
- function usage() {
35
- const idx = loadHarnessIndex();
36
- console.log(`\nUsage: npx @nurix/etna <harness-name>\n`);
37
- const entries = Object.entries(idx.harnesses || {});
38
- if (entries.length) {
39
- console.log(`Available harnesses:`);
40
- for (const [folder, plugin] of entries) {
41
- console.log(` ${folder}${plugin && plugin !== folder ? ` (plugin: ${plugin})` : ''}`);
42
- }
38
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
39
+
40
+ // True when both stdin and stdout are a terminal — clack prompts can't resolve
41
+ // otherwise (CI, piped stdin), so the no-arg pick must fall back to an error.
42
+ function isInteractive() {
43
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
44
+ }
45
+
46
+ // Boxed list of installable harnesses, for --help and the non-interactive no-arg path.
47
+ function printHarnessList(harnesses) {
48
+ const entries = Object.entries(harnesses);
49
+ if (!entries.length) {
50
+ p.log.warn('No harnesses are bundled. Run `npm run build:data` from the package root.');
51
+ return;
43
52
  }
44
- console.log();
53
+ const lines = entries.map(
54
+ ([folder, plugin]) => `${folder}${plugin && plugin !== folder ? ` (plugin: ${plugin})` : ''}`,
55
+ );
56
+ p.note(lines.join('\n'), 'Available harnesses');
45
57
  }
46
58
 
47
59
  function loadHarnessIndex() {
48
60
  const path = join(dataDir, 'harness-index.json');
49
61
  if (!existsSync(path)) {
50
- die(`harness-index.json missing — run \`npm run build:data\` from the package root.`);
62
+ die('harness-index.json missing — run `npm run build:data` from the package root.');
51
63
  }
52
64
  return JSON.parse(readFileSync(path, 'utf8'));
53
65
  }
54
66
 
55
67
  function die(msg, code = 1) {
56
- console.error(`error: ${msg}`);
68
+ p.log.error(msg);
57
69
  process.exit(code);
58
70
  }
59
71
 
@@ -204,22 +216,47 @@ function mergeHooks(targetDir, bundledHooksJson) {
204
216
  return added;
205
217
  }
206
218
 
207
- async function main(args) {
208
- if (args.includes('-h') || args.includes('--help') || args.length === 0) {
209
- usage();
210
- process.exit(args.length === 0 ? 1 : 0);
219
+ // Resolve the harness to install: the positional arg, or — in a TTY — an interactive
220
+ // pick. Returns the chosen folder name, or null if the user cancels / can't be prompted.
221
+ async function resolveHarnessName(positional, harnesses) {
222
+ const names = Object.keys(harnesses);
223
+ if (positional) {
224
+ if (!harnesses[positional]) {
225
+ p.log.error(`Unknown harness '${positional}'.`);
226
+ printHarnessList(harnesses);
227
+ p.outro('Pick one of the harnesses above.');
228
+ process.exit(1);
229
+ }
230
+ return positional;
211
231
  }
212
-
213
- const harnessName = args[0];
214
- const idx = loadHarnessIndex();
215
- const harnesses = idx.harnesses || {};
216
- if (!harnesses[harnessName]) {
217
- die(
218
- `Unknown harness '${harnessName}'. Available: ${Object.keys(harnesses).join(', ') || '(none)'}`,
219
- );
232
+ if (!isInteractive()) {
233
+ p.log.error('No harness specified, and not running in an interactive terminal.');
234
+ printHarnessList(harnesses);
235
+ p.outro('Re-run as `npx @nurix/etna <harness-name>`.');
236
+ process.exit(1);
220
237
  }
221
- const harnessFolder = harnessName;
238
+ const choice = await p.select({
239
+ message: 'Which harness do you want to install here?',
240
+ options: names.map((folder) => {
241
+ const plugin = harnesses[folder];
242
+ return {
243
+ value: folder,
244
+ label: folder,
245
+ hint: plugin && plugin !== folder ? `plugin: ${plugin}` : undefined,
246
+ };
247
+ }),
248
+ });
249
+ if (p.isCancel(choice)) {
250
+ p.cancel('Cancelled — nothing installed.');
251
+ return null;
252
+ }
253
+ return choice;
254
+ }
222
255
 
256
+ // Copy one harness's bundled payload into the current directory. All file ops run
257
+ // inside a spinner; the per-item detail + any warnings are emitted after it stops.
258
+ async function installHarness(harnessName, harnesses) {
259
+ const harnessFolder = harnessName;
223
260
  const bundledHarnessDir = join(dataDir, 'harnesses', harnessFolder);
224
261
  const bundledClaudeMd = join(bundledHarnessDir, 'CLAUDE.md');
225
262
  if (!existsSync(bundledClaudeMd)) {
@@ -229,8 +266,8 @@ async function main(args) {
229
266
  const targetDir = process.cwd();
230
267
  const claudeMdDest = join(targetDir, 'CLAUDE.md');
231
268
 
232
- // Read the bundled epoch marker up front so the install banner can name the
233
- // harness version; the file itself is copied into the project further down.
269
+ // Bundled epoch marker — names the version for the banner and carries the
270
+ // `files` list (project-root config shipped outside the plugin route).
234
271
  const bundledHarnessJson = join(bundledHarnessDir, 'harness.json');
235
272
  const harnessMeta = existsSync(bundledHarnessJson) ? readJson(bundledHarnessJson) : {};
236
273
  const versionTag = harnessMeta.version ? ` v${harnessMeta.version}` : '';
@@ -238,31 +275,35 @@ async function main(args) {
238
275
  // harnesses may share it. Used only for legacy marketplace-entry cleanup.
239
276
  const pluginName = harnessMeta.name || harnesses[harnessName] || harnessName;
240
277
 
241
- console.log(`\nInstalling ${harnessName}${versionTag} into ${targetDir}`);
278
+ const spin = p.spinner();
279
+ spin.start(`Installing ${harnessName}${versionTag} into ${targetDir}`);
242
280
 
243
- const claudeBackup = backupIfExists(claudeMdDest);
244
- if (claudeBackup) console.log(` backed up existing CLAUDE.md → ${claudeBackup}`);
245
-
246
- // CLAUDE.md ships frontmatter-free (the harness's metadata lives in its index.md and the
247
- // plugin's definition.md), so it installs as a verbatim copy — no stripping.
248
- // Tracks the categories actually present in this harness, for the closing
249
- // summary — so a harness that ships no skills or hooks reads as an explicit,
250
- // honest list rather than a silently missing line.
251
281
  const installed = [];
252
-
282
+ const notices = [];
283
+ const warnings = [];
284
+
285
+ // CLAUDE.md ships frontmatter-free, so it installs as a verbatim copy — no stripping.
286
+ // It's harness-owned and overwritten in place. If the project is a git repo the old copy
287
+ // is recoverable from history, so we skip the backup; if it isn't, there's no safety net,
288
+ // so we back it up and tell the user. (Backup applies to CLAUDE.md only.)
289
+ const claudeExisted = existsSync(claudeMdDest) || isSymlink(claudeMdDest);
290
+ const projectHasGit = existsSync(join(targetDir, '.git'));
291
+ if (claudeExisted) {
292
+ if (projectHasGit) {
293
+ notices.push('Overwrote existing CLAUDE.md (tracked by git — `git checkout CLAUDE.md` to restore).');
294
+ } else {
295
+ const claudeBackup = backupIfExists(claudeMdDest);
296
+ warnings.push(
297
+ `No .git here, so no version-control safety net — backed up existing CLAUDE.md → ${claudeBackup}`,
298
+ );
299
+ }
300
+ }
253
301
  cpSync(bundledClaudeMd, claudeMdDest, { force: true });
254
- console.log(` installed CLAUDE.md → ${claudeMdDest}`);
255
- installed.push('CLAUDE.md');
302
+ installed.push('CLAUDE.md → ./');
256
303
 
257
304
  for (const sub of PLUGIN_SUBDIRS) {
258
- const count = copyHarnessSubdir(
259
- join(bundledHarnessDir, sub),
260
- join(targetDir, '.claude', sub),
261
- );
262
- if (count > 0) {
263
- console.log(` copied ${count} ${sub} → ${targetDir}/.claude/${sub}/`);
264
- installed.push(`${count} ${sub}`);
265
- }
305
+ const count = copyHarnessSubdir(join(bundledHarnessDir, sub), join(targetDir, '.claude', sub));
306
+ if (count > 0) installed.push(`${count} ${sub} → .claude/${sub}/`);
266
307
  }
267
308
 
268
309
  // Copy the epoch marker so harness-manager can resolve the installed epoch.
@@ -270,33 +311,73 @@ async function main(args) {
270
311
  const harnessJsonDest = join(targetDir, '.claude', 'harness.json');
271
312
  mkdirSync(dirname(harnessJsonDest), { recursive: true });
272
313
  cpSync(bundledHarnessJson, harnessJsonDest, { force: true });
273
- const tag = [harnessMeta.version && `v${harnessMeta.version}`, harnessMeta.epoch && `epoch ${harnessMeta.epoch}`]
274
- .filter(Boolean)
275
- .join(', ');
276
- console.log(` installed harness.json → ${harnessJsonDest}${tag ? ` (${tag})` : ''}`);
314
+ }
315
+
316
+ // Verbatim project-root files bundled outside the plugin route (.prettierrc.json,
317
+ // .vscode/settings.json, …), listed in harness.json's `files`. They land at the project
318
+ // root at their relative path — NOT under .claude/ — and overwrite unconditionally with no
319
+ // backup: the harness owns this config, and re-running etna refreshes it.
320
+ for (const rel of harnessMeta.files || []) {
321
+ const src = join(bundledHarnessDir, rel);
322
+ if (!existsSync(src)) continue;
323
+ const dest = join(targetDir, rel);
324
+ mkdirSync(dirname(dest), { recursive: true });
325
+ cpSync(src, dest, { recursive: true, force: true });
326
+ installed.push(`${rel} → ./${rel}`);
277
327
  }
278
328
 
279
329
  // Retire hooks earlier epochs shipped before merging, so the additive merge
280
330
  // never resurrects an entry pointing at a script this bundle no longer carries.
281
- const retiredHooks = retireOldHooks(targetDir);
282
- for (const r of retiredHooks) console.log(` removed ${r}`);
331
+ for (const r of retireOldHooks(targetDir)) notices.push(`removed ${r}`);
283
332
 
284
333
  // Merge the bundle's hooks.json into the project's settings.json (idempotent).
285
334
  const added = mergeHooks(targetDir, join(bundledHarnessDir, 'hooks', 'hooks.json'));
286
- if (added > 0) {
287
- console.log(` merged ${added} hook(s) → ${targetDir}/.claude/settings.json`);
335
+ if (added > 0) installed.push(`${added} hook(s) → .claude/settings.json`);
336
+
337
+ for (const entry of migrateFromPluginInstall(targetDir, pluginName)) {
338
+ notices.push(`removed stale ${entry}`);
288
339
  }
289
340
 
290
- const migrated = migrateFromPluginInstall(targetDir, pluginName);
291
- for (const entry of migrated) console.log(` removed stale ${entry}`);
341
+ spin.stop(`Installed ${harnessName}${versionTag}`);
342
+
343
+ p.note(installed.join('\n'), 'Written');
344
+ for (const notice of notices) p.log.info(notice);
345
+ for (const warning of warnings) p.log.warn(warning);
292
346
 
293
- console.log(`\nDone — ${harnessName}${versionTag} installed: ${installed.join(', ')}.`);
294
- console.log(
295
- `Open Claude Code in ${targetDir}. Re-run \`npx @nurix/etna ${harnessName}\` anytime to refresh harness content.\n`,
347
+ p.outro(
348
+ `${harnessName}${versionTag} ready in ${targetDir}. ` +
349
+ `Re-run \`npx @nurix/etna ${harnessName}\` anytime to refresh harness content.`,
296
350
  );
297
351
  }
298
352
 
353
+ async function main(args) {
354
+ const idx = loadHarnessIndex();
355
+ const harnesses = idx.harnesses || {};
356
+
357
+ if (args.includes('-h') || args.includes('--help')) {
358
+ p.intro('@nurix/etna — Nurix harness installer');
359
+ p.log.message('Usage: npx @nurix/etna [harness-name]');
360
+ printHarnessList(harnesses);
361
+ p.outro('Run with a name to install, or with no name to pick interactively.');
362
+ return;
363
+ }
364
+
365
+ const positional = args.find((a) => !a.startsWith('-'));
366
+
367
+ p.intro('@nurix/etna — Nurix harness installer');
368
+
369
+ const boot = p.spinner();
370
+ boot.start('Starting up');
371
+ await sleep(2000);
372
+ boot.stop('Ready');
373
+
374
+ const harnessName = await resolveHarnessName(positional, harnesses);
375
+ if (!harnessName) return; // user cancelled the interactive pick
376
+
377
+ await installHarness(harnessName, harnesses);
378
+ }
379
+
299
380
  main(process.argv.slice(2)).catch((e) => {
300
- console.error(e?.stack || e);
381
+ p.log.error(e?.stack || String(e));
301
382
  process.exit(1);
302
383
  });
@@ -1,5 +1,5 @@
1
1
  {
2
- "fingerprint": "b51d4ebd2a1e",
2
+ "fingerprint": "50dc8734b7ee",
3
3
  "harnesses": {
4
4
  "code": "code-fable",
5
5
  "code-fable": "code-fable",
@@ -0,0 +1,12 @@
1
+ # Dependencies & build output
2
+ node_modules
3
+ dist
4
+ build
5
+
6
+ # Lockfiles
7
+ pnpm-lock.yaml
8
+ package-lock.json
9
+ yarn.lock
10
+
11
+ # Harness-managed agent/skill definitions (installed under .claude/)
12
+ .claude
@@ -0,0 +1,3 @@
1
+ {
2
+ "proseWrap": "never"
3
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ // Markdown: Prettier unwraps prose to one logical line per paragraph
3
+ // (see .prettierrc → proseWrap:never) on save; the editor soft-wraps for
4
+ // reading, so no hard line breaks get baked into the source.
5
+ "[markdown]": {
6
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
7
+ "editor.formatOnSave": true,
8
+ "editor.wordWrap": "on"
9
+ }
10
+ }
@@ -3,5 +3,10 @@
3
3
  "name": "code-fable",
4
4
  "version": "0.17.0",
5
5
  "epoch": 14,
6
- "digest": "adc67e13b079"
6
+ "files": [
7
+ ".prettierrc.json",
8
+ ".prettierignore",
9
+ ".vscode/settings.json"
10
+ ],
11
+ "digest": "da02e3f9ca11"
7
12
  }
@@ -0,0 +1,12 @@
1
+ # Dependencies & build output
2
+ node_modules
3
+ dist
4
+ build
5
+
6
+ # Lockfiles
7
+ pnpm-lock.yaml
8
+ package-lock.json
9
+ yarn.lock
10
+
11
+ # Harness-managed agent/skill definitions (installed under .claude/)
12
+ .claude
@@ -0,0 +1,3 @@
1
+ {
2
+ "proseWrap": "never"
3
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ // Markdown: Prettier unwraps prose to one logical line per paragraph
3
+ // (see .prettierrc → proseWrap:never) on save; the editor soft-wraps for
4
+ // reading, so no hard line breaks get baked into the source.
5
+ "[markdown]": {
6
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
7
+ "editor.formatOnSave": true,
8
+ "editor.wordWrap": "on"
9
+ }
10
+ }
@@ -3,5 +3,10 @@
3
3
  "name": "code-fable",
4
4
  "version": "0.17.0",
5
5
  "epoch": 14,
6
- "digest": "6e3420a69727"
6
+ "files": [
7
+ ".prettierrc.json",
8
+ ".prettierignore",
9
+ ".vscode/settings.json"
10
+ ],
11
+ "digest": "c62c79b3aff1"
7
12
  }
@@ -0,0 +1,12 @@
1
+ # Dependencies & build output
2
+ node_modules
3
+ dist
4
+ build
5
+
6
+ # Lockfiles
7
+ pnpm-lock.yaml
8
+ package-lock.json
9
+ yarn.lock
10
+
11
+ # Harness-managed agent/skill definitions (installed under .claude/)
12
+ .claude
@@ -0,0 +1,3 @@
1
+ {
2
+ "proseWrap": "never"
3
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ // Markdown: Prettier unwraps prose to one logical line per paragraph
3
+ // (see .prettierrc → proseWrap:never) on save; the editor soft-wraps for
4
+ // reading, so no hard line breaks get baked into the source.
5
+ "[markdown]": {
6
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
7
+ "editor.formatOnSave": true,
8
+ "editor.wordWrap": "on"
9
+ }
10
+ }
@@ -3,5 +3,10 @@
3
3
  "name": "discover",
4
4
  "version": "0.1.7",
5
5
  "epoch": null,
6
- "digest": "459be63d0534"
6
+ "files": [
7
+ ".prettierrc.json",
8
+ ".prettierignore",
9
+ ".vscode/settings.json"
10
+ ],
11
+ "digest": "bd18173c697a"
7
12
  }
@@ -0,0 +1,12 @@
1
+ # Dependencies & build output
2
+ node_modules
3
+ dist
4
+ build
5
+
6
+ # Lockfiles
7
+ pnpm-lock.yaml
8
+ package-lock.json
9
+ yarn.lock
10
+
11
+ # Harness-managed agent/skill definitions (installed under .claude/)
12
+ .claude
@@ -0,0 +1,3 @@
1
+ {
2
+ "proseWrap": "never"
3
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ // Markdown: Prettier unwraps prose to one logical line per paragraph
3
+ // (see .prettierrc → proseWrap:never) on save; the editor soft-wraps for
4
+ // reading, so no hard line breaks get baked into the source.
5
+ "[markdown]": {
6
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
7
+ "editor.formatOnSave": true,
8
+ "editor.wordWrap": "on"
9
+ }
10
+ }
@@ -3,5 +3,10 @@
3
3
  "name": "hr",
4
4
  "version": "0.1.1",
5
5
  "epoch": null,
6
- "digest": "dfc7c529bbec"
6
+ "files": [
7
+ ".prettierrc.json",
8
+ ".prettierignore",
9
+ ".vscode/settings.json"
10
+ ],
11
+ "digest": "95bc2ce3024d"
7
12
  }
@@ -0,0 +1,12 @@
1
+ # Dependencies & build output
2
+ node_modules
3
+ dist
4
+ build
5
+
6
+ # Lockfiles
7
+ pnpm-lock.yaml
8
+ package-lock.json
9
+ yarn.lock
10
+
11
+ # Harness-managed agent/skill definitions (installed under .claude/)
12
+ .claude
@@ -0,0 +1,3 @@
1
+ {
2
+ "proseWrap": "never"
3
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ // Markdown: Prettier unwraps prose to one logical line per paragraph
3
+ // (see .prettierrc → proseWrap:never) on save; the editor soft-wraps for
4
+ // reading, so no hard line breaks get baked into the source.
5
+ "[markdown]": {
6
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
7
+ "editor.formatOnSave": true,
8
+ "editor.wordWrap": "on"
9
+ }
10
+ }
@@ -3,5 +3,10 @@
3
3
  "name": "kb-agi",
4
4
  "version": "0.10.1",
5
5
  "epoch": null,
6
- "digest": "856a9f4e297a"
6
+ "files": [
7
+ ".prettierrc.json",
8
+ ".prettierignore",
9
+ ".vscode/settings.json"
10
+ ],
11
+ "digest": "25e1cb993a7f"
7
12
  }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
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": {
7
7
  "etna": "bin/etna.js"
8
8
  },
9
+ "dependencies": {
10
+ "@clack/prompts": "^0.11.0"
11
+ },
9
12
  "files": [
10
13
  "bin/",
11
14
  "data/",