@zyaiting/keelson 0.4.0 → 0.5.0
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 +48 -30
- package/README_CN.md +48 -30
- package/hooks/codebuddy-session.mjs +13 -28
- package/hooks/codex-session.mjs +16 -0
- package/hooks/prompt-state.mjs +5 -29
- package/hooks/session-start.mjs +7 -0
- package/hooks/workflow-guard.mjs +95 -0
- package/package.json +1 -1
- package/registry/platforms.json +4 -4
- package/registry/runtime-hashes.json +41 -0
- package/skills/keelson/SKILL.md +6 -3
- package/skills/keelson/references/build.md +12 -0
- package/skills/keelson/references/discover.md +5 -3
- package/skills/keelson/references/frontend.md +1 -1
- package/skills/keelson/references/interview.md +26 -7
- package/skills/keelson/references/land.md +2 -0
- package/skills/keelson/references/shape.md +3 -3
- package/skills/keelson/references/verify.md +1 -1
- package/skills/keelson/templates/resident-block.md +1 -1
- package/skills/keelson/templates/workflow.md +3 -3
- package/skills/zh/keelson/SKILL.md +6 -3
- package/skills/zh/keelson/references/build.md +12 -0
- package/skills/zh/keelson/references/discover.md +5 -3
- package/skills/zh/keelson/references/frontend.md +1 -1
- package/skills/zh/keelson/references/interview.md +26 -7
- package/skills/zh/keelson/references/land.md +2 -0
- package/skills/zh/keelson/references/shape.md +3 -3
- package/skills/zh/keelson/references/verify.md +2 -0
- package/skills/zh/keelson/templates/resident-block.md +1 -1
- package/skills/zh/keelson/templates/workflow.md +3 -3
- package/src/cli.js +4 -3
- package/src/commands/ablate.js +2 -1
- package/src/commands/ask.js +4 -1
- package/src/commands/context.js +7 -0
- package/src/commands/doctor.js +3 -1
- package/src/commands/hook.js +6 -1
- package/src/commands/init.js +12 -7
- package/src/commands/new.js +1 -1
- package/src/commands/platforms.js +1 -1
- package/src/commands/start.js +33 -0
- package/src/commands/uninstall.js +1 -1
- package/src/lib/decisions.js +3 -1
- package/src/lib/hook-context.js +30 -0
- package/src/lib/markdown.js +1 -1
- package/src/lib/rules.js +10 -2
- package/src/lib/workflow.js +102 -0
- package/src/platforms/integration.js +75 -93
- package/src/platforms/runtime.js +68 -17
|
@@ -3,7 +3,7 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { exists, read, readJson, readOr, rmrf, write, writeJson } from '../lib/fs.js';
|
|
5
5
|
import { installTargets, PLATFORMS } from './registry.js';
|
|
6
|
-
import { managedSkillShimMatches, residentBlock } from './runtime.js';
|
|
6
|
+
import { captureRuntimeOwnership, managedSkillShimMatches, residentBlock } from './runtime.js';
|
|
7
7
|
|
|
8
8
|
export const MANAGED_STATE = path.join('.keelson', 'manifest.json');
|
|
9
9
|
export const LEGACY_MANAGED_STATE = path.join('.keelson', '.managed.json');
|
|
@@ -100,7 +100,7 @@ export function managedStateMatches(root, targets) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export function writeManagedState(root, targets, packageVersion, { vendor = false } = {}) {
|
|
103
|
-
writeJson(path.join(root, MANAGED_STATE), { schema: 1, packageVersion, vendor: Boolean(vendor), targets: targets.map(managedTarget) });
|
|
103
|
+
writeJson(path.join(root, MANAGED_STATE), { schema: 1, packageVersion, vendor: Boolean(vendor), targets: targets.map(managedTarget), runtime: captureRuntimeOwnership(root, targets, vendor) });
|
|
104
104
|
if (exists(path.join(root, LEGACY_MANAGED_STATE))) rmrf(path.join(root, LEGACY_MANAGED_STATE));
|
|
105
105
|
return MANAGED_STATE;
|
|
106
106
|
}
|
|
@@ -173,11 +173,11 @@ const LEGACY_CLAUDE_HOOK_COMMANDS = new Set([
|
|
|
173
173
|
'node "$CLAUDE_PROJECT_DIR/.keelson/hooks/session-start.mjs"',
|
|
174
174
|
'node "$CLAUDE_PROJECT_DIR/.keelson/hooks/prompt-state.mjs"',
|
|
175
175
|
]);
|
|
176
|
-
const CLAUDE_HOOK_COMMANDS = new Set(['keelson hook session-start', 'keelson hook prompt-state', ...LEGACY_CLAUDE_HOOK_COMMANDS]);
|
|
176
|
+
const CLAUDE_HOOK_COMMANDS = new Set(['keelson hook session-start', 'keelson hook prompt-state', 'keelson hook workflow-guard', ...LEGACY_CLAUDE_HOOK_COMMANDS]);
|
|
177
177
|
const isClaudeHook = (hook) => hook?.type === 'command' && CLAUDE_HOOK_COMMANDS.has(String(hook.command));
|
|
178
178
|
|
|
179
179
|
export function installHooks(root) {
|
|
180
|
-
removeHooks(root
|
|
180
|
+
removeHooks(root);
|
|
181
181
|
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
182
182
|
const settings = readJson(settingsPath, {}) ?? {};
|
|
183
183
|
settings.hooks ??= {};
|
|
@@ -192,6 +192,8 @@ export function installHooks(root) {
|
|
|
192
192
|
};
|
|
193
193
|
ensure('SessionStart', 'startup|resume|clear|compact', 'session-start.mjs');
|
|
194
194
|
ensure('UserPromptSubmit', null, 'prompt-state.mjs');
|
|
195
|
+
ensure('PreToolUse', 'Edit|Write|MultiEdit|NotebookEdit|Agent|Task', 'workflow-guard.mjs');
|
|
196
|
+
ensure('SubagentStart', null, 'workflow-guard.mjs');
|
|
195
197
|
writeJson(settingsPath, settings);
|
|
196
198
|
return ['.claude/settings.json'];
|
|
197
199
|
}
|
|
@@ -216,78 +218,41 @@ export function removeHooks(root, { legacyOnly = false } = {}) {
|
|
|
216
218
|
writeJson(settingsPath, settings);
|
|
217
219
|
}
|
|
218
220
|
|
|
219
|
-
const CODEBUDDY_SESSION_COMMAND = 'keelson hook codebuddy-session';
|
|
220
221
|
const LEGACY_CODEBUDDY_SESSION_COMMAND = 'node "$CODEBUDDY_PROJECT_DIR/.keelson/hooks/codebuddy-session.mjs"';
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (p.sessionAdapter === 'codebuddy-hooks') {
|
|
250
|
-
removeCodeBuddyHooks(root, { legacyOnly: true });
|
|
251
|
-
const settingsPath = path.join(root, '.codebuddy', 'settings.json');
|
|
252
|
-
const settings = readJson(settingsPath, {}) ?? {};
|
|
253
|
-
ensureCodeBuddyHook(settings, 'SessionStart');
|
|
254
|
-
ensureCodeBuddyHook(settings, 'UserPromptSubmit');
|
|
255
|
-
ensureCodeBuddyHook(settings, 'PreToolUse', 'Bash|PowerShell');
|
|
256
|
-
writeJson(settingsPath, settings);
|
|
257
|
-
return [path.relative(root, settingsPath)];
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return [];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export function plannedSessionAdapterFiles(root, target) {
|
|
264
|
-
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
265
|
-
const rows = [];
|
|
266
|
-
if (!p?.sessionAdapter || p.sessionAdapter === 'pi-env' || p.sessionAdapter === 'claude-hooks') return rows;
|
|
267
|
-
|
|
268
|
-
if (p.sessionAdapter === 'codebuddy-hooks') {
|
|
269
|
-
const settings = readJson(path.join(root, '.codebuddy', 'settings.json'), {}) ?? {};
|
|
270
|
-
const has = (event, matcher = null) => (settings.hooks?.[event] ?? []).some((g) =>
|
|
271
|
-
(matcher === null || g.matcher === matcher) &&
|
|
272
|
-
(g.hooks ?? []).some(isCodeBuddyHook),
|
|
273
|
-
);
|
|
274
|
-
const ready = has('SessionStart') && has('UserPromptSubmit') && has('PreToolUse', 'Bash|PowerShell');
|
|
275
|
-
rows.push({ path: '.codebuddy/settings.json (Keelson session hooks)', status: ready ? 'unchanged' : exists(path.join(root, '.codebuddy', 'settings.json')) ? 'update' : 'create' });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return rows;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function removeCodeBuddyHooks(root, { legacyOnly = false } = {}) {
|
|
282
|
-
const settingsPath = path.join(root, '.codebuddy', 'settings.json');
|
|
222
|
+
const HOST_HOOKS = {
|
|
223
|
+
'codebuddy-hooks': {
|
|
224
|
+
file: '.codebuddy/settings.json',
|
|
225
|
+
registrations: [
|
|
226
|
+
['SessionStart', null, 'codebuddy-session'],
|
|
227
|
+
['UserPromptSubmit', null, 'codebuddy-session'],
|
|
228
|
+
['PreToolUse', 'Bash|PowerShell', 'codebuddy-session'],
|
|
229
|
+
['PreToolUse', '^(Edit|Write|MultiEdit|NotebookEdit|NotebookWrite|Agent|Task)$', 'codebuddy-workflow'],
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
'codex-thread-env': {
|
|
233
|
+
file: '.codex/hooks.json',
|
|
234
|
+
registrations: [
|
|
235
|
+
['SessionStart', null, 'codex-session'],
|
|
236
|
+
['UserPromptSubmit', null, 'codex-session'],
|
|
237
|
+
['PreToolUse', '^(apply_patch|Edit|Write|Agent|spawn_agent)$', 'codex-workflow'],
|
|
238
|
+
['SubagentStart', null, 'codex-workflow'],
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
const adapterFor = (target) => HOST_HOOKS[target?.sessionAdapter];
|
|
243
|
+
const isAdapterHook = (h, adapter) => h?.type === 'command' && adapter.registrations.some(([, , name]) => h.command === `keelson hook ${name}`);
|
|
244
|
+
const hasRegistration = (settings, [event, matcher, name]) => (settings.hooks?.[event] ?? []).some((g) =>
|
|
245
|
+
(g.matcher ?? null) === matcher && (g.hooks ?? []).some((h) => h.type === 'command' && h.command === `keelson hook ${name}`));
|
|
246
|
+
|
|
247
|
+
function removeAdapterHooks(root, adapter) {
|
|
248
|
+
const settingsPath = path.join(root, adapter.file);
|
|
283
249
|
const settings = readJson(settingsPath, null);
|
|
284
250
|
if (!settings?.hooks) return false;
|
|
285
251
|
let changed = false;
|
|
286
252
|
for (const event of Object.keys(settings.hooks)) {
|
|
287
253
|
settings.hooks[event] = settings.hooks[event].map((g) => {
|
|
288
|
-
const hooks = (g.hooks ?? []).filter((h) =>
|
|
289
|
-
!(h?.type === 'command' && h.command === LEGACY_CODEBUDDY_SESSION_COMMAND)
|
|
290
|
-
);
|
|
254
|
+
const hooks = (g.hooks ?? []).filter((h) => !isAdapterHook(h, adapter) &&
|
|
255
|
+
!(adapter.file.startsWith('.codebuddy/') && h?.type === 'command' && h.command === LEGACY_CODEBUDDY_SESSION_COMMAND));
|
|
291
256
|
if (hooks.length === (g.hooks ?? []).length) return g;
|
|
292
257
|
changed = true;
|
|
293
258
|
return hooks.length ? { ...g, hooks } : null;
|
|
@@ -301,32 +266,49 @@ function removeCodeBuddyHooks(root, { legacyOnly = false } = {}) {
|
|
|
301
266
|
return true;
|
|
302
267
|
}
|
|
303
268
|
|
|
304
|
-
export function
|
|
269
|
+
export function installSessionAdapter(root, target) {
|
|
305
270
|
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
308
|
-
|
|
271
|
+
const adapter = adapterFor(p);
|
|
272
|
+
if (!adapter) return [];
|
|
273
|
+
// Split owned hooks out of mixed groups; never change a user's matcher.
|
|
274
|
+
removeAdapterHooks(root, adapter);
|
|
275
|
+
const settingsPath = path.join(root, adapter.file);
|
|
276
|
+
const settings = readJson(settingsPath, {}) ?? {};
|
|
277
|
+
settings.hooks ??= {};
|
|
278
|
+
for (const [event, matcher, name] of adapter.registrations) {
|
|
279
|
+
settings.hooks[event] ??= [];
|
|
280
|
+
settings.hooks[event].push({
|
|
281
|
+
...(matcher ? { matcher } : {}),
|
|
282
|
+
hooks: [{ type: 'command', command: `keelson hook ${name}`, timeout: 10 }],
|
|
283
|
+
});
|
|
309
284
|
}
|
|
310
|
-
|
|
285
|
+
writeJson(settingsPath, settings);
|
|
286
|
+
return [adapter.file];
|
|
311
287
|
}
|
|
312
288
|
|
|
313
|
-
export function
|
|
289
|
+
export function plannedSessionAdapterFiles(root, target) {
|
|
314
290
|
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
315
|
-
const
|
|
316
|
-
if (!
|
|
291
|
+
const adapter = adapterFor(p);
|
|
292
|
+
if (!adapter) return [];
|
|
293
|
+
const file = path.join(root, adapter.file);
|
|
294
|
+
const settings = readJson(file, {}) ?? {};
|
|
295
|
+
const ready = adapter.registrations.every((registration) => hasRegistration(settings, registration));
|
|
296
|
+
return [{ path: `${adapter.file} (Keelson workflow hooks)`, status: ready ? 'unchanged' : exists(file) ? 'update' : 'create' }];
|
|
297
|
+
}
|
|
317
298
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
);
|
|
324
|
-
if (!has('SessionStart')) problems.push(`${p.label}: SessionStart session hook not registered`);
|
|
325
|
-
if (!has('UserPromptSubmit')) problems.push(`${p.label}: UserPromptSubmit session hook not registered`);
|
|
326
|
-
if (!has('PreToolUse', 'Bash|PowerShell')) problems.push(`${p.label}: Bash|PowerShell PreToolUse session hook not registered`);
|
|
327
|
-
}
|
|
299
|
+
export function removeSessionAdapter(root, target, keep = new Set()) {
|
|
300
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
301
|
+
const adapter = adapterFor(p);
|
|
302
|
+
return adapter && removeAdapterHooks(root, adapter) ? [`${adapter.file} (Keelson hooks)`] : [];
|
|
303
|
+
}
|
|
328
304
|
|
|
329
|
-
|
|
305
|
+
export function sessionAdapterProblems(root, target) {
|
|
306
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
307
|
+
const adapter = adapterFor(p);
|
|
308
|
+
if (!adapter) return [];
|
|
309
|
+
const settings = readJson(path.join(root, adapter.file), {}) ?? {};
|
|
310
|
+
return adapter.registrations.filter((r) => !hasRegistration(settings, r))
|
|
311
|
+
.map(([event, matcher, name]) => `${p.label}: ${event}${matcher ? ` (${matcher})` : ''} hook ${name} not registered`);
|
|
330
312
|
}
|
|
331
313
|
|
|
332
314
|
function removeTargetSurfaces(root, p, keep = new Set()) {
|
|
@@ -334,7 +316,7 @@ function removeTargetSurfaces(root, p, keep = new Set()) {
|
|
|
334
316
|
const skillRel = path.join(p.skillsDir, 'keelson');
|
|
335
317
|
const skill = path.join(root, skillRel);
|
|
336
318
|
const managed = readManagedState(root);
|
|
337
|
-
if (!keep.has(skillRel) && exists(skill) && managedSkillShimMatches(root, p, managed?.packageVersion)) {
|
|
319
|
+
if (!keep.has(skillRel) && exists(skill) && managedSkillShimMatches(root, p, managed?.packageVersion, managed)) {
|
|
338
320
|
rmrf(skill);
|
|
339
321
|
removed.push(path.relative(root, skill));
|
|
340
322
|
}
|
|
@@ -348,7 +330,7 @@ function removeTargetSurfaces(root, p, keep = new Set()) {
|
|
|
348
330
|
rmrf(path.join(root, p.rulesFile));
|
|
349
331
|
removed.push(p.rulesFile);
|
|
350
332
|
}
|
|
351
|
-
if (p.hooks && !keep.has('.claude/settings.json')) removeHooks(root);
|
|
333
|
+
if (p.hooks && p.id === 'claude' && !keep.has('.claude/settings.json')) removeHooks(root);
|
|
352
334
|
removed.push(...removeSessionAdapter(root, p, keep));
|
|
353
335
|
return removed;
|
|
354
336
|
}
|
|
@@ -357,13 +339,13 @@ export function reconcileManagedTargets(root, targets) {
|
|
|
357
339
|
const removed = [];
|
|
358
340
|
const stale = staleManagedTargets(root, targets);
|
|
359
341
|
const keep = new Set(targets.flatMap(managedSurfacePaths));
|
|
360
|
-
if (targets.some((p) => p.hooks)) keep.add('.claude/settings.json');
|
|
342
|
+
if (targets.some((p) => p.hooks && p.id === 'claude')) keep.add('.claude/settings.json');
|
|
361
343
|
for (const p of stale) removed.push(...removeTargetSurfaces(root, p, keep));
|
|
362
344
|
for (const rel of legacyManagedRemovals(root, targets)) {
|
|
363
345
|
rmrf(path.join(root, rel));
|
|
364
346
|
removed.push(rel);
|
|
365
347
|
}
|
|
366
|
-
if (stale.some((p) => p.hooks) && !targets.some((p) => p.hooks)) {
|
|
348
|
+
if (stale.some((p) => p.hooks && p.id === 'claude') && !targets.some((p) => p.hooks && p.id === 'claude')) {
|
|
367
349
|
removeHooks(root);
|
|
368
350
|
}
|
|
369
351
|
return [...new Set(removed)];
|
package/src/platforms/runtime.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { PKG_ROOT } from '../lib/paths.js';
|
|
5
|
-
import { exists, read, walk, write, rmrf, replaceDirSafe, withLock } from '../lib/fs.js';
|
|
5
|
+
import { exists, read, readJson, walk, write, rmrf, replaceDirSafe, withLock } from '../lib/fs.js';
|
|
6
6
|
import { PLATFORMS } from './registry.js';
|
|
7
7
|
|
|
8
8
|
export const CANONICAL_SKILL_DIR = path.join('.keelson', 'skill');
|
|
@@ -124,6 +124,42 @@ function treeHash(dir) {
|
|
|
124
124
|
} catch { return null; }
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Before 0.5.0, manifests carried a version but no content digest. Bootstrap
|
|
128
|
+
// only from exact published output; future releases use the installed digest.
|
|
129
|
+
const HISTORICAL_RUNTIME = readJson(path.join(PKG_ROOT, 'registry', 'runtime-hashes.json'), {}).versions ?? {};
|
|
130
|
+
|
|
131
|
+
function runtimeDigest(root, rel, kind) {
|
|
132
|
+
const target = path.join(root, rel);
|
|
133
|
+
if (kind !== 'workflows') return treeHash(target);
|
|
134
|
+
try { return fs.lstatSync(target).isFile() ? fileHash(read(target)) : null; } catch { return null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function previousRuntimeMatches(root, rel, kind, previousState) {
|
|
138
|
+
if (!previousState) return false;
|
|
139
|
+
const owned = kind === 'shims'
|
|
140
|
+
? previousState.targets?.some((t) => t.skillsDir && path.join(t.skillsDir, 'keelson') === rel)
|
|
141
|
+
: previousState.vendor === true;
|
|
142
|
+
if (!owned) return false;
|
|
143
|
+
const actual = runtimeDigest(root, rel, kind);
|
|
144
|
+
if (!actual) return false;
|
|
145
|
+
const recorded = previousState.runtime?.[rel];
|
|
146
|
+
if (recorded) return actual === recorded;
|
|
147
|
+
return (HISTORICAL_RUNTIME[previousState.packageVersion]?.[kind] ?? []).includes(actual);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function captureRuntimeOwnership(root, targets, vendor) {
|
|
151
|
+
const runtime = {};
|
|
152
|
+
for (const target of targets) {
|
|
153
|
+
const rel = path.join(target.skillsDir, 'keelson');
|
|
154
|
+
runtime[rel] = runtimeDigest(root, rel, 'shims');
|
|
155
|
+
}
|
|
156
|
+
if (vendor) {
|
|
157
|
+
runtime[CANONICAL_SKILL_DIR] = runtimeDigest(root, CANONICAL_SKILL_DIR, 'skills');
|
|
158
|
+
runtime[CANONICAL_WORKFLOW] = runtimeDigest(root, CANONICAL_WORKFLOW, 'workflows');
|
|
159
|
+
}
|
|
160
|
+
return runtime;
|
|
161
|
+
}
|
|
162
|
+
|
|
127
163
|
function canonicalSkillMatches(root, { lang, profile, version }) {
|
|
128
164
|
const dest = path.join(root, CANONICAL_SKILL_DIR);
|
|
129
165
|
const files = renderSkillFiles(lang, profile, version);
|
|
@@ -132,12 +168,19 @@ function canonicalSkillMatches(root, { lang, profile, version }) {
|
|
|
132
168
|
return version === '0.3.0' && treeHash(dest) === LEGACY_V03_SKILL_TREE_HASHES.get(`${lang}/${profile}`);
|
|
133
169
|
}
|
|
134
170
|
|
|
135
|
-
export function
|
|
171
|
+
export function assertCanonicalSkillInstallable(root, { lang, profile, version, previousState, force = false }) {
|
|
136
172
|
const dest = path.join(root, CANONICAL_SKILL_DIR);
|
|
137
|
-
|
|
138
|
-
|
|
173
|
+
if (fs.lstatSync(dest, { throwIfNoEntry: false }) && !canonicalSkillMatches(root, { lang, profile, version }) &&
|
|
174
|
+
!previousRuntimeMatches(root, CANONICAL_SKILL_DIR, 'skills', previousState) &&
|
|
175
|
+
!(previousState?.packageVersion === '0.3.0' && canonicalSkillMatches(root, { lang, profile, version: '0.3.0' })) && !force) {
|
|
139
176
|
throw new Error('vendored skill differs from this CLI output; Keelson left it unchanged. Review it, then pass --force only if replacing the whole directory is intended.');
|
|
140
177
|
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function installCanonicalSkill(root, options) {
|
|
181
|
+
assertCanonicalSkillInstallable(root, options);
|
|
182
|
+
const dest = path.join(root, CANONICAL_SKILL_DIR);
|
|
183
|
+
const files = renderSkillFiles(options.lang, options.profile, options.version);
|
|
141
184
|
withLock(dest, () => {
|
|
142
185
|
replaceDirSafe(dest, (tmp) => {
|
|
143
186
|
for (const f of files) write(path.join(tmp, f.rel), f.content);
|
|
@@ -157,10 +200,10 @@ function skillShimMatches(dest, content, legacyVersion) {
|
|
|
157
200
|
(normalize(read(skill)) === content || legacyV03SkillShimMatches(dest, legacyVersion));
|
|
158
201
|
}
|
|
159
202
|
|
|
160
|
-
function assertSkillInstallable(root, target, { lang, version, legacyVersion, previousVersion, force }) {
|
|
203
|
+
function assertSkillInstallable(root, target, { lang, version, legacyVersion, previousVersion, previousState, force }) {
|
|
161
204
|
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
162
205
|
const dest = path.join(root, p.skillsDir, 'keelson');
|
|
163
|
-
const priorOutput = previousVersion && managedSkillShimMatches(root, p, previousVersion);
|
|
206
|
+
const priorOutput = previousVersion && managedSkillShimMatches(root, p, previousVersion, previousState);
|
|
164
207
|
if (fs.lstatSync(dest, { throwIfNoEntry: false }) && !skillShimMatches(dest, renderSkillShim(lang, version), legacyVersion) && !priorOutput && !force) {
|
|
165
208
|
throw new Error('discovery shim differs from this CLI output; Keelson left it unchanged. Review it, then pass --force only if replacing the whole directory is intended.');
|
|
166
209
|
}
|
|
@@ -177,18 +220,19 @@ export function assertSkillsInstallable(root, targets, options) {
|
|
|
177
220
|
}
|
|
178
221
|
}
|
|
179
222
|
|
|
180
|
-
/** True only for a complete
|
|
181
|
-
export function managedSkillShimMatches(root, target, version) {
|
|
223
|
+
/** True only for a complete generated shim or its recorded/published digest. */
|
|
224
|
+
export function managedSkillShimMatches(root, target, version, previousState) {
|
|
182
225
|
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
183
226
|
const dest = path.join(root, p.skillsDir, 'keelson');
|
|
184
|
-
return
|
|
227
|
+
return previousRuntimeMatches(root, path.join(p.skillsDir, 'keelson'), 'shims', previousState) ||
|
|
228
|
+
['en', 'zh'].some((lang) => skillShimMatches(dest, renderSkillShim(lang, version), version));
|
|
185
229
|
}
|
|
186
230
|
|
|
187
|
-
export function installSkill(root, target, { lang, version, legacyVersion = null, previousVersion = null, force = false }) {
|
|
231
|
+
export function installSkill(root, target, { lang, version, legacyVersion = null, previousVersion = null, previousState, force = false }) {
|
|
188
232
|
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
189
233
|
const dest = path.join(root, p.skillsDir, 'keelson');
|
|
190
234
|
const content = renderSkillShim(lang, version);
|
|
191
|
-
assertSkillInstallable(root, p, { lang, version, legacyVersion, previousVersion, force });
|
|
235
|
+
assertSkillInstallable(root, p, { lang, version, legacyVersion, previousVersion, previousState, force });
|
|
192
236
|
withLock(dest, () => replaceDirSafe(dest, (tmp) => write(path.join(tmp, 'SKILL.md'), content)));
|
|
193
237
|
return path.relative(root, dest);
|
|
194
238
|
}
|
|
@@ -210,14 +254,21 @@ export function plannedWorkflowFile(root, { lang, guide = false }) {
|
|
|
210
254
|
return { path: path.relative(root, target), status };
|
|
211
255
|
}
|
|
212
256
|
|
|
213
|
-
export function
|
|
257
|
+
export function assertWorkflowInstallable(root, { lang, guide = false, previousState, force = false }) {
|
|
214
258
|
const target = path.join(root, CANONICAL_WORKFLOW);
|
|
215
259
|
const content = workflowContent(lang, guide);
|
|
216
260
|
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
217
|
-
if (stat && (stat.
|
|
261
|
+
if (stat && (!stat.isFile() || normalize(read(target)) !== content) &&
|
|
262
|
+
!previousRuntimeMatches(root, CANONICAL_WORKFLOW, 'workflows', previousState) &&
|
|
263
|
+
!(previousState?.packageVersion === '0.3.0' && stat.isFile() && LEGACY_V03_WORKFLOW_HASHES.has(fileHash(read(target)))) && !force) {
|
|
218
264
|
throw new Error('vendored workflow differs from this CLI output; Keelson left it unchanged. Review it, then pass --force only if replacing it is intended.');
|
|
219
265
|
}
|
|
220
|
-
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function installWorkflow(root, options) {
|
|
269
|
+
assertWorkflowInstallable(root, options);
|
|
270
|
+
const target = path.join(root, CANONICAL_WORKFLOW);
|
|
271
|
+
write(target, workflowContent(options.lang, options.guide));
|
|
221
272
|
return path.relative(root, target);
|
|
222
273
|
}
|
|
223
274
|
|
|
@@ -225,12 +276,12 @@ export function residentBlock(lang) {
|
|
|
225
276
|
return read(path.join(skillSource(lang), 'templates', 'resident-block.md'));
|
|
226
277
|
}
|
|
227
278
|
|
|
228
|
-
export function removeCanonicalRuntime(root, { lang, profile, version, guide = false } = {}) {
|
|
279
|
+
export function removeCanonicalRuntime(root, { lang, profile, version, guide = false, previousState } = {}) {
|
|
229
280
|
const removed = [];
|
|
230
281
|
const preserved = [];
|
|
231
282
|
const skill = path.join(root, CANONICAL_SKILL_DIR);
|
|
232
283
|
if (exists(skill)) {
|
|
233
|
-
if (canonicalSkillMatches(root, { lang, profile, version })) {
|
|
284
|
+
if (canonicalSkillMatches(root, { lang, profile, version }) || previousRuntimeMatches(root, CANONICAL_SKILL_DIR, 'skills', previousState)) {
|
|
234
285
|
rmrf(skill);
|
|
235
286
|
removed.push(CANONICAL_SKILL_DIR);
|
|
236
287
|
} else preserved.push(CANONICAL_SKILL_DIR);
|
|
@@ -240,7 +291,7 @@ export function removeCanonicalRuntime(root, { lang, profile, version, guide = f
|
|
|
240
291
|
preserved.push(CANONICAL_WORKFLOW);
|
|
241
292
|
} else if (exists(workflow)) {
|
|
242
293
|
const legacyWorkflow = version === '0.3.0' && LEGACY_V03_WORKFLOW_HASHES.has(fileHash(read(workflow)));
|
|
243
|
-
if (normalize(read(workflow)) === workflowContent(lang, guide) || legacyWorkflow) {
|
|
294
|
+
if (normalize(read(workflow)) === workflowContent(lang, guide) || legacyWorkflow || previousRuntimeMatches(root, CANONICAL_WORKFLOW, 'workflows', previousState)) {
|
|
244
295
|
rmrf(workflow);
|
|
245
296
|
removed.push(CANONICAL_WORKFLOW);
|
|
246
297
|
} else preserved.push(CANONICAL_WORKFLOW);
|