@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 +25 -37
- package/bin/etna.js +55 -92
- package/data/harness-index.json +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,35 +1,36 @@
|
|
|
1
1
|
# @nurix/etna
|
|
2
2
|
|
|
3
|
-
CLI to bootstrap a project with a Nurix harness.
|
|
3
|
+
CLI to bootstrap a project with a Nurix harness. Produces the same on-disk state as running
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
17
|
+
npx @nurix/etna nurix-harness
|
|
13
18
|
```
|
|
14
19
|
|
|
15
|
-
After this completes,
|
|
20
|
+
After this completes, three things have changed on disk:
|
|
16
21
|
|
|
17
22
|
```
|
|
18
23
|
your-project/
|
|
19
|
-
├── CLAUDE.md
|
|
24
|
+
├── CLAUDE.md ← bundled in this package; the harness's policy
|
|
20
25
|
└── .claude/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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 —
|
|
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.
|
|
53
|
-
3.
|
|
54
|
-
4. Adds `<plugin-name
|
|
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
|
-
|
|
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**
|
|
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
|
-
|
|
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>` —
|
|
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 →
|
|
22
|
-
for (const name of names)
|
|
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(`\
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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}
|
|
93
|
+
const key = `${pluginName}@${marketplaceName}`;
|
|
126
94
|
settings.enabledPlugins[key] = true;
|
|
127
|
-
|
|
128
|
-
|
|
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 ${
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
178
|
-
console.log(
|
|
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) => {
|
package/data/harness-index.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.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": {
|