@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 +148 -67
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/.prettierignore +12 -0
- package/data/harnesses/code/.prettierrc.json +3 -0
- package/data/harnesses/code/.vscode/settings.json +10 -0
- package/data/harnesses/code/harness.json +6 -1
- package/data/harnesses/code-fable/.prettierignore +12 -0
- package/data/harnesses/code-fable/.prettierrc.json +3 -0
- package/data/harnesses/code-fable/.vscode/settings.json +10 -0
- package/data/harnesses/code-fable/harness.json +6 -1
- package/data/harnesses/discover/.prettierignore +12 -0
- package/data/harnesses/discover/.prettierrc.json +3 -0
- package/data/harnesses/discover/.vscode/settings.json +10 -0
- package/data/harnesses/discover/harness.json +6 -1
- package/data/harnesses/hr/.prettierignore +12 -0
- package/data/harnesses/hr/.prettierrc.json +3 -0
- package/data/harnesses/hr/.vscode/settings.json +10 -0
- package/data/harnesses/hr/harness.json +6 -1
- package/data/harnesses/kb-agi/.prettierignore +12 -0
- package/data/harnesses/kb-agi/.prettierrc.json +3 -0
- package/data/harnesses/kb-agi/.vscode/settings.json +10 -0
- package/data/harnesses/kb-agi/harness.json +6 -1
- package/package.json +4 -1
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
|
|
4
|
-
// directory.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
//
|
|
233
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
381
|
+
p.log.error(e?.stack || String(e));
|
|
301
382
|
process.exit(1);
|
|
302
383
|
});
|
package/data/harness-index.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.17.
|
|
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/",
|