@rune-kit/rune 2.18.0 → 2.18.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/README.md +5 -1
- package/compiler/__tests__/setup.test.js +333 -7
- package/compiler/commands/setup.js +190 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,7 +83,11 @@ _Methodology: Claude Code CLI headless mode (`claude -p --output-format json`),
|
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
86
|
-
## What's New (v2.18.
|
|
86
|
+
## What's New (v2.18.1 — Setup Installs Tier Skills, Not Just Hooks)
|
|
87
|
+
|
|
88
|
+
> **v2.18.1 (2026-05-17):** Bug fix — `rune setup --tier pro|business` now copies the tier's `skills/` directories into the Free plugin's `skills/` folder. Before this fix, paid tiers shipped hooks only, so `rune:autopilot` (Pro) returned `Unknown skill: rune:autopilot` because the SKILL.md was at `Pro/skills/autopilot/` but invisible to the Claude Code plugin runtime. New `installTierSkills` in `compiler/commands/setup.js` runs after `installHooks`, copies each `<tierRoot>/skills/<name>/` into `<runeRoot>/skills/`, idempotent (skips existing — protects Free skills from clobber and user-edited Pro skills from stomp), with path-traversal guard + symlink rejection + partial-copy cleanup + version-drift detection. Paired with Pro `autopilot-v1.5.0` (Step 0 LOAD now reads user-message context for plan path — same pattern cook uses). 25/26 setup tests pass (1 skipped on Windows — symlink test needs admin/dev-mode). Full CI 1444 tests.
|
|
89
|
+
|
|
90
|
+
### Previous (v2.18.0 — Cross-Platform Reach + Discipline Tightening)
|
|
87
91
|
|
|
88
92
|
> **v2.18.0 (2026-05-15):** Compiler grew from 8 → 13 platforms with five new adapters: **Aider** (per-skill `aider/rules/` + auto-generated `.aider.conf.yml` `read:` array), **GitHub Copilot CLI** (`.github/instructions/*.instructions.md` w/ documented `applyTo` YAML), **Gemini CLI** (bundled `GEMINI.md` for single-file context), **Qoder** (`.qoder/rules/` + AGENTS.md), **Qwen Coder** (`qwen/skills/` + `QWEN.md` with `@import`). New `adapter.generateExtraFiles()` hook with path-traversal guard + frozen stats snapshot — replaces ad-hoc adapter special-cases (codex AGENTS.md migrated). Discipline tightening: `design` v0.6.0 adds Step 2.9 Rules 4/5/6 (measurable constraints, no #000/#fff/lorem ipsum, CJK-first font stack); `skill-forge` v1.9.0 adds soft `examples/` convention for output-format skills; `sentinel-env` v0.4.0 expands Tier 8 binary detection (Bun, Cargo, Deno, Volta, asdf, proto). New `CONTRIBUTING.md` "What we don't accept" non-goals section. Source: graft from `nexu-io/html-anything` (Apache-2.0). CI 1435/1435.
|
|
89
93
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { platform as osPlatform, tmpdir } from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { afterEach, beforeEach, describe, test } from 'node:test';
|
|
6
|
-
import {
|
|
7
|
+
import { resolveTier } from '../commands/hooks/tiers.js';
|
|
8
|
+
import { detectTiers, formatSetupResult, installTierSkills, runSetup } from '../commands/setup.js';
|
|
7
9
|
|
|
8
10
|
let tmpRoot;
|
|
9
11
|
|
|
@@ -11,11 +13,11 @@ async function seedClaude(root) {
|
|
|
11
13
|
await mkdir(path.join(root, '.claude'), { recursive: true });
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
async function seedTier(root, tier) {
|
|
15
|
-
const
|
|
16
|
-
await mkdir(
|
|
16
|
+
async function seedTier(root, tier, opts = {}) {
|
|
17
|
+
const tierRoot = path.join(root, '..', tier === 'pro' ? 'Pro' : 'Business');
|
|
18
|
+
await mkdir(path.join(tierRoot, 'hooks'), { recursive: true });
|
|
17
19
|
await writeFile(
|
|
18
|
-
path.join(
|
|
20
|
+
path.join(tierRoot, 'hooks', 'manifest.json'),
|
|
19
21
|
JSON.stringify({
|
|
20
22
|
tier,
|
|
21
23
|
version: '1.0.0',
|
|
@@ -24,6 +26,19 @@ async function seedTier(root, tier) {
|
|
|
24
26
|
entries: [],
|
|
25
27
|
}),
|
|
26
28
|
);
|
|
29
|
+
if (Array.isArray(opts.skills)) {
|
|
30
|
+
for (const skill of opts.skills) {
|
|
31
|
+
const skillDir = path.join(tierRoot, 'skills', skill);
|
|
32
|
+
await mkdir(skillDir, { recursive: true });
|
|
33
|
+
await writeFile(path.join(skillDir, 'SKILL.md'), `---\nname: ${skill}\n---\n# ${skill}\n`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function seedFakeRuneRoot(root) {
|
|
39
|
+
const runeRoot = path.join(root, 'fake-rune');
|
|
40
|
+
await mkdir(path.join(runeRoot, 'skills'), { recursive: true });
|
|
41
|
+
return runeRoot;
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
beforeEach(async () => {
|
|
@@ -120,6 +135,256 @@ describe('runSetup (non-interactive)', () => {
|
|
|
120
135
|
});
|
|
121
136
|
});
|
|
122
137
|
|
|
138
|
+
describe('installTierSkills', () => {
|
|
139
|
+
test('copies Pro skill directories into runeRoot/skills', async () => {
|
|
140
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
141
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
142
|
+
await seedTier(projectRoot, 'pro', { skills: ['autopilot', 'context-inject'] });
|
|
143
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
144
|
+
|
|
145
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
146
|
+
|
|
147
|
+
assert.strictEqual(result.tier, 'pro');
|
|
148
|
+
assert.deepStrictEqual(result.installed.sort(), ['autopilot', 'context-inject']);
|
|
149
|
+
assert.deepStrictEqual(result.skipped, []);
|
|
150
|
+
assert.strictEqual(result.reason, null);
|
|
151
|
+
assert.ok(existsSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md')));
|
|
152
|
+
assert.ok(existsSync(path.join(runeRoot, 'skills', 'context-inject', 'SKILL.md')));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('skips skills already present (no Free clobber, no edit stomp)', async () => {
|
|
156
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
157
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
158
|
+
// Pre-existing skill at target (e.g. Free skill with same name, or prior install)
|
|
159
|
+
await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
|
|
160
|
+
await writeFile(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'), '# existing — must not be overwritten');
|
|
161
|
+
await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
|
|
162
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
163
|
+
|
|
164
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
165
|
+
|
|
166
|
+
assert.deepStrictEqual(result.installed, []);
|
|
167
|
+
assert.strictEqual(result.skipped.length, 1);
|
|
168
|
+
assert.strictEqual(result.skipped[0].skill, 'autopilot');
|
|
169
|
+
const preserved = readFileSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'), 'utf-8');
|
|
170
|
+
assert.match(preserved, /existing — must not be overwritten/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('dry mode reports installs without writing', async () => {
|
|
174
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
175
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
176
|
+
await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
|
|
177
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
178
|
+
|
|
179
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot, dry: true });
|
|
180
|
+
|
|
181
|
+
assert.deepStrictEqual(result.installed, ['autopilot']);
|
|
182
|
+
assert.ok(!existsSync(path.join(runeRoot, 'skills', 'autopilot')));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('returns reason when tier has no skills/ dir', async () => {
|
|
186
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
187
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
188
|
+
await seedTier(projectRoot, 'pro'); // no skills option
|
|
189
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
190
|
+
|
|
191
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
192
|
+
|
|
193
|
+
assert.deepStrictEqual(result.installed, []);
|
|
194
|
+
assert.match(result.reason, /no skills\//);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('returns reason when runeRoot/skills target missing', async () => {
|
|
198
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
199
|
+
await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
|
|
200
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
201
|
+
const runeRoot = path.join(tmpRoot, 'missing-rune-root');
|
|
202
|
+
|
|
203
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
204
|
+
|
|
205
|
+
assert.deepStrictEqual(result.installed, []);
|
|
206
|
+
assert.match(result.reason, /target skills\/ missing/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('rejects adversarial skill directory names (path traversal)', async () => {
|
|
210
|
+
// The OS won't let us mkdir literal '..' or names with '/', so the basename
|
|
211
|
+
// guard is unit-tested at the path.basename level. The runtime guard inside
|
|
212
|
+
// installTierSkills uses the same path.basename(name) !== name check.
|
|
213
|
+
assert.strictEqual(path.basename('normal'), 'normal');
|
|
214
|
+
assert.notStrictEqual(path.basename('../escape'), '../escape');
|
|
215
|
+
assert.notStrictEqual(path.basename('a/b'), 'a/b');
|
|
216
|
+
// Integration smoke: a clean install still works after the guard was added
|
|
217
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
218
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
219
|
+
await seedTier(projectRoot, 'pro', { skills: ['normal'] });
|
|
220
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
221
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
222
|
+
assert.deepStrictEqual(result.installed, ['normal']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('rejects relative tierManifest.source path', async () => {
|
|
226
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
227
|
+
const fakeManifest = {
|
|
228
|
+
tier: 'pro',
|
|
229
|
+
source: './relative/manifest.json', // not absolute
|
|
230
|
+
entries: [],
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: fakeManifest, runeRoot });
|
|
234
|
+
|
|
235
|
+
assert.deepStrictEqual(result.installed, []);
|
|
236
|
+
assert.match(result.reason, /must be absolute/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('skips non-directory entries in skills/ (e.g. stray README.md)', async () => {
|
|
240
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
241
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
242
|
+
await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
|
|
243
|
+
// Stray file at sibling of skill dirs — must NOT be installed
|
|
244
|
+
await writeFile(path.join(tmpRoot, 'Pro', 'skills', 'README.md'), '# Pro skills\n');
|
|
245
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
246
|
+
|
|
247
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
248
|
+
|
|
249
|
+
assert.deepStrictEqual(result.installed, ['autopilot']);
|
|
250
|
+
assert.ok(!existsSync(path.join(runeRoot, 'skills', 'README.md')));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('skip message includes version drift when source > installed', async () => {
|
|
254
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
255
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
256
|
+
// Installed v1.0.0 at target
|
|
257
|
+
await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
|
|
258
|
+
await writeFile(
|
|
259
|
+
path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'),
|
|
260
|
+
'---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n# autopilot\n',
|
|
261
|
+
);
|
|
262
|
+
// Seed Pro tier (creates manifest.json) then source skill with v1.5.0
|
|
263
|
+
await seedTier(projectRoot, 'pro');
|
|
264
|
+
await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'autopilot'), { recursive: true });
|
|
265
|
+
await writeFile(
|
|
266
|
+
path.join(tmpRoot, 'Pro', 'skills', 'autopilot', 'SKILL.md'),
|
|
267
|
+
'---\nname: autopilot\nmetadata:\n version: "1.5.0"\n---\n# autopilot\n',
|
|
268
|
+
);
|
|
269
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
270
|
+
|
|
271
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
272
|
+
|
|
273
|
+
assert.deepStrictEqual(result.installed, []);
|
|
274
|
+
assert.strictEqual(result.skipped.length, 1);
|
|
275
|
+
assert.match(result.skipped[0].reason, /stale: installed v1\.0\.0, source has v1\.5\.0/);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('rejects symlink entries inside skills/ (no sandbox escape)', async (t) => {
|
|
279
|
+
// Symlinks on Windows require elevated privileges or Developer Mode — skip cleanly.
|
|
280
|
+
if (osPlatform() === 'win32') {
|
|
281
|
+
t.skip('symlink creation needs admin/dev-mode on Windows');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
285
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
286
|
+
await seedTier(projectRoot, 'pro', { skills: ['real-skill'] });
|
|
287
|
+
// Create a malicious symlink targeting /etc inside the Pro skills/ dir
|
|
288
|
+
const escapeTarget = path.join(tmpRoot, 'attacker-target');
|
|
289
|
+
await mkdir(escapeTarget, { recursive: true });
|
|
290
|
+
await writeFile(path.join(escapeTarget, 'SECRET'), 'should not be reachable');
|
|
291
|
+
await symlink(escapeTarget, path.join(tmpRoot, 'Pro', 'skills', 'escape-link'), 'dir');
|
|
292
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
293
|
+
|
|
294
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
295
|
+
|
|
296
|
+
assert.deepStrictEqual(result.installed, ['real-skill']);
|
|
297
|
+
const rejected = result.skipped.find((s) => s.skill === 'escape-link');
|
|
298
|
+
assert.ok(rejected, 'symlink entry should appear in skipped[]');
|
|
299
|
+
assert.match(rejected.reason, /rejected: symlink/);
|
|
300
|
+
assert.ok(!existsSync(path.join(runeRoot, 'skills', 'escape-link')), 'symlink must not be recreated at target');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('version regex ignores `version:` text inside multiline description', async () => {
|
|
304
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
305
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
306
|
+
// Installed v1.0.0
|
|
307
|
+
await mkdir(path.join(runeRoot, 'skills', 'foo'), { recursive: true });
|
|
308
|
+
await writeFile(
|
|
309
|
+
path.join(runeRoot, 'skills', 'foo', 'SKILL.md'),
|
|
310
|
+
'---\nname: foo\nmetadata:\n version: "1.0.0"\n---\n',
|
|
311
|
+
);
|
|
312
|
+
// Source has DECEPTIVE description with `version: 9.9.9` text inside YAML block scalar;
|
|
313
|
+
// real metadata.version is also 1.0.0 → drift detector must report "same version", NOT "stale v9.9.9 → v1.0.0"
|
|
314
|
+
await seedTier(projectRoot, 'pro');
|
|
315
|
+
await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'foo'), { recursive: true });
|
|
316
|
+
await writeFile(
|
|
317
|
+
path.join(tmpRoot, 'Pro', 'skills', 'foo', 'SKILL.md'),
|
|
318
|
+
'---\nname: foo\ndescription: |\n version: 9.9.9 is the legacy format we used to use\nmetadata:\n version: "1.0.0"\n---\n',
|
|
319
|
+
);
|
|
320
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
321
|
+
|
|
322
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
323
|
+
|
|
324
|
+
// Same version — reason should be "already present (v1.0.0)", NOT a stale-drift message
|
|
325
|
+
assert.strictEqual(result.skipped.length, 1);
|
|
326
|
+
assert.match(result.skipped[0].reason, /already present \(v1\.0\.0\)/);
|
|
327
|
+
assert.doesNotMatch(result.skipped[0].reason, /9\.9\.9/);
|
|
328
|
+
assert.doesNotMatch(result.skipped[0].reason, /stale/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('skip message reports matching version when source == installed', async () => {
|
|
332
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
333
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
334
|
+
await mkdir(path.join(runeRoot, 'skills', 'autopilot'), { recursive: true });
|
|
335
|
+
await writeFile(
|
|
336
|
+
path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md'),
|
|
337
|
+
'---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n',
|
|
338
|
+
);
|
|
339
|
+
await seedTier(projectRoot, 'pro');
|
|
340
|
+
await mkdir(path.join(tmpRoot, 'Pro', 'skills', 'autopilot'), { recursive: true });
|
|
341
|
+
await writeFile(
|
|
342
|
+
path.join(tmpRoot, 'Pro', 'skills', 'autopilot', 'SKILL.md'),
|
|
343
|
+
'---\nname: autopilot\nmetadata:\n version: "1.0.0"\n---\n',
|
|
344
|
+
);
|
|
345
|
+
const manifest = await resolveTier('pro', projectRoot);
|
|
346
|
+
|
|
347
|
+
const result = await installTierSkills({ tier: 'pro', tierManifest: manifest, runeRoot });
|
|
348
|
+
|
|
349
|
+
assert.match(result.skipped[0].reason, /already present \(v1\.0\.0\)/);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('runSetup — Pro skill installation (regression: rune:autopilot Unknown skill)', () => {
|
|
354
|
+
test('installs Pro skills into runeRoot/skills as part of setup', async () => {
|
|
355
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
356
|
+
await seedClaude(projectRoot);
|
|
357
|
+
await seedTier(projectRoot, 'pro', { skills: ['autopilot'] });
|
|
358
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
359
|
+
|
|
360
|
+
const result = await runSetup({
|
|
361
|
+
projectRoot,
|
|
362
|
+
runeRoot,
|
|
363
|
+
args: { here: true, preset: 'gentle', tier: 'pro' },
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
assert.deepStrictEqual(result.tiers, ['pro']);
|
|
367
|
+
assert.strictEqual(result.skillResults.length, 1);
|
|
368
|
+
assert.strictEqual(result.skillResults[0].tier, 'pro');
|
|
369
|
+
assert.deepStrictEqual(result.skillResults[0].installed, ['autopilot']);
|
|
370
|
+
assert.ok(existsSync(path.join(runeRoot, 'skills', 'autopilot', 'SKILL.md')));
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('skillResults empty array when no tier selected', async () => {
|
|
374
|
+
const projectRoot = path.join(tmpRoot, 'project');
|
|
375
|
+
await seedClaude(projectRoot);
|
|
376
|
+
const runeRoot = await seedFakeRuneRoot(tmpRoot);
|
|
377
|
+
|
|
378
|
+
const result = await runSetup({
|
|
379
|
+
projectRoot,
|
|
380
|
+
runeRoot,
|
|
381
|
+
args: { here: true, preset: 'gentle', 'no-tier': true },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
assert.deepStrictEqual(result.skillResults, []);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
123
388
|
describe('formatSetupResult', () => {
|
|
124
389
|
test('renders summary with scope and tiers', () => {
|
|
125
390
|
const out = formatSetupResult({
|
|
@@ -149,4 +414,65 @@ describe('formatSetupResult', () => {
|
|
|
149
414
|
});
|
|
150
415
|
assert.match(out, /GLOBAL/);
|
|
151
416
|
});
|
|
417
|
+
|
|
418
|
+
test('renders skill install summary when skillResults present', () => {
|
|
419
|
+
const out = formatSetupResult({
|
|
420
|
+
scope: 'current',
|
|
421
|
+
targetRoot: '/path/to/project',
|
|
422
|
+
tiers: ['pro'],
|
|
423
|
+
preset: 'gentle',
|
|
424
|
+
platforms: ['claude'],
|
|
425
|
+
written: true,
|
|
426
|
+
notes: [],
|
|
427
|
+
skillResults: [{ tier: 'pro', installed: ['autopilot'], skipped: [], reason: null }],
|
|
428
|
+
});
|
|
429
|
+
assert.match(out, /Skills:.*pro: 1 installed.*autopilot/);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('renders skip + warn lines for tiers with collisions or missing skills/', () => {
|
|
433
|
+
const out = formatSetupResult({
|
|
434
|
+
scope: 'current',
|
|
435
|
+
targetRoot: '/path/to/project',
|
|
436
|
+
tiers: ['pro', 'business'],
|
|
437
|
+
preset: 'gentle',
|
|
438
|
+
platforms: ['claude'],
|
|
439
|
+
written: true,
|
|
440
|
+
notes: [],
|
|
441
|
+
skillResults: [
|
|
442
|
+
{ tier: 'pro', installed: [], skipped: [{ skill: 'autopilot', reason: 'already present' }], reason: null },
|
|
443
|
+
{ tier: 'business', installed: [], skipped: [], reason: 'no skills/ dir at /tmp/Business/skills' },
|
|
444
|
+
],
|
|
445
|
+
});
|
|
446
|
+
assert.match(out, /Skipped:.*pro: 1 already present/);
|
|
447
|
+
assert.match(out, /Skill warn:business: no skills\//);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('partitions skipped into benign vs rejected and surfaces rejection details', () => {
|
|
451
|
+
const out = formatSetupResult({
|
|
452
|
+
scope: 'current',
|
|
453
|
+
targetRoot: '/path/to/project',
|
|
454
|
+
tiers: ['pro'],
|
|
455
|
+
preset: 'gentle',
|
|
456
|
+
platforms: ['claude'],
|
|
457
|
+
written: true,
|
|
458
|
+
notes: [],
|
|
459
|
+
skillResults: [
|
|
460
|
+
{
|
|
461
|
+
tier: 'pro',
|
|
462
|
+
installed: ['safe-skill'],
|
|
463
|
+
skipped: [
|
|
464
|
+
{ skill: 'autopilot', reason: 'already present (v1.0.0)' },
|
|
465
|
+
{ skill: 'evil-link', reason: 'rejected: symlink (would escape sandbox)' },
|
|
466
|
+
{ skill: '../escape', reason: 'rejected: unsafe directory name' },
|
|
467
|
+
],
|
|
468
|
+
reason: null,
|
|
469
|
+
},
|
|
470
|
+
],
|
|
471
|
+
});
|
|
472
|
+
// Counts partitioned and reported together
|
|
473
|
+
assert.match(out, /Skipped:.*pro: 1 already present, 2 rejected/);
|
|
474
|
+
// Each rejected skill surfaced as a warning line (security visibility)
|
|
475
|
+
assert.match(out, /⚠ evil-link: rejected: symlink/);
|
|
476
|
+
assert.match(out, /⚠ \.\.\/escape: rejected: unsafe directory name/);
|
|
477
|
+
});
|
|
152
478
|
});
|
|
@@ -21,11 +21,12 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { cp, readdir, readFile, rm } from 'node:fs/promises';
|
|
24
25
|
import os from 'node:os';
|
|
25
26
|
import path from 'node:path';
|
|
26
27
|
import { createInterface } from 'node:readline';
|
|
27
28
|
import { installHooks } from './hooks/install.js';
|
|
28
|
-
import { TIER_ENV_VARS } from './hooks/tiers.js';
|
|
29
|
+
import { resolveTier, TIER_ENV_VARS } from './hooks/tiers.js';
|
|
29
30
|
|
|
30
31
|
export const WELL_KNOWN_TIER_PATHS = {
|
|
31
32
|
pro: ['D:/Project/Rune/Pro', path.join(os.homedir(), 'rune-pro'), path.join(os.homedir(), 'Project', 'Rune', 'Pro')],
|
|
@@ -38,9 +39,9 @@ export const WELL_KNOWN_TIER_PATHS = {
|
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* @param {{ projectRoot: string, runeRoot: string, args: object }} opts
|
|
41
|
-
* @returns {Promise<{ scope: string, tiers: string[], preset: string, written: boolean, files: string[], notes: string[] }>}
|
|
42
|
+
* @returns {Promise<{ scope: string, tiers: string[], preset: string, written: boolean, files: string[], notes: string[], skillResults: Array<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}> }>}
|
|
42
43
|
*/
|
|
43
|
-
export async function runSetup({ projectRoot, runeRoot
|
|
44
|
+
export async function runSetup({ projectRoot, runeRoot, args = {} }) {
|
|
44
45
|
const detected = detectTiers(projectRoot);
|
|
45
46
|
|
|
46
47
|
// Scope resolution
|
|
@@ -88,16 +89,180 @@ export async function runSetup({ projectRoot, runeRoot: _runeRoot, args = {} })
|
|
|
88
89
|
dry: args.dry,
|
|
89
90
|
});
|
|
90
91
|
|
|
92
|
+
// Install tier skill files into <runeRoot>/skills/ so Claude Code (and any
|
|
93
|
+
// plugin-aware platform) discovers them alongside Free skills. Without this
|
|
94
|
+
// step, paid tiers ship hooks only — `rune:autopilot` returns "Unknown skill"
|
|
95
|
+
// because Pro/skills/autopilot/ is invisible to the plugin runtime.
|
|
96
|
+
const skillResults = [];
|
|
97
|
+
for (const tier of tiers) {
|
|
98
|
+
try {
|
|
99
|
+
const tierManifest = await resolveTier(tier, projectRoot);
|
|
100
|
+
const skillResult = await installTierSkills({
|
|
101
|
+
tier,
|
|
102
|
+
tierManifest,
|
|
103
|
+
runeRoot,
|
|
104
|
+
dry: args.dry,
|
|
105
|
+
});
|
|
106
|
+
skillResults.push(skillResult);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
skillResults.push({ tier, installed: [], skipped: [], reason: err.message });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
91
112
|
return {
|
|
92
113
|
scope,
|
|
93
114
|
targetRoot,
|
|
94
115
|
tiers,
|
|
95
116
|
preset,
|
|
96
117
|
detected,
|
|
118
|
+
skillResults,
|
|
97
119
|
...result,
|
|
98
120
|
};
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Copy a tier's skill directories into the Free plugin's skills/ folder so
|
|
125
|
+
* the Claude Code plugin runtime discovers them with the `rune:` prefix.
|
|
126
|
+
*
|
|
127
|
+
* Idempotent: existing skill directories are SKIPPED (not overwritten). This
|
|
128
|
+
* protects Free skills from being clobbered by a same-named Pro skill, and
|
|
129
|
+
* protects an in-place edit of a previously-installed Pro skill from being
|
|
130
|
+
* stomped on a re-run.
|
|
131
|
+
*
|
|
132
|
+
* Pre-condition: tierManifest.source points at the absolute path of the tier's
|
|
133
|
+
* manifest.json (set by validateManifest). Tier root = grandparent of that path.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} opts
|
|
136
|
+
* @param {string} opts.tier
|
|
137
|
+
* @param {import('./hooks/tiers.js').TierManifest} opts.tierManifest
|
|
138
|
+
* @param {string} opts.runeRoot — target is `<runeRoot>/skills/`
|
|
139
|
+
* @param {boolean} [opts.dry]
|
|
140
|
+
* @returns {Promise<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}>}
|
|
141
|
+
*/
|
|
142
|
+
export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
|
|
143
|
+
if (!tierManifest?.source) {
|
|
144
|
+
return { tier, installed: [], skipped: [], reason: 'tier manifest source missing' };
|
|
145
|
+
}
|
|
146
|
+
// Defensive: validateManifest accepts any string; only locateTierManifest guarantees absolute.
|
|
147
|
+
// Reject relative to keep tierRoot derivation honest.
|
|
148
|
+
if (!path.isAbsolute(tierManifest.source)) {
|
|
149
|
+
return {
|
|
150
|
+
tier,
|
|
151
|
+
installed: [],
|
|
152
|
+
skipped: [],
|
|
153
|
+
reason: `tier manifest source must be absolute (got ${tierManifest.source})`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const tierRoot = path.dirname(path.dirname(tierManifest.source));
|
|
157
|
+
const sourceDir = path.join(tierRoot, 'skills');
|
|
158
|
+
if (!existsSync(sourceDir)) {
|
|
159
|
+
return { tier, installed: [], skipped: [], reason: `no skills/ dir at ${sourceDir}` };
|
|
160
|
+
}
|
|
161
|
+
if (!runeRoot) {
|
|
162
|
+
return { tier, installed: [], skipped: [], reason: 'runeRoot not provided' };
|
|
163
|
+
}
|
|
164
|
+
const targetDir = path.join(runeRoot, 'skills');
|
|
165
|
+
if (!existsSync(targetDir)) {
|
|
166
|
+
return { tier, installed: [], skipped: [], reason: `target skills/ missing at ${targetDir}` };
|
|
167
|
+
}
|
|
168
|
+
const installed = [];
|
|
169
|
+
const skipped = [];
|
|
170
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (!entry.isDirectory()) {
|
|
173
|
+
// Reject symlinks even when their target is a directory — `cp` with default
|
|
174
|
+
// dereference=false would recreate the symlink and let the Claude Code runtime
|
|
175
|
+
// follow it outside runeRoot/skills/ at read time (POSIX symlink-escape).
|
|
176
|
+
if (entry.isSymbolicLink()) {
|
|
177
|
+
skipped.push({ skill: entry.name, reason: 'rejected: symlink (would escape sandbox)' });
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const skillName = entry.name;
|
|
182
|
+
// Guard against path traversal via adversarial directory names (../, /, \).
|
|
183
|
+
// A compromised tier repo with a skill dir like '../../../etc' would otherwise
|
|
184
|
+
// escape runeRoot/skills/ and overwrite arbitrary files. assertSafeTierName
|
|
185
|
+
// in tiers.js covers the tier name but NOT per-skill directory names.
|
|
186
|
+
if (path.basename(skillName) !== skillName || skillName === '.' || skillName === '..') {
|
|
187
|
+
skipped.push({ skill: skillName, reason: 'rejected: unsafe directory name' });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const src = path.join(sourceDir, skillName);
|
|
191
|
+
const dst = path.join(targetDir, skillName);
|
|
192
|
+
if (existsSync(dst)) {
|
|
193
|
+
const drift = await detectVersionDrift(src, dst);
|
|
194
|
+
skipped.push({ skill: skillName, reason: drift || 'already present' });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!dry) {
|
|
198
|
+
try {
|
|
199
|
+
// dereference: true → copy symlink targets as content instead of recreating
|
|
200
|
+
// symlinks at dst. Belt-and-suspenders alongside the isSymbolicLink reject above
|
|
201
|
+
// (nested symlinks inside a skill dir would otherwise still recreate).
|
|
202
|
+
await cp(src, dst, { recursive: true, dereference: true });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// Clean partial-copy residue so next run isn't silently locked-out by
|
|
205
|
+
// existsSync(dst) on a corrupt half-written directory.
|
|
206
|
+
await rm(dst, { recursive: true, force: true }).catch(() => {});
|
|
207
|
+
skipped.push({ skill: skillName, reason: `copy failed: ${err.message}` });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
installed.push(skillName);
|
|
212
|
+
}
|
|
213
|
+
return { tier, installed, skipped, reason: null };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compare source SKILL.md version against installed version. Returns a string
|
|
218
|
+
* describing the drift if source is newer (so user knows an upgrade exists),
|
|
219
|
+
* or null when versions match / cannot be parsed. Never throws — best-effort.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} srcSkillDir
|
|
222
|
+
* @param {string} dstSkillDir
|
|
223
|
+
* @returns {Promise<string|null>}
|
|
224
|
+
*/
|
|
225
|
+
async function detectVersionDrift(srcSkillDir, dstSkillDir) {
|
|
226
|
+
try {
|
|
227
|
+
const [srcVer, dstVer] = await Promise.all([
|
|
228
|
+
readSkillVersion(path.join(srcSkillDir, 'SKILL.md')),
|
|
229
|
+
readSkillVersion(path.join(dstSkillDir, 'SKILL.md')),
|
|
230
|
+
]);
|
|
231
|
+
if (!srcVer || !dstVer) return null;
|
|
232
|
+
if (srcVer === dstVer) return `already present (v${dstVer})`;
|
|
233
|
+
return `stale: installed v${dstVer}, source has v${srcVer} — delete target dir to upgrade`;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Extract version string from a SKILL.md `metadata.version` field. Minimal
|
|
241
|
+
* YAML scan scoped to the `metadata:` block — avoids false positives from
|
|
242
|
+
* multiline description continuation lines that contain `version:` text
|
|
243
|
+
* (e.g. `description: |\n version: 1.0.0 is legacy`). Avoids pulling in a
|
|
244
|
+
* full YAML dependency for one field.
|
|
245
|
+
*
|
|
246
|
+
* Returns null when SKILL.md cannot be read, frontmatter missing, metadata
|
|
247
|
+
* block missing, or version field absent.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} skillMdPath
|
|
250
|
+
* @returns {Promise<string|null>}
|
|
251
|
+
*/
|
|
252
|
+
async function readSkillVersion(skillMdPath) {
|
|
253
|
+
try {
|
|
254
|
+
const content = await readFile(skillMdPath, 'utf-8');
|
|
255
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
256
|
+
if (!fmMatch) return null;
|
|
257
|
+
// Require metadata: header followed by an indented version: line.
|
|
258
|
+
// Indent is preserved so a top-level `version:` (outside metadata) doesn't match.
|
|
259
|
+
const verMatch = fmMatch[1].match(/^metadata:\s*\n(?:\s+[^\n]*\n)*?\s+version:\s*["']?([^"'\s#]+)/m);
|
|
260
|
+
return verMatch ? verMatch[1] : null;
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
101
266
|
/**
|
|
102
267
|
* Auto-detect Pro/Business tiers across env vars, sibling paths, and well-known
|
|
103
268
|
* locations. Returns { pro: { path, source } | null, business: { ... } | null }.
|
|
@@ -228,6 +393,28 @@ export function formatSetupResult(result) {
|
|
|
228
393
|
lines.push(` Tiers: Free${result.tiers.length > 0 ? ` + ${result.tiers.join(' + ')}` : ''}`);
|
|
229
394
|
lines.push(` Preset: ${result.preset}`);
|
|
230
395
|
lines.push(` Platforms: ${(result.platforms || []).join(', ') || '—'}`);
|
|
396
|
+
for (const sr of result.skillResults || []) {
|
|
397
|
+
if (sr.installed.length > 0) {
|
|
398
|
+
const preview = sr.installed.slice(0, 3).join(', ');
|
|
399
|
+
const more = sr.installed.length > 3 ? `, +${sr.installed.length - 3}` : '';
|
|
400
|
+
lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed (${preview}${more})`);
|
|
401
|
+
}
|
|
402
|
+
if (sr.skipped.length > 0) {
|
|
403
|
+
const rejected = sr.skipped.filter((s) => s.reason.startsWith('rejected:'));
|
|
404
|
+
const benign = sr.skipped.length - rejected.length;
|
|
405
|
+
const parts = [];
|
|
406
|
+
if (benign > 0) parts.push(`${benign} already present`);
|
|
407
|
+
if (rejected.length > 0) parts.push(`${rejected.length} rejected`);
|
|
408
|
+
lines.push(` Skipped: ${sr.tier}: ${parts.join(', ')}`);
|
|
409
|
+
// Surface rejection details so the operator can investigate a compromised tier repo.
|
|
410
|
+
for (const r of rejected) {
|
|
411
|
+
lines.push(` ⚠ ${r.skill}: ${r.reason}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (sr.reason) {
|
|
415
|
+
lines.push(` Skill warn:${sr.tier}: ${sr.reason}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
231
418
|
if (result.notes?.length) {
|
|
232
419
|
lines.push('');
|
|
233
420
|
lines.push(' Notes:');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rune-kit/rune",
|
|
3
|
-
"version": "2.18.
|
|
3
|
+
"version": "2.18.1",
|
|
4
4
|
"description": "64-skill mesh for AI coding assistants — runtime auto-discipline via native hooks (Claude/Cursor/Windsurf/Antigravity), 5-layer architecture, 215+ connections, multi-platform compiler. v2.17 adds quarantine L3 for prompt-injection advisory on untrusted external content.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|