@nurix/etna 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -1,35 +1,36 @@
1
1
  # @nurix/etna
2
2
 
3
- CLI to bootstrap a project with a Nurix harness. Installs a Claude Code plugin from the `nurixlabs/skills` marketplace and drops the harness's `CLAUDE.md` at the project root.
3
+ CLI to bootstrap a project with a Nurix harness. Produces the same on-disk state as running
4
4
 
5
- See [`../../harness/README.md`](../../harness/README.md) for what a harness is.
5
+ ```
6
+ /plugin marketplace add nurixlabs/skills
7
+ /plugin install nurix-harness@nurixlabs
8
+ ```
9
+
10
+ inside Claude Code, plus drops the harness's `CLAUDE.md` at the project root. See [`../../harness/README.md`](../../harness/README.md) for what a harness is and why the install model is designed this way.
6
11
 
7
12
  ## Usage
8
13
 
9
14
  From the root of the project you want to bootstrap:
10
15
 
11
16
  ```sh
12
- GITHUB_TOKEN=ghp_... npx @nurix/etna nurix-harness
17
+ npx @nurix/etna nurix-harness
13
18
  ```
14
19
 
15
- After this completes, the directory contains:
20
+ After this completes, three things have changed on disk:
16
21
 
17
22
  ```
18
23
  your-project/
19
- ├── CLAUDE.md ← from harness/<folder>/CLAUDE.md (bundled in this package)
24
+ ├── CLAUDE.md ← bundled in this package; the harness's policy
20
25
  └── .claude/
21
- ├── settings.json ← enabledPlugins updated
22
- └── plugins/
23
- └── nurix-harness/ ← fetched from nurixlabs/skills/plugins/nurix-harness/
24
- ├── .claude-plugin/plugin.json
25
- ├── skills/{documentation, workflow-mapper, introspect}/
26
- ├── agents/{change-review, contract-monitor, dep-health, git-history, maintenance, review, spec-mode}.md
27
- ├── scripts/{build-ownership-index, check-doc-drift}.sh
28
- ├── rules/{general, nomenclature}.md
29
- └── README.md
26
+ └── settings.json ← enabledPlugins["nurix-harness@nurixlabs"] = true
27
+
28
+ ~/.claude/settings.json ← extraKnownMarketplaces["nurixlabs"] registered
30
29
  ```
31
30
 
32
- Open Claude Code in the project; it will pick up the plugin on next start. If Claude Code is already running, run `/reload-plugins`.
31
+ That's the entire footprint. Open Claude Code in the project; on next session start it resolves `nurix-harness@nurixlabs`, clones `nurixlabs/skills` into `~/.claude/plugins/marketplaces/`, fills the plugin cache at `~/.claude/plugins/cache/nurixlabs/nurix-harness/<version>/`, and loads the plugin. The CLI does not touch any of those Claude Code internals itself — that's deliberate (see [Installation goals](../../harness/README.md#installation-goals)).
32
+
33
+ If Claude Code is already running, run `/reload-plugins`.
33
34
 
34
35
  ## Available harnesses
35
36
 
@@ -37,28 +38,21 @@ Listed by `npx @nurix/etna` (no args) using the bundled `data/harness-index.json
37
38
 
38
39
  | Plugin name | Harness folder in etna | What it ships |
39
40
  |---|---|---|
40
- | `nurix-harness` | [`harness/code/`](../../harness/code/) | The canonical v7 harness — 7 audit agents, 3 workflow skills, 2 rules, 2 scripts |
41
-
42
- ## Environment
43
-
44
- | Variable | Required | Purpose |
45
- |---|---|---|
46
- | `GITHUB_TOKEN` | Yes (while `nurixlabs/skills` is private) | Personal access token with `repo:read` scope. Access to `nurixlabs/skills` is the access control for the harness. |
47
- | `NURIX_SKILLS_REF` | No | Git ref to fetch from in `nurixlabs/skills`. Default `dev`. |
41
+ | `nurix-harness` | [`harness/code/`](../../harness/code/) | The canonical v7 harness — governance agents, baseline workflow skills, ownership/drift scripts |
48
42
 
49
43
  ## How it works
50
44
 
51
- 1. Looks up `<plugin-name>` in the bundled `data/harness-index.json` to find the matching harness folder.
52
- 2. Walks `nurixlabs/skills/plugins/<plugin-name>/` via the GitHub Contents API and downloads each file into `./.claude/plugins/<plugin-name>/`.
53
- 3. Copies the bundled `data/harnesses/<harness-folder>/CLAUDE.md` to `./CLAUDE.md`.
54
- 4. Adds `<plugin-name>@nurixlabs: true` to `./.claude/settings.json` under `enabledPlugins`.
45
+ 1. Looks up `<plugin-name>` in the bundled `data/harness-index.json` to find the matching harness folder and the marketplace metadata.
46
+ 2. Copies the bundled `data/harnesses/<harness-folder>/CLAUDE.md` to `./CLAUDE.md` (backing up any existing one to `CLAUDE.md.bak.<ISO-timestamp>`).
47
+ 3. Adds the marketplace entry to `~/.claude/settings.json` under `extraKnownMarketplaces`.
48
+ 4. Adds `<plugin-name>@<marketplace>: true` to `./.claude/settings.json` under `enabledPlugins`.
55
49
 
56
- Existing `CLAUDE.md` or plugin folder at the destination is renamed to `<name>.bak.<ISO-timestamp>` before install. Nothing is overwritten.
50
+ No GitHub fetch. No `.claude/plugins/<name>/` folder. Claude Code's plugin resolver handles all of that on next launch.
57
51
 
58
52
  ## Why two sources
59
53
 
60
- - **Plugin content** comes from the marketplace (`nurixlabs/skills`) so it's always the latest CI-published version.
61
- - **`CLAUDE.md`** is bundled in this npm package, versioned with the package. Harness policy changes warrant a package version bump.
54
+ - **Plugin content** lives in the marketplace (`nurixlabs/skills`) and is fetched by Claude Code at session start — always the latest CI-published version.
55
+ - **`CLAUDE.md`** is bundled in this npm package, versioned with the package. A plugin cannot ship a project-root `CLAUDE.md` (Claude Code's plugin spec doesn't support it), so the CLI ships that half itself. Harness policy changes warrant a package version bump.
62
56
 
63
57
  ## Building / contributing
64
58
 
@@ -73,10 +67,4 @@ npm run build:data
73
67
 
74
68
  ## Publishing
75
69
 
76
- From `packages/cli/`:
77
-
78
- ```sh
79
- npm publish --access restricted
80
- ```
81
-
82
- The `publishConfig.access` is `restricted` so the scoped package is private by default.
70
+ CI auto-publishes on push to `dev` when `packages/cli/**` or `harness/**` change — see [`.github/workflows/publish-etna-to-npm.yml`](../../.github/workflows/publish-etna-to-npm.yml). The `publishConfig.access` is `restricted` so the scoped package is private by default.
package/bin/etna.js CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // packages/cli/bin/etna.js
3
- // `npx @nurix/etna <plugin-name>` — install a Nurix harness into the current directory.
3
+ // `npx @nurix/etna <plugin-name>` — bootstrap a Nurix harness in the current directory.
4
+ // Produces the same on-disk state as running `/plugin marketplace add <repo>` followed by
5
+ // `/plugin install <plugin>@<marketplace>` inside Claude Code, plus drops the harness's
6
+ // CLAUDE.md at the project root. Claude Code's plugin resolver fills the cache on next launch.
4
7
 
5
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, lstatSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
6
10
  import { join, dirname, resolve } from 'node:path';
7
11
  import { fileURLToPath } from 'node:url';
8
12
 
@@ -10,20 +14,17 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
10
14
  const pkgRoot = resolve(__dirname, '..');
11
15
  const dataDir = join(pkgRoot, 'data');
12
16
 
13
- const SKILLS_REPO = 'nurixlabs/skills';
14
- const SKILLS_REF = process.env.NURIX_SKILLS_REF || 'dev';
15
-
16
17
  function usage() {
17
- console.log(`\nUsage: npx @nurix/etna <plugin-name>\n`);
18
18
  const idx = loadHarnessIndex();
19
+ console.log(`\nUsage: npx @nurix/etna <plugin-name>\n`);
19
20
  const names = Object.keys(idx.plugins);
20
21
  if (names.length) {
21
- console.log(`Available harnesses (plugin → harness folder):`);
22
- for (const name of names) console.log(` ${name.padEnd(20)} → harness/${idx.plugins[name]}`);
22
+ console.log(`Available harnesses (plugin → marketplace):`);
23
+ for (const name of names) {
24
+ console.log(` ${name.padEnd(20)} → ${idx.marketplace.name}`);
25
+ }
23
26
  }
24
- console.log(`\nEnvironment:`);
25
- console.log(` GITHUB_TOKEN Required if ${SKILLS_REPO} is private (token with repo:read scope)`);
26
- console.log(` NURIX_SKILLS_REF Git ref to fetch from (default: dev)`);
27
+ console.log(`\nMarketplace: ${idx.marketplace.source.repo} (github)`);
27
28
  console.log();
28
29
  }
29
30
 
@@ -40,54 +41,6 @@ function die(msg, code = 1) {
40
41
  process.exit(code);
41
42
  }
42
43
 
43
- function ghHeaders() {
44
- const h = {
45
- Accept: 'application/vnd.github+json',
46
- 'User-Agent': '@nurix/etna',
47
- 'X-GitHub-Api-Version': '2022-11-28',
48
- };
49
- if (process.env.GITHUB_TOKEN) h.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
50
- return h;
51
- }
52
-
53
- // Recursively download a GitHub repo subdirectory into a local destination.
54
- async function fetchPluginFolder(pluginName, destDir) {
55
- const apiUrl = `https://api.github.com/repos/${SKILLS_REPO}/contents/plugins/${pluginName}?ref=${SKILLS_REF}`;
56
- await fetchDir(apiUrl, destDir);
57
- }
58
-
59
- async function fetchDir(apiUrl, destDir) {
60
- const res = await fetch(apiUrl, { headers: ghHeaders() });
61
- if (res.status === 401 || res.status === 403) {
62
- die(
63
- `GitHub returned ${res.status} for ${apiUrl}.\n` +
64
- `${SKILLS_REPO} is private — set GITHUB_TOKEN (a personal access token with repo:read scope).\n` +
65
- ` export GITHUB_TOKEN=ghp_...`,
66
- );
67
- }
68
- if (res.status === 404) {
69
- die(`Plugin not found in ${SKILLS_REPO}@${SKILLS_REF}. Has the marketplace publish pipeline run yet?`);
70
- }
71
- if (!res.ok) die(`GitHub API ${res.status}: ${await res.text()}`);
72
-
73
- const items = await res.json();
74
- mkdirSync(destDir, { recursive: true });
75
-
76
- for (const item of items) {
77
- const localPath = join(destDir, item.name);
78
- if (item.type === 'dir') {
79
- await fetchDir(item.url, localPath);
80
- } else if (item.type === 'file') {
81
- const dl = item.download_url;
82
- if (!dl) die(`File ${item.path} has no download_url`);
83
- const fileRes = await fetch(dl, { headers: ghHeaders() });
84
- if (!fileRes.ok) die(`Failed to download ${item.path}: ${fileRes.status}`);
85
- const buf = Buffer.from(await fileRes.arrayBuffer());
86
- writeFileSync(localPath, buf);
87
- }
88
- }
89
- }
90
-
91
44
  // Move <path> to <path>.bak.<timestamp> if it exists. Returns the backup path, or null.
92
45
  function backupIfExists(path) {
93
46
  if (!existsSync(path) && !isSymlink(path)) return null;
@@ -105,28 +58,42 @@ function isSymlink(path) {
105
58
  }
106
59
  }
107
60
 
108
- // Add pluginName to enabledPlugins in .claude/settings.json (creating the file if needed).
109
- // Uses the conventional shape `"plugin-name@marketplace": true`.
110
- function enablePlugin(targetDir, pluginName) {
111
- const claudeDir = join(targetDir, '.claude');
112
- mkdirSync(claudeDir, { recursive: true });
113
- const settingsPath = join(claudeDir, 'settings.json');
114
-
115
- let settings = {};
116
- if (existsSync(settingsPath)) {
117
- try {
118
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
119
- } catch (e) {
120
- die(`Failed to parse existing ${settingsPath}: ${e.message}`);
121
- }
61
+ function readJson(path) {
62
+ if (!existsSync(path)) return {};
63
+ try {
64
+ return JSON.parse(readFileSync(path, 'utf8'));
65
+ } catch (e) {
66
+ die(`failed to parse ${path}: ${e.message}`);
122
67
  }
68
+ }
123
69
 
70
+ // 2-space indent matches Claude Code's own writes to settings.json.
71
+ function writeJson(path, value) {
72
+ mkdirSync(dirname(path), { recursive: true });
73
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
74
+ }
75
+
76
+ // Register the marketplace in ~/.claude/settings.json (extraKnownMarketplaces).
77
+ // Mirrors what `/plugin marketplace add <owner>/<repo>` writes.
78
+ function registerMarketplace(marketplace) {
79
+ const path = join(homedir(), '.claude', 'settings.json');
80
+ const settings = readJson(path);
81
+ settings.extraKnownMarketplaces = settings.extraKnownMarketplaces || {};
82
+ settings.extraKnownMarketplaces[marketplace.name] = { source: marketplace.source };
83
+ writeJson(path, settings);
84
+ return path;
85
+ }
86
+
87
+ // Add `<plugin>@<marketplace>: true` to <target>/.claude/settings.json under enabledPlugins.
88
+ // Mirrors what `/plugin install <plugin>@<marketplace>` writes at project scope.
89
+ function enablePlugin(targetDir, pluginName, marketplaceName) {
90
+ const path = join(targetDir, '.claude', 'settings.json');
91
+ const settings = readJson(path);
124
92
  settings.enabledPlugins = settings.enabledPlugins || {};
125
- const key = `${pluginName}@nurixlabs`;
93
+ const key = `${pluginName}@${marketplaceName}`;
126
94
  settings.enabledPlugins[key] = true;
127
-
128
- writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
129
- return { settingsPath, key };
95
+ writeJson(path, settings);
96
+ return { path, key };
130
97
  }
131
98
 
132
99
  async function main(args) {
@@ -150,32 +117,28 @@ async function main(args) {
150
117
  }
151
118
 
152
119
  const targetDir = process.cwd();
153
- const pluginDir = join(targetDir, '.claude', 'plugins', pluginName);
154
120
  const claudeMdDest = join(targetDir, 'CLAUDE.md');
121
+ const marketplace = idx.marketplace;
122
+ const fqKey = `${pluginName}@${marketplace.name}`;
155
123
 
156
- console.log(`\nInstalling ${pluginName} (from harness/${harnessFolder}) into ${targetDir}`);
124
+ console.log(`\nInstalling ${fqKey} into ${targetDir}`);
157
125
 
158
- // 1. Backup conflicts
159
- const pluginBackup = backupIfExists(pluginDir);
160
- if (pluginBackup) console.log(` backed up existing plugin folder → ${pluginBackup}`);
161
126
  const claudeBackup = backupIfExists(claudeMdDest);
162
127
  if (claudeBackup) console.log(` backed up existing CLAUDE.md → ${claudeBackup}`);
163
128
 
164
- // 2. Fetch plugin from nurixlabs/skills marketplace
165
- console.log(` fetching plugin from ${SKILLS_REPO}@${SKILLS_REF}…`);
166
- await fetchPluginFolder(pluginName, pluginDir);
167
- console.log(` installed plugin → ${pluginDir}`);
168
-
169
- // 3. Copy bundled CLAUDE.md to project root
170
129
  copyFileSync(bundledClaude, claudeMdDest);
171
130
  console.log(` installed CLAUDE.md → ${claudeMdDest}`);
172
131
 
173
- // 4. Enable plugin in .claude/settings.json
174
- const { settingsPath, key } = enablePlugin(targetDir, pluginName);
175
- console.log(` enabled '${key}' in ${settingsPath}`);
132
+ const marketplacePath = registerMarketplace(marketplace);
133
+ console.log(` registered marketplace '${marketplace.name}' → ${marketplacePath}`);
134
+
135
+ const { path: enablePath } = enablePlugin(targetDir, pluginName, marketplace.name);
136
+ console.log(` enabled '${fqKey}' → ${enablePath}`);
176
137
 
177
- console.log(`\nDone. Open Claude Code in this directory; it will load the plugin on next start.`);
178
- console.log(`Run /plugin to verify, or /reload-plugins if it's already running.\n`);
138
+ console.log(`\nDone. Open Claude Code in ${targetDir}.`);
139
+ console.log(
140
+ `It will fetch ${marketplace.source.repo}, install ${pluginName}, and load it on next session start.\n`,
141
+ );
179
142
  }
180
143
 
181
144
  main(process.argv.slice(2)).catch((e) => {
@@ -1,4 +1,11 @@
1
1
  {
2
+ "marketplace": {
3
+ "name": "nurixlabs",
4
+ "source": {
5
+ "source": "github",
6
+ "repo": "nurixlabs/skills"
7
+ }
8
+ },
2
9
  "plugins": {
3
10
  "nurix-harness": "code"
4
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Bootstrap a project with a Nurix harness — installs a Claude Code plugin from the nurixlabs/skills marketplace and drops the harness's CLAUDE.md at the project root.",
5
5
  "type": "module",
6
6
  "bin": {