@rune-kit/rune 2.28.0 → 2.29.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/.codex-plugin/plugin.json +14 -0
- package/README.md +114 -41
- package/compiler/__tests__/adapter-model-mapping.test.js +22 -24
- package/compiler/__tests__/adapters.test.js +4 -1
- package/compiler/__tests__/doctor-mesh.test.js +55 -1
- package/compiler/__tests__/governance-collector.test.js +1 -0
- package/compiler/__tests__/hooks-codex.test.js +85 -0
- package/compiler/__tests__/hooks-doctor-tier.test.js +3 -5
- package/compiler/__tests__/hooks-drift.test.js +2 -2
- package/compiler/__tests__/hooks-install.test.js +2 -2
- package/compiler/__tests__/hooks-tiers.test.js +3 -4
- package/compiler/__tests__/setup.test.js +59 -0
- package/compiler/__tests__/status.test.js +7 -1
- package/compiler/__tests__/tier-override.test.js +42 -1
- package/compiler/__tests__/transforms.test.js +15 -0
- package/compiler/__tests__/update.test.js +416 -0
- package/compiler/adapters/codex.js +88 -11
- package/compiler/adapters/hooks/codex.js +178 -0
- package/compiler/adapters/hooks/index.js +10 -0
- package/compiler/adapters/openclaw.js +2 -2
- package/compiler/bin/rune.js +24 -4
- package/compiler/commands/hooks/install.js +2 -2
- package/compiler/commands/hooks/status.js +1 -1
- package/compiler/commands/setup.js +101 -28
- package/compiler/commands/update.js +354 -0
- package/compiler/doctor.js +12 -6
- package/compiler/emitter.js +46 -3
- package/compiler/governance-collector.js +3 -2
- package/compiler/status.js +4 -7
- package/compiler/transforms/branding.js +2 -2
- package/compiler/transforms/subagents.js +3 -3
- package/hooks/codex-hooks.json +96 -0
- package/hooks/context-watch/index.cjs +8 -5
- package/hooks/intent-router/index.cjs +3 -0
- package/hooks/lib/hook-output.cjs +11 -3
- package/hooks/post-session-reflect/index.cjs +65 -24
- package/hooks/pre-compact/index.cjs +10 -4
- package/hooks/pre-tool-guard/index.cjs +38 -36
- package/hooks/run-hook +1 -1
- package/hooks/run-hook.cmd +1 -1
- package/hooks/secrets-scan/index.cjs +18 -2
- package/package.json +3 -2
- package/skills/browser-pilot/SKILL.md +1 -0
- package/skills/completion-gate/SKILL.md +5 -5
- package/skills/doc-processor/SKILL.md +2 -2
- package/skills/hallucination-guard/SKILL.md +1 -0
- package/skills/journal/SKILL.md +1 -0
- package/skills/retro/SKILL.md +2 -2
- package/skills/session-bridge/SKILL.md +1 -0
- package/skills/session-bridge/scripts/load-invariants.js +1 -1
- package/skills/video-creator/SKILL.md +1 -1
|
@@ -21,10 +21,14 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
24
|
-
import { cp, readdir, readFile, rm } from 'node:fs/promises';
|
|
24
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { createInterface } from 'node:readline';
|
|
28
|
+
import { getAdapter as getCompilerAdapter } from '../adapters/index.js';
|
|
29
|
+
import { parseSkill } from '../parser.js';
|
|
30
|
+
import { transformSkill } from '../transformer.js';
|
|
31
|
+
import { resolveScriptsPath } from '../transforms/scripts-path.js';
|
|
28
32
|
import { installHooks } from './hooks/install.js';
|
|
29
33
|
import { resolveTier, TIER_ENV_VARS } from './hooks/tiers.js';
|
|
30
34
|
|
|
@@ -103,9 +107,10 @@ export async function runSetup({ projectRoot, runeRoot, args = {}, skillTarget:
|
|
|
103
107
|
// Determine target root
|
|
104
108
|
const targetRoot = scope === 'global' ? os.homedir() : projectRoot;
|
|
105
109
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
110
|
+
// Global defaults to Claude for backward compatibility, but an explicit
|
|
111
|
+
// Codex target installs user-wide hooks under ~/.codex and skills under
|
|
112
|
+
// ~/.agents/skills.
|
|
113
|
+
const platform = args.platform || (scope === 'global' ? 'claude' : undefined);
|
|
109
114
|
|
|
110
115
|
// Set tier env vars from detection so installer can resolve
|
|
111
116
|
for (const tier of tiers) {
|
|
@@ -130,23 +135,44 @@ export async function runSetup({ projectRoot, runeRoot, args = {}, skillTarget:
|
|
|
130
135
|
// Claude Code plugin cache when present, runeRoot otherwise. Without this
|
|
131
136
|
// step, paid tiers ship hooks only — `rune:autopilot` returns "Unknown skill"
|
|
132
137
|
// because Pro/skills/autopilot/ is invisible to the plugin runtime.
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
138
|
+
const skillPlatforms = result.platforms || [];
|
|
139
|
+
const skillTargets = skillPlatforms.map((skillPlatform) => {
|
|
140
|
+
if (skillPlatform === 'claude') {
|
|
141
|
+
if (typeof skillTargetOverride === 'string') {
|
|
142
|
+
return { platform: skillPlatform, root: skillTargetOverride, source: 'override' };
|
|
143
|
+
}
|
|
144
|
+
if (skillTargetOverride) return { platform: skillPlatform, ...skillTargetOverride };
|
|
145
|
+
return { platform: skillPlatform, ...resolveSkillInstallRoot(runeRoot) };
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
platform: skillPlatform,
|
|
149
|
+
root: targetRoot,
|
|
150
|
+
source: scope === 'global' ? `${skillPlatform}-user` : `${skillPlatform}-project`,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
const skillTarget = skillTargets[0] || null;
|
|
137
154
|
const skillResults = [];
|
|
138
|
-
for (const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
155
|
+
for (const skillTargetEntry of skillTargets) {
|
|
156
|
+
for (const tier of tiers) {
|
|
157
|
+
try {
|
|
158
|
+
const tierManifest = await resolveTier(tier, projectRoot);
|
|
159
|
+
const skillResult = await installTierSkills({
|
|
160
|
+
tier,
|
|
161
|
+
tierManifest,
|
|
162
|
+
runeRoot: skillTargetEntry.root,
|
|
163
|
+
platform: skillTargetEntry.platform,
|
|
164
|
+
dry: args.dry,
|
|
165
|
+
});
|
|
166
|
+
skillResults.push({ platform: skillTargetEntry.platform, ...skillResult });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
skillResults.push({
|
|
169
|
+
platform: skillTargetEntry.platform,
|
|
170
|
+
tier,
|
|
171
|
+
installed: [],
|
|
172
|
+
skipped: [],
|
|
173
|
+
reason: err.message,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
150
176
|
}
|
|
151
177
|
}
|
|
152
178
|
|
|
@@ -158,6 +184,7 @@ export async function runSetup({ projectRoot, runeRoot, args = {}, skillTarget:
|
|
|
158
184
|
detected,
|
|
159
185
|
skillResults,
|
|
160
186
|
skillTarget,
|
|
187
|
+
skillTargets,
|
|
161
188
|
...result,
|
|
162
189
|
};
|
|
163
190
|
}
|
|
@@ -177,11 +204,12 @@ export async function runSetup({ projectRoot, runeRoot, args = {}, skillTarget:
|
|
|
177
204
|
* @param {object} opts
|
|
178
205
|
* @param {string} opts.tier
|
|
179
206
|
* @param {import('./hooks/tiers.js').TierManifest} opts.tierManifest
|
|
180
|
-
* @param {string} opts.runeRoot —
|
|
207
|
+
* @param {string} opts.runeRoot — Claude plugin root, or project/user root for other platforms
|
|
208
|
+
* @param {string} [opts.platform='claude']
|
|
181
209
|
* @param {boolean} [opts.dry]
|
|
182
210
|
* @returns {Promise<{tier: string, installed: string[], skipped: Array<{skill: string, reason: string}>, reason: string|null}>}
|
|
183
211
|
*/
|
|
184
|
-
export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
|
|
212
|
+
export async function installTierSkills({ tier, tierManifest, runeRoot, platform = 'claude', dry }) {
|
|
185
213
|
if (!tierManifest?.source) {
|
|
186
214
|
return { tier, installed: [], skipped: [], reason: 'tier manifest source missing' };
|
|
187
215
|
}
|
|
@@ -203,9 +231,24 @@ export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
|
|
|
203
231
|
if (!runeRoot) {
|
|
204
232
|
return { tier, installed: [], skipped: [], reason: 'runeRoot not provided' };
|
|
205
233
|
}
|
|
206
|
-
const
|
|
234
|
+
const adapter = platform === 'claude' ? null : getCompilerAdapter(platform);
|
|
235
|
+
if (adapter && !adapter.useSkillDirectories) {
|
|
236
|
+
return {
|
|
237
|
+
tier,
|
|
238
|
+
installed: [],
|
|
239
|
+
skipped: [],
|
|
240
|
+
reason: `platform '${platform}' does not use directory-based Agent Skills`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const targetDir = adapter ? path.join(runeRoot, adapter.outputDir) : path.join(runeRoot, 'skills');
|
|
207
244
|
if (!existsSync(targetDir)) {
|
|
208
|
-
|
|
245
|
+
if (dry) {
|
|
246
|
+
// Dry-run should describe what would be installed even on a fresh target.
|
|
247
|
+
} else if (adapter) {
|
|
248
|
+
await mkdir(targetDir, { recursive: true });
|
|
249
|
+
} else {
|
|
250
|
+
return { tier, installed: [], skipped: [], reason: `target skills/ missing at ${targetDir}` };
|
|
251
|
+
}
|
|
209
252
|
}
|
|
210
253
|
const installed = [];
|
|
211
254
|
const skipped = [];
|
|
@@ -230,7 +273,33 @@ export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
|
|
|
230
273
|
continue;
|
|
231
274
|
}
|
|
232
275
|
const src = path.join(sourceDir, skillName);
|
|
233
|
-
|
|
276
|
+
let parsed = null;
|
|
277
|
+
let compiledOutput = null;
|
|
278
|
+
let targetSkillName = skillName;
|
|
279
|
+
if (adapter) {
|
|
280
|
+
try {
|
|
281
|
+
const skillPath = path.join(src, 'SKILL.md');
|
|
282
|
+
const content = await readFile(skillPath, 'utf-8');
|
|
283
|
+
parsed = parseSkill(content, skillPath);
|
|
284
|
+
targetSkillName = `${adapter.skillPrefix || ''}${parsed.name}${adapter.skillSuffix || ''}`;
|
|
285
|
+
if (path.basename(targetSkillName) !== targetSkillName) {
|
|
286
|
+
skipped.push({ skill: skillName, reason: 'rejected: compiled skill name is unsafe' });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const { header, body: rawBody, footer } = transformSkill(parsed, adapter);
|
|
290
|
+
const scriptsSource = path.join(src, 'scripts');
|
|
291
|
+
const scriptsPath =
|
|
292
|
+
existsSync(scriptsSource) && typeof adapter.scriptsDir === 'function'
|
|
293
|
+
? path.join(adapter.outputDir, adapter.scriptsDir(parsed.name)).replaceAll('\\', '/')
|
|
294
|
+
: null;
|
|
295
|
+
const body = scriptsPath ? resolveScriptsPath(rawBody, scriptsPath) : rawBody;
|
|
296
|
+
compiledOutput = [header, body, footer].filter(Boolean).join('\n');
|
|
297
|
+
} catch (err) {
|
|
298
|
+
skipped.push({ skill: skillName, reason: `compile failed for ${platform}: ${err.message}` });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const dst = path.join(targetDir, targetSkillName);
|
|
234
303
|
if (existsSync(dst)) {
|
|
235
304
|
const drift = await detectVersionDrift(src, dst);
|
|
236
305
|
skipped.push({ skill: skillName, reason: drift || 'already present' });
|
|
@@ -242,6 +311,9 @@ export async function installTierSkills({ tier, tierManifest, runeRoot, dry }) {
|
|
|
242
311
|
// symlinks at dst. Belt-and-suspenders alongside the isSymbolicLink reject above
|
|
243
312
|
// (nested symlinks inside a skill dir would otherwise still recreate).
|
|
244
313
|
await cp(src, dst, { recursive: true, dereference: true });
|
|
314
|
+
if (compiledOutput !== null) {
|
|
315
|
+
await writeFile(path.join(dst, adapter.skillFileName || 'SKILL.md'), compiledOutput, 'utf-8');
|
|
316
|
+
}
|
|
245
317
|
} catch (err) {
|
|
246
318
|
// Clean partial-copy residue so next run isn't silently locked-out by
|
|
247
319
|
// existsSync(dst) on a corrupt half-written directory.
|
|
@@ -436,10 +508,11 @@ export function formatSetupResult(result) {
|
|
|
436
508
|
lines.push(` Preset: ${result.preset}`);
|
|
437
509
|
lines.push(` Platforms: ${(result.platforms || []).join(', ') || '—'}`);
|
|
438
510
|
for (const sr of result.skillResults || []) {
|
|
511
|
+
const platformLabel = sr.platform ? `/${sr.platform}` : '';
|
|
439
512
|
if (sr.installed.length > 0) {
|
|
440
513
|
const preview = sr.installed.slice(0, 3).join(', ');
|
|
441
514
|
const more = sr.installed.length > 3 ? `, +${sr.installed.length - 3}` : '';
|
|
442
|
-
lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed (${preview}${more})`);
|
|
515
|
+
lines.push(` Skills: ${sr.tier}${platformLabel}: ${sr.installed.length} installed (${preview}${more})`);
|
|
443
516
|
}
|
|
444
517
|
if (sr.skipped.length > 0) {
|
|
445
518
|
const rejected = sr.skipped.filter((s) => s.reason.startsWith('rejected:'));
|
|
@@ -447,14 +520,14 @@ export function formatSetupResult(result) {
|
|
|
447
520
|
const parts = [];
|
|
448
521
|
if (benign > 0) parts.push(`${benign} already present`);
|
|
449
522
|
if (rejected.length > 0) parts.push(`${rejected.length} rejected`);
|
|
450
|
-
lines.push(` Skipped: ${sr.tier}: ${parts.join(', ')}`);
|
|
523
|
+
lines.push(` Skipped: ${sr.tier}${platformLabel}: ${parts.join(', ')}`);
|
|
451
524
|
// Surface rejection details so the operator can investigate a compromised tier repo.
|
|
452
525
|
for (const r of rejected) {
|
|
453
526
|
lines.push(` ⚠ ${r.skill}: ${r.reason}`);
|
|
454
527
|
}
|
|
455
528
|
}
|
|
456
529
|
if (sr.reason) {
|
|
457
|
-
lines.push(` Skill warn:${sr.tier}: ${sr.reason}`);
|
|
530
|
+
lines.push(` Skill warn:${sr.tier}${platformLabel}: ${sr.reason}`);
|
|
458
531
|
}
|
|
459
532
|
}
|
|
460
533
|
if (result.notes?.length) {
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rune update` — one-shot updater for an already-configured project.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the manual "Updating" flow from the README in a single command:
|
|
5
|
+
* 1. `git pull --ff-only` any detected paid tier repos (Pro / Business).
|
|
6
|
+
* Detection reuses setup's logic: $RUNE_PRO_ROOT / $RUNE_BUSINESS_ROOT
|
|
7
|
+
* env vars, then sibling dirs (../Pro, ../Business), then well-known
|
|
8
|
+
* paths. Absent tiers and non-git checkouts are skipped with a note;
|
|
9
|
+
* a FAILED pull aborts the update (fail loud — never silently continue).
|
|
10
|
+
* 2. Re-run the managed setup rewrite in place, non-interactively: the
|
|
11
|
+
* installed platforms, preset, and tiers are detected from the project's
|
|
12
|
+
* existing hook config instead of prompting. Delegates to `runSetup`.
|
|
13
|
+
* 3. Verify: compiled-output doctor (when a rune.config.json build exists)
|
|
14
|
+
* + hook drift report, then print a short summary. Codex users are
|
|
15
|
+
* reminded to re-trust hooks via `/hooks` when `.codex/hooks.json`
|
|
16
|
+
* changed.
|
|
17
|
+
*
|
|
18
|
+
* Non-interactive by design — safe to run from scripts. Flags:
|
|
19
|
+
* --no-pull skip step 1 (tier repos managed some other way)
|
|
20
|
+
* --preset <p> override the detected preset
|
|
21
|
+
* --tier <t>[,<t>] override the detected tier list
|
|
22
|
+
* --dry preview: skip pulls, pass dry to setup (no writes)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFile } from 'node:child_process';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { ADAPTERS, detectPlatforms } from '../adapters/hooks/index.js';
|
|
30
|
+
import { checkHookDrift } from './hooks/drift.js';
|
|
31
|
+
import { TIER_ENV_VARS } from './hooks/tiers.js';
|
|
32
|
+
import { detectTiers, runSetup } from './setup.js';
|
|
33
|
+
|
|
34
|
+
/** Installed hook configs that may carry tier tokens (env var references). */
|
|
35
|
+
const TIER_SCAN_FILES = ['.claude/settings.json', '.codex/hooks.json'];
|
|
36
|
+
|
|
37
|
+
/** Rule/workflow dirs whose Rune-managed files carry `rune-tier:` frontmatter. */
|
|
38
|
+
const TIER_SCAN_DIRS = ['.cursor/rules', '.windsurf/rules', '.windsurf/workflows', '.antigravity/rules'];
|
|
39
|
+
|
|
40
|
+
const CODEX_HOOKS_REL_PATH = '.codex/hooks.json';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Detect which paid tiers are wired into this project's installed hook config.
|
|
44
|
+
* Reads the config files Rune manages and looks for tier env-var tokens
|
|
45
|
+
* (`RUNE_PRO_ROOT` / `RUNE_BUSINESS_ROOT`) or `rune-tier:` frontmatter.
|
|
46
|
+
* Free preset entries never contain either marker, so a Free-only install
|
|
47
|
+
* returns [].
|
|
48
|
+
*
|
|
49
|
+
* @param {string} projectRoot
|
|
50
|
+
* @returns {Promise<string[]>} tier names, e.g. ['pro']
|
|
51
|
+
*/
|
|
52
|
+
export async function detectInstalledTiers(projectRoot) {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for (const rel of TIER_SCAN_FILES) {
|
|
55
|
+
const filePath = path.join(projectRoot, rel);
|
|
56
|
+
if (existsSync(filePath)) {
|
|
57
|
+
chunks.push(await readFile(filePath, 'utf-8').catch(() => ''));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const rel of TIER_SCAN_DIRS) {
|
|
61
|
+
const dir = path.join(projectRoot, rel);
|
|
62
|
+
if (!existsSync(dir)) continue;
|
|
63
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.isFile() || !/\.(md|mdc)$/.test(entry.name)) continue;
|
|
66
|
+
chunks.push(await readFile(path.join(dir, entry.name), 'utf-8').catch(() => ''));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const text = chunks.join('\n');
|
|
70
|
+
const tiers = [];
|
|
71
|
+
for (const [tier, envVar] of Object.entries(TIER_ENV_VARS)) {
|
|
72
|
+
const marker = new RegExp(`${envVar}|rune-tier:\\s*${tier}\\b`);
|
|
73
|
+
if (marker.test(text)) tiers.push(tier);
|
|
74
|
+
}
|
|
75
|
+
return tiers;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detect the preset the project was installed with, by asking each detected
|
|
80
|
+
* platform adapter for its status. A `mixed` install resolves to `gentle`
|
|
81
|
+
* (same fallback the drift reporter uses for canonical comparison).
|
|
82
|
+
*
|
|
83
|
+
* @param {string} projectRoot
|
|
84
|
+
* @returns {Promise<'gentle'|'strict'|null>} null when nothing is installed
|
|
85
|
+
*/
|
|
86
|
+
export async function detectInstalledPreset(projectRoot) {
|
|
87
|
+
for (const id of detectPlatforms(projectRoot)) {
|
|
88
|
+
const status = await ADAPTERS[id].status(projectRoot);
|
|
89
|
+
if (!status.installed || !status.preset) continue;
|
|
90
|
+
if (status.preset === 'mixed') return 'gentle';
|
|
91
|
+
if (status.preset === 'gentle' || status.preset === 'strict') return status.preset;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Default command runner — thin execFile wrapper that never throws.
|
|
98
|
+
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
|
|
99
|
+
*/
|
|
100
|
+
function defaultExec(cmd, argv) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
execFile(cmd, argv, { windowsHide: true }, (err, stdout, stderr) => {
|
|
103
|
+
if (err) {
|
|
104
|
+
resolve({
|
|
105
|
+
code: typeof err.code === 'number' ? err.code : 1,
|
|
106
|
+
stdout: stdout ?? '',
|
|
107
|
+
stderr: stderr || err.message,
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
resolve({ code: 0, stdout: stdout ?? '', stderr: stderr ?? '' });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `git pull --ff-only` each detected tier repo.
|
|
118
|
+
*
|
|
119
|
+
* Statuses per tier:
|
|
120
|
+
* - absent — tier not detected (no env var / sibling / well-known path)
|
|
121
|
+
* - skipped — detected but not a git checkout (npx/tarball install)
|
|
122
|
+
* - pulled — git pull succeeded
|
|
123
|
+
* - failed — git pull failed (dirty tree, auth, diverged…) → result.ok=false
|
|
124
|
+
*
|
|
125
|
+
* @param {{ detected: ReturnType<typeof detectTiers>, exec?: typeof defaultExec }} opts
|
|
126
|
+
* @returns {Promise<{ ok: boolean, results: Array<{tier: string, root: string|null, status: string, detail: string}> }>}
|
|
127
|
+
*/
|
|
128
|
+
export async function pullTierRepos({ detected, exec = defaultExec }) {
|
|
129
|
+
const results = [];
|
|
130
|
+
for (const tier of Object.keys(TIER_ENV_VARS)) {
|
|
131
|
+
const info = detected[tier];
|
|
132
|
+
if (!info) {
|
|
133
|
+
results.push({ tier, root: null, status: 'absent', detail: 'not detected — skipping' });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// detectTiers points at <tierRoot>/hooks/manifest.json — walk back to the root.
|
|
137
|
+
const tierRoot = path.resolve(path.dirname(path.dirname(info.path)));
|
|
138
|
+
if (!existsSync(path.join(tierRoot, '.git'))) {
|
|
139
|
+
results.push({
|
|
140
|
+
tier,
|
|
141
|
+
root: tierRoot,
|
|
142
|
+
status: 'skipped',
|
|
143
|
+
detail: `${tierRoot} is not a git repo — pull skipped (update it the way it was installed)`,
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const { code, stdout, stderr } = await exec('git', ['-C', tierRoot, 'pull', '--ff-only']);
|
|
148
|
+
if (code === 0) {
|
|
149
|
+
const summary = (stdout.trim().split('\n').pop() || 'done').trim();
|
|
150
|
+
results.push({ tier, root: tierRoot, status: 'pulled', detail: summary });
|
|
151
|
+
} else {
|
|
152
|
+
results.push({ tier, root: tierRoot, status: 'failed', detail: (stderr || stdout).trim() });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { ok: results.every((r) => r.status !== 'failed'), results };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Compiled-output verification. Only meaningful for build-pipeline projects
|
|
160
|
+
* (rune.config.json with a non-Claude platform) — Claude Code loads source
|
|
161
|
+
* SKILL.md files natively, so there is no compiled output to check.
|
|
162
|
+
*/
|
|
163
|
+
async function defaultDoctorCheck(projectRoot, runeRoot) {
|
|
164
|
+
const configPath = path.join(projectRoot, 'rune.config.json');
|
|
165
|
+
if (!existsSync(configPath)) {
|
|
166
|
+
return { skipped: true, reason: 'no rune.config.json — compiled-output check skipped' };
|
|
167
|
+
}
|
|
168
|
+
let config;
|
|
169
|
+
try {
|
|
170
|
+
config = JSON.parse(await readFile(configPath, 'utf-8'));
|
|
171
|
+
} catch (err) {
|
|
172
|
+
return { skipped: true, reason: `rune.config.json unreadable (${err.message}) — compiled-output check skipped` };
|
|
173
|
+
}
|
|
174
|
+
if (!config.platform || config.platform === 'claude') {
|
|
175
|
+
return { skipped: true, reason: 'Claude Code native — no compiled output to check' };
|
|
176
|
+
}
|
|
177
|
+
const { runDoctor } = await import('../doctor.js');
|
|
178
|
+
const { getAdapter } = await import('../adapters/index.js');
|
|
179
|
+
const resolvedRuneRoot = config.source === '@rune-kit/rune' ? runeRoot : config.source || runeRoot;
|
|
180
|
+
const results = await runDoctor({
|
|
181
|
+
outputRoot: projectRoot,
|
|
182
|
+
adapter: getAdapter(config.platform),
|
|
183
|
+
config,
|
|
184
|
+
runeRoot: resolvedRuneRoot,
|
|
185
|
+
});
|
|
186
|
+
return { skipped: false, healthy: results.healthy, results };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The full update flow. Pure orchestration — every side-effecting collaborator
|
|
191
|
+
* is injectable via `deps` for tests.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} opts
|
|
194
|
+
* @param {string} opts.projectRoot
|
|
195
|
+
* @param {string} opts.runeRoot
|
|
196
|
+
* @param {object} [opts.args] — CLI flags: no-pull, preset, tier, dry
|
|
197
|
+
* @param {object} [opts.deps] — { exec, runSetupFn, checkHookDriftFn, doctorFn, skillTarget, wellKnownPaths }
|
|
198
|
+
* @returns {Promise<object>} structured result for formatUpdateResult
|
|
199
|
+
*/
|
|
200
|
+
export async function runUpdate({ projectRoot, runeRoot, args = {}, deps = {} }) {
|
|
201
|
+
const notes = [];
|
|
202
|
+
|
|
203
|
+
// ── 0. Something to update? ──
|
|
204
|
+
const platforms = detectPlatforms(projectRoot);
|
|
205
|
+
if (platforms.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason:
|
|
209
|
+
'No Rune installation detected in this project — nothing to update. Run `npx @rune-kit/rune setup` first.',
|
|
210
|
+
pull: { ok: true, results: [] },
|
|
211
|
+
notes,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 1. Pull tier repos (fail loud on pull errors) ──
|
|
216
|
+
const detected = detectTiers(projectRoot, deps.wellKnownPaths ? { wellKnownPaths: deps.wellKnownPaths } : {});
|
|
217
|
+
let pull;
|
|
218
|
+
if (args['no-pull'] || args.dry) {
|
|
219
|
+
const why = args['no-pull'] ? '--no-pull' : '--dry';
|
|
220
|
+
pull = {
|
|
221
|
+
ok: true,
|
|
222
|
+
results: Object.keys(TIER_ENV_VARS).map((tier) => ({
|
|
223
|
+
tier,
|
|
224
|
+
root: null,
|
|
225
|
+
status: detected[tier] ? 'skipped' : 'absent',
|
|
226
|
+
detail: detected[tier] ? `pull skipped (${why})` : 'not detected — skipping',
|
|
227
|
+
})),
|
|
228
|
+
};
|
|
229
|
+
} else {
|
|
230
|
+
pull = await pullTierRepos({ detected, exec: deps.exec });
|
|
231
|
+
}
|
|
232
|
+
if (!pull.ok) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: 'tier pull failed — resolve it manually (dirty tree? auth?) then re-run `rune update`',
|
|
236
|
+
pull,
|
|
237
|
+
notes,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 2. Re-run managed setup, reusing what is already installed ──
|
|
242
|
+
const preset = args.preset || (await detectInstalledPreset(projectRoot)) || 'gentle';
|
|
243
|
+
let tiers;
|
|
244
|
+
if (args.tier) {
|
|
245
|
+
tiers = String(args.tier)
|
|
246
|
+
.split(',')
|
|
247
|
+
.map((t) => t.trim())
|
|
248
|
+
.filter(Boolean);
|
|
249
|
+
} else {
|
|
250
|
+
const installed = await detectInstalledTiers(projectRoot);
|
|
251
|
+
tiers = installed.filter((tier) => detected[tier]);
|
|
252
|
+
for (const tier of installed) {
|
|
253
|
+
if (!detected[tier]) {
|
|
254
|
+
const envVar = TIER_ENV_VARS[tier] || `RUNE_${tier.toUpperCase()}_ROOT`;
|
|
255
|
+
notes.push(
|
|
256
|
+
`${tier}: hooks are installed but the tier repo was not found — skipping. Set $${envVar} or clone the repo next to Free, then re-run \`rune update\`.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const codexHooksPath = path.join(projectRoot, CODEX_HOOKS_REL_PATH);
|
|
263
|
+
const codexBefore = existsSync(codexHooksPath) ? await readFile(codexHooksPath, 'utf-8').catch(() => null) : null;
|
|
264
|
+
|
|
265
|
+
const setupArgs = {
|
|
266
|
+
here: true,
|
|
267
|
+
preset,
|
|
268
|
+
dry: args.dry,
|
|
269
|
+
...(tiers.length > 0 ? { tier: tiers.join(',') } : { 'no-tier': true }),
|
|
270
|
+
};
|
|
271
|
+
const setup = await (deps.runSetupFn || runSetup)({
|
|
272
|
+
projectRoot,
|
|
273
|
+
runeRoot,
|
|
274
|
+
args: setupArgs,
|
|
275
|
+
skillTarget: deps.skillTarget,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const codexAfter = existsSync(codexHooksPath) ? await readFile(codexHooksPath, 'utf-8').catch(() => null) : null;
|
|
279
|
+
const codexReTrust = platforms.includes('codex') && codexBefore !== codexAfter;
|
|
280
|
+
|
|
281
|
+
// ── 3. Verify: compiled output + hook drift ──
|
|
282
|
+
const doctor = await (deps.doctorFn || defaultDoctorCheck)(projectRoot, runeRoot);
|
|
283
|
+
const drift = await (deps.checkHookDriftFn || checkHookDrift)(projectRoot);
|
|
284
|
+
|
|
285
|
+
return { ok: true, pull, platforms, preset, tiers, setup, drift, doctor, codexReTrust, notes };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const PULL_ICONS = { pulled: '✓', absent: '·', skipped: '·', failed: '✗' };
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Render a runUpdate result for console output.
|
|
292
|
+
* @param {Awaited<ReturnType<typeof runUpdate>>} result
|
|
293
|
+
* @returns {string}
|
|
294
|
+
*/
|
|
295
|
+
export function formatUpdateResult(result) {
|
|
296
|
+
const lines = [];
|
|
297
|
+
lines.push('');
|
|
298
|
+
lines.push(' Rune Update');
|
|
299
|
+
lines.push(' ────────────');
|
|
300
|
+
|
|
301
|
+
if (result.pull?.results?.length > 0) {
|
|
302
|
+
lines.push(' Tier repos:');
|
|
303
|
+
for (const r of result.pull.results) {
|
|
304
|
+
const icon = PULL_ICONS[r.status] || '·';
|
|
305
|
+
const label = r.status === 'failed' ? 'pull FAILED' : r.status;
|
|
306
|
+
lines.push(` ${icon} ${r.tier} — ${label}: ${r.detail}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!result.ok) {
|
|
311
|
+
lines.push('');
|
|
312
|
+
lines.push(` ✗ Update aborted: ${result.reason}`);
|
|
313
|
+
lines.push('');
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
lines.push(' Setup rewrite:');
|
|
318
|
+
lines.push(
|
|
319
|
+
` Platforms: ${(result.setup?.platforms || result.platforms || []).join(', ') || '—'} | Preset: ${result.preset} | Tiers: Free${result.tiers?.length ? ` + ${result.tiers.join(' + ')}` : ''}`,
|
|
320
|
+
);
|
|
321
|
+
for (const sr of result.setup?.skillResults || []) {
|
|
322
|
+
if (sr.installed?.length > 0) {
|
|
323
|
+
lines.push(` Skills: ${sr.tier}: ${sr.installed.length} installed`);
|
|
324
|
+
} else if (sr.skipped?.length > 0) {
|
|
325
|
+
lines.push(` Skills: ${sr.tier}: ${sr.skipped.length} already present / skipped`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (result.setup && result.setup.written === false) {
|
|
329
|
+
lines.push(' (dry-run — no files written)');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
lines.push(' Verify:');
|
|
333
|
+
if (result.doctor?.skipped) {
|
|
334
|
+
lines.push(` Doctor: skipped — ${result.doctor.reason}`);
|
|
335
|
+
} else if (result.doctor) {
|
|
336
|
+
lines.push(` Doctor: ${result.doctor.healthy ? '✓ healthy' : '✗ issues found — run `rune doctor` for detail'}`);
|
|
337
|
+
}
|
|
338
|
+
if (result.drift?.summary) {
|
|
339
|
+
const s = result.drift.summary;
|
|
340
|
+
lines.push(` Hook drift: ${s.drifted} drifted, ${s.missing} missing, ${s.errors} error(s)`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const note of result.notes || []) {
|
|
344
|
+
lines.push(` ⚠ ${note}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (result.codexReTrust) {
|
|
348
|
+
lines.push('');
|
|
349
|
+
lines.push(' ⚠ Codex: .codex/hooks.json changed — open /hooks in Codex to review and re-trust the definitions.');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
lines.push('');
|
|
353
|
+
return lines.join('\n');
|
|
354
|
+
}
|
package/compiler/doctor.js
CHANGED
|
@@ -742,7 +742,7 @@ export async function checkMeshIntegrity(runeRoot) {
|
|
|
742
742
|
/**
|
|
743
743
|
* Parse skill connections from SKILL.md content
|
|
744
744
|
*/
|
|
745
|
-
function parseSkillConnections(content, skillName) {
|
|
745
|
+
export function parseSkillConnections(content, skillName) {
|
|
746
746
|
const result = {
|
|
747
747
|
name: skillName,
|
|
748
748
|
calls: [],
|
|
@@ -777,13 +777,15 @@ function parseSkillConnections(content, skillName) {
|
|
|
777
777
|
}
|
|
778
778
|
|
|
779
779
|
// Extract Calls section
|
|
780
|
-
const callsMatch = content.match(/## Calls \(outbound
|
|
780
|
+
const callsMatch = content.match(/## Calls \(outbound(?: connections)?\)\s*\r?\n([\s\S]*?)(?=\r?\n## |\r?\n---|$)/);
|
|
781
781
|
if (callsMatch) {
|
|
782
782
|
const lines = callsMatch[1].split('\n');
|
|
783
783
|
for (const line of lines) {
|
|
784
784
|
// Match patterns like: - `scout` (L2): scan codebase
|
|
785
|
-
// Or:
|
|
786
|
-
const match =
|
|
785
|
+
// Or table rows like: | 1 | `scout` | L2 | scan codebase |
|
|
786
|
+
const match =
|
|
787
|
+
line.match(/^-\s*`?([a-z][\w-]*)`?\s*\((?:L[0-4]|ext)\)/i) ||
|
|
788
|
+
line.match(/^\|(?:[^|]*\|)*?\s*`?([a-z][\w-]*)`?\s*\|\s*(?:L[0-4]|ext)\s*\|/i);
|
|
787
789
|
if (match) {
|
|
788
790
|
const skillRef = match[1].toLowerCase();
|
|
789
791
|
const reason = line
|
|
@@ -796,11 +798,15 @@ function parseSkillConnections(content, skillName) {
|
|
|
796
798
|
}
|
|
797
799
|
|
|
798
800
|
// Extract Called By section
|
|
799
|
-
const calledByMatch = content.match(
|
|
801
|
+
const calledByMatch = content.match(
|
|
802
|
+
/## Called By \(inbound(?: connections)?\)\s*\r?\n([\s\S]*?)(?=\r?\n## |\r?\n---|$)/,
|
|
803
|
+
);
|
|
800
804
|
if (calledByMatch) {
|
|
801
805
|
const lines = calledByMatch[1].split('\n');
|
|
802
806
|
for (const line of lines) {
|
|
803
|
-
const match =
|
|
807
|
+
const match =
|
|
808
|
+
line.match(/^-\s*`?([a-z][\w-]*)`?\s*\((?:L[0-4]|ext)\)/i) ||
|
|
809
|
+
line.match(/^\|(?:[^|]*\|)*?\s*`?([a-z][\w-]*)`?\s*\|\s*(?:L[0-4]|ext)\s*\|/i);
|
|
804
810
|
if (match) {
|
|
805
811
|
const skillRef = match[1].toLowerCase();
|
|
806
812
|
result.calledBy.push({ skill: skillRef });
|