@rune-kit/rune 2.17.1 → 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 +12 -4
- package/compiler/__tests__/adapter-model-mapping.test.js +80 -0
- package/compiler/__tests__/adapters.test.js +115 -3
- package/compiler/__tests__/setup.test.js +333 -7
- package/compiler/adapters/aider.js +120 -0
- package/compiler/adapters/codex.js +33 -0
- package/compiler/adapters/copilot.js +126 -0
- package/compiler/adapters/gemini.js +131 -0
- package/compiler/adapters/index.js +10 -0
- package/compiler/adapters/qoder.js +102 -0
- package/compiler/adapters/qwen.js +111 -0
- package/compiler/bin/rune.js +5 -3
- package/compiler/commands/hooks/drift.js +5 -2
- package/compiler/commands/setup.js +193 -4
- package/compiler/emitter.js +38 -41
- package/hooks/session-start/index.cjs +91 -0
- package/package.json +1 -1
- package/skills/asset-creator/SKILL.md +1 -1
- package/skills/audit/SKILL.md +20 -2
- package/skills/autopsy/SKILL.md +173 -2
- package/skills/brainstorm/SKILL.md +24 -1
- package/skills/browser-pilot/SKILL.md +16 -1
- package/skills/debug/SKILL.md +4 -2
- package/skills/deploy/SKILL.md +72 -2
- package/skills/design/SKILL.md +50 -3
- package/skills/launch/SKILL.md +11 -1
- package/skills/marketing/SKILL.md +1 -1
- package/skills/neural-memory/SKILL.md +1 -1
- package/skills/perf/SKILL.md +93 -2
- package/skills/sentinel-env/SKILL.md +2 -2
- package/skills/skill-forge/SKILL.md +47 -1
- package/skills/surgeon/SKILL.md +1 -1
- package/skills/team/SKILL.md +27 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<strong>Less skills. Deeper connections.</strong><br>
|
|
7
7
|
A lean, interconnected skill ecosystem for AI coding assistants.<br>
|
|
8
|
-
64 skills · 203 connections · 40 signals ·
|
|
8
|
+
64 skills · 203 connections · 40 signals · 13 platforms · MIT
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
|
-
<strong>Claude Code</strong> (native plugin) · <strong>Cursor</strong> · <strong>Windsurf</strong> · <strong>Google Antigravity</strong> · <strong>OpenAI Codex</strong> · <strong>OpenCode</strong> · any AI IDE
|
|
19
|
+
<strong>Claude Code</strong> (native plugin) · <strong>Cursor</strong> · <strong>Windsurf</strong> · <strong>Google Antigravity</strong> · <strong>OpenAI Codex</strong> · <strong>OpenCode</strong> · <strong>Aider</strong> · <strong>GitHub Copilot CLI</strong> · <strong>Gemini CLI</strong> · <strong>Qoder</strong> · <strong>Qwen Coder</strong> · any AI IDE
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
## Why Rune?
|
|
@@ -83,7 +83,15 @@ _Methodology: Claude Code CLI headless mode (`claude -p --output-format json`),
|
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
86
|
-
## What's New (v2.
|
|
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)
|
|
91
|
+
|
|
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.
|
|
93
|
+
|
|
94
|
+
### Previous (v2.17.1 — One-Command Setup Wizard)
|
|
87
95
|
|
|
88
96
|
> **v2.17.1 (2026-05-06):** New `rune setup` interactive wizard collapses the multi-step `cd <project> && export RUNE_PRO_ROOT && rune hooks install --preset gentle --tier pro` workflow into one command — auto-detects Pro/Business tiers across env var / sibling / well-known paths, asks for scope (current project / global) + preset, installs hooks. New `--global` flag on `rune hooks install` writes to `~/.claude/settings.json` (every Claude Code session, regardless of project). Non-interactive mode via `--here` / `--global` / `--tier` / `--preset` / `--dry` flags. Anti-paywall — wizard ships in Free, NOT Pro/Business (tier-agnostic infrastructure UX). Doc sweep: README "One-Command Setup", HOOKS.md restructure, agent skill-routing row for "set up rune". CI 1376/1376.
|
|
89
97
|
|
|
@@ -243,7 +251,7 @@ The wizard auto-detects what you have:
|
|
|
243
251
|
```
|
|
244
252
|
Rune Setup Wizard
|
|
245
253
|
──────────────────
|
|
246
|
-
Free version: 2.
|
|
254
|
+
Free version: 2.18.0 (cached)
|
|
247
255
|
Pro detected: sibling (../Pro) (v1.1.0)
|
|
248
256
|
Business: not detected
|
|
249
257
|
|
|
@@ -131,6 +131,86 @@ describe('Anthropic-native adapters are no-op for model field', () => {
|
|
|
131
131
|
});
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
describe('qoder adapter model mapping (provider-agnostic)', () => {
|
|
135
|
+
const qoder = getAdapter('qoder');
|
|
136
|
+
|
|
137
|
+
test('opus translates to tier:heavy', () => {
|
|
138
|
+
const header = qoder.generateHeader({ ...baseSkill, model: 'opus' });
|
|
139
|
+
assert.match(header, /model: tier:heavy/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('omitted model produces no model line', () => {
|
|
143
|
+
const header = qoder.generateHeader({ ...baseSkill });
|
|
144
|
+
assert.doesNotMatch(header, /model:/);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('copilot adapter model mapping (tier-hint comment)', () => {
|
|
149
|
+
const copilot = getAdapter('copilot');
|
|
150
|
+
|
|
151
|
+
test('opus emits tier-hint:tier:heavy as comment (Copilot ignores model field)', () => {
|
|
152
|
+
const header = copilot.generateHeader({ ...baseSkill, model: 'opus' });
|
|
153
|
+
assert.match(header, /tier-hint: tier:heavy/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('omitted model produces no tier-hint line', () => {
|
|
157
|
+
const header = copilot.generateHeader({ ...baseSkill });
|
|
158
|
+
assert.doesNotMatch(header, /tier-hint/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('aider adapter model mapping (inline header)', () => {
|
|
163
|
+
const aider = getAdapter('aider');
|
|
164
|
+
|
|
165
|
+
test('opus appears as tier:heavy inline', () => {
|
|
166
|
+
const header = aider.generateHeader({ ...baseSkill, model: 'opus' });
|
|
167
|
+
assert.match(header, /tier:heavy/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('omitted model produces no tier line', () => {
|
|
171
|
+
const header = aider.generateHeader({ ...baseSkill });
|
|
172
|
+
assert.doesNotMatch(header, /tier:/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('qwen adapter model mapping (Qwen family)', () => {
|
|
177
|
+
const qwen = getAdapter('qwen');
|
|
178
|
+
|
|
179
|
+
test('opus translates to qwen3-coder-plus', () => {
|
|
180
|
+
const header = qwen.generateHeader({ ...baseSkill, model: 'opus' });
|
|
181
|
+
assert.match(header, /qwen3-coder-plus/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('sonnet translates to qwen3-coder', () => {
|
|
185
|
+
const header = qwen.generateHeader({ ...baseSkill, model: 'sonnet' });
|
|
186
|
+
assert.match(header, /qwen3-coder(?!-)/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('haiku translates to qwen3-coder-flash', () => {
|
|
190
|
+
const header = qwen.generateHeader({ ...baseSkill, model: 'haiku' });
|
|
191
|
+
assert.match(header, /qwen3-coder-flash/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('gemini adapter model mapping (Gemini family)', () => {
|
|
196
|
+
const gemini = getAdapter('gemini');
|
|
197
|
+
|
|
198
|
+
test('opus translates to gemini-2.5-pro', () => {
|
|
199
|
+
const header = gemini.generateHeader({ ...baseSkill, model: 'opus' });
|
|
200
|
+
assert.match(header, /gemini-2\.5-pro/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('sonnet translates to gemini-2.5-flash', () => {
|
|
204
|
+
const header = gemini.generateHeader({ ...baseSkill, model: 'sonnet' });
|
|
205
|
+
assert.match(header, /gemini-2\.5-flash/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('haiku translates to gemini-2.0-flash-lite', () => {
|
|
209
|
+
const header = gemini.generateHeader({ ...baseSkill, model: 'haiku' });
|
|
210
|
+
assert.match(header, /gemini-2\.0-flash-lite/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
134
214
|
describe('cross-adapter consistency', () => {
|
|
135
215
|
test('all 5 non-Anthropic adapters emit a model line for opus skills', () => {
|
|
136
216
|
const adapterNames = ['codex', 'antigravity', 'opencode', 'openclaw', 'generic'];
|
|
@@ -4,9 +4,23 @@ import { getAdapter, listPlatforms } from '../adapters/index.js';
|
|
|
4
4
|
|
|
5
5
|
// --- Adapter registry ---
|
|
6
6
|
|
|
7
|
-
test('listPlatforms returns all
|
|
7
|
+
test('listPlatforms returns all 13 platform adapters', () => {
|
|
8
8
|
const adapters = listPlatforms();
|
|
9
|
-
const expected = [
|
|
9
|
+
const expected = [
|
|
10
|
+
'claude',
|
|
11
|
+
'cursor',
|
|
12
|
+
'windsurf',
|
|
13
|
+
'antigravity',
|
|
14
|
+
'codex',
|
|
15
|
+
'opencode',
|
|
16
|
+
'openclaw',
|
|
17
|
+
'generic',
|
|
18
|
+
'aider',
|
|
19
|
+
'copilot',
|
|
20
|
+
'gemini',
|
|
21
|
+
'qoder',
|
|
22
|
+
'qwen',
|
|
23
|
+
];
|
|
10
24
|
for (const name of expected) {
|
|
11
25
|
assert.ok(adapters.includes(name), `missing adapter: ${name}`);
|
|
12
26
|
}
|
|
@@ -35,7 +49,19 @@ const REQUIRED_METHODS = [
|
|
|
35
49
|
|
|
36
50
|
const REQUIRED_PROPS = ['name', 'outputDir', 'fileExtension', 'skillPrefix', 'skillSuffix'];
|
|
37
51
|
|
|
38
|
-
const ADAPTER_NAMES = [
|
|
52
|
+
const ADAPTER_NAMES = [
|
|
53
|
+
'cursor',
|
|
54
|
+
'windsurf',
|
|
55
|
+
'antigravity',
|
|
56
|
+
'codex',
|
|
57
|
+
'opencode',
|
|
58
|
+
'generic',
|
|
59
|
+
'aider',
|
|
60
|
+
'copilot',
|
|
61
|
+
'gemini',
|
|
62
|
+
'qoder',
|
|
63
|
+
'qwen',
|
|
64
|
+
];
|
|
39
65
|
|
|
40
66
|
for (const adapterName of ADAPTER_NAMES) {
|
|
41
67
|
describe(`${adapterName} adapter contract`, () => {
|
|
@@ -107,3 +133,89 @@ test('codex adapter uses skill directories', () => {
|
|
|
107
133
|
assert.strictEqual(codex.useSkillDirectories, true);
|
|
108
134
|
assert.strictEqual(codex.skillFileName, 'SKILL.md');
|
|
109
135
|
});
|
|
136
|
+
|
|
137
|
+
// --- New v2.18 adapters: shape + generateExtraFiles contract ---
|
|
138
|
+
|
|
139
|
+
test('qoder adapter targets .qoder/rules and emits AGENTS.md', async () => {
|
|
140
|
+
const qoder = getAdapter('qoder');
|
|
141
|
+
assert.strictEqual(qoder.outputDir, '.qoder/rules');
|
|
142
|
+
assert.strictEqual(qoder.useSkillDirectories, false);
|
|
143
|
+
assert.strictEqual(typeof qoder.generateExtraFiles, 'function');
|
|
144
|
+
const extras = await qoder.generateExtraFiles({ stats: { skillCount: 5, packCount: 1, files: [] } });
|
|
145
|
+
assert.ok(extras.some((e) => e.path === 'AGENTS.md' && e.content.includes('Rune')));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('copilot adapter targets .github/instructions with .instructions.md ext', async () => {
|
|
149
|
+
const copilot = getAdapter('copilot');
|
|
150
|
+
assert.strictEqual(copilot.outputDir, '.github/instructions');
|
|
151
|
+
assert.strictEqual(copilot.fileExtension, '.instructions.md');
|
|
152
|
+
const header = copilot.generateHeader({ name: 'cook', layer: 'L1', group: 'orchestrator', description: 'Test' });
|
|
153
|
+
// Per docs.github.com Copilot CLI custom-instructions spec, only `applyTo` is a
|
|
154
|
+
// documented frontmatter field. Description and tier-hint must live in the body.
|
|
155
|
+
assert.ok(header.includes('applyTo: "**"'));
|
|
156
|
+
assert.ok(!/^description:/m.test(header.split('---')[1] || ''), 'description must NOT appear in frontmatter');
|
|
157
|
+
const extras = await copilot.generateExtraFiles({ stats: { skillCount: 5, packCount: 1, files: [] } });
|
|
158
|
+
assert.ok(extras.some((e) => e.path === '.github/copilot-instructions.md'));
|
|
159
|
+
assert.ok(extras.some((e) => e.path === 'AGENTS.md'));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('codex adapter migrated to generateExtraFiles for AGENTS.md (no longer special-cased in emitter)', async () => {
|
|
163
|
+
const codex = getAdapter('codex');
|
|
164
|
+
assert.strictEqual(typeof codex.generateExtraFiles, 'function');
|
|
165
|
+
const extras = await codex.generateExtraFiles({
|
|
166
|
+
stats: { skillCount: 64, packCount: 14, crossRefsResolved: 287, files: [] },
|
|
167
|
+
});
|
|
168
|
+
const agentsMd = extras.find((e) => e.path === 'AGENTS.md');
|
|
169
|
+
assert.ok(agentsMd, 'codex must emit AGENTS.md via generateExtraFiles');
|
|
170
|
+
assert.ok(agentsMd.content.includes('Rune'));
|
|
171
|
+
assert.ok(agentsMd.content.includes('64 core skills'));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('emitter rejects absolute paths from generateExtraFiles (path-traversal guard)', async () => {
|
|
175
|
+
// Smoke: verify both qoder and gemini return relative paths only (no absolute).
|
|
176
|
+
for (const name of ['qoder', 'copilot', 'aider', 'qwen', 'gemini', 'codex']) {
|
|
177
|
+
const adapter = getAdapter(name);
|
|
178
|
+
const extras = await adapter.generateExtraFiles({
|
|
179
|
+
stats: { skillCount: 1, packCount: 0, crossRefsResolved: 0, files: ['rune-cook.md'] },
|
|
180
|
+
outputDir: '/tmp/test',
|
|
181
|
+
});
|
|
182
|
+
for (const extra of extras || []) {
|
|
183
|
+
assert.ok(!extra.path.startsWith('/'), `${name} returned absolute path: ${extra.path}`);
|
|
184
|
+
assert.ok(!/^[A-Z]:[\\/]/.test(extra.path), `${name} returned Windows absolute path: ${extra.path}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('aider adapter emits .aider.conf.yml with read array', async () => {
|
|
190
|
+
const aider = getAdapter('aider');
|
|
191
|
+
assert.strictEqual(aider.outputDir, 'aider/rules');
|
|
192
|
+
const extras = await aider.generateExtraFiles({
|
|
193
|
+
stats: { skillCount: 2, packCount: 0, files: ['rune-cook.md', 'rune-fix.md', 'index.md'] },
|
|
194
|
+
});
|
|
195
|
+
const conf = extras.find((e) => e.path === '.aider.conf.yml');
|
|
196
|
+
assert.ok(conf);
|
|
197
|
+
assert.ok(conf.content.includes('read:'));
|
|
198
|
+
assert.ok(conf.content.includes('aider/rules/rune-cook.md'));
|
|
199
|
+
assert.ok(conf.content.includes('aider/rules/rune-fix.md'));
|
|
200
|
+
// index.md must NOT be in the read array
|
|
201
|
+
assert.ok(!conf.content.includes('aider/rules/index.md'));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('qwen adapter emits QWEN.md with @import lines', async () => {
|
|
205
|
+
const qwen = getAdapter('qwen');
|
|
206
|
+
assert.strictEqual(qwen.outputDir, 'qwen/skills');
|
|
207
|
+
const extras = await qwen.generateExtraFiles({
|
|
208
|
+
stats: { skillCount: 2, packCount: 0, files: ['rune-cook.md', 'rune-fix.md'] },
|
|
209
|
+
});
|
|
210
|
+
const qwenMd = extras.find((e) => e.path === 'QWEN.md');
|
|
211
|
+
assert.ok(qwenMd);
|
|
212
|
+
assert.ok(qwenMd.content.includes('@qwen/skills/rune-cook.md'));
|
|
213
|
+
assert.ok(qwenMd.content.includes('@qwen/skills/rune-fix.md'));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('gemini adapter declares generateExtraFiles for bundled GEMINI.md', () => {
|
|
217
|
+
const gemini = getAdapter('gemini');
|
|
218
|
+
assert.strictEqual(gemini.outputDir, 'gemini/skills');
|
|
219
|
+
assert.strictEqual(typeof gemini.generateExtraFiles, 'function');
|
|
220
|
+
// Full bundle test happens in pipeline test (needs filesystem) — contract checked here.
|
|
221
|
+
});
|
|
@@ -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
|
});
|