@zyaiting/keelson 0.4.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/LICENSE +21 -0
- package/README.md +101 -0
- package/README_CN.md +101 -0
- package/bin/keelson.js +15 -0
- package/hooks/codebuddy-session.mjs +67 -0
- package/hooks/opencode-session.mjs +65 -0
- package/hooks/prompt-state.mjs +66 -0
- package/hooks/session-start.mjs +94 -0
- package/package.json +64 -0
- package/registry/models.json +118 -0
- package/registry/platforms.json +92 -0
- package/skills/keelson/SKILL.md +44 -0
- package/skills/keelson/references/build.md +61 -0
- package/skills/keelson/references/context.md +34 -0
- package/skills/keelson/references/debug.md +46 -0
- package/skills/keelson/references/design-lenses.md +78 -0
- package/skills/keelson/references/discover.md +70 -0
- package/skills/keelson/references/engineer.md +110 -0
- package/skills/keelson/references/frontend-delivery.md +38 -0
- package/skills/keelson/references/frontend-interaction.md +31 -0
- package/skills/keelson/references/frontend-review.md +33 -0
- package/skills/keelson/references/frontend-visual.md +31 -0
- package/skills/keelson/references/frontend.md +33 -0
- package/skills/keelson/references/handoff.md +43 -0
- package/skills/keelson/references/harness.md +54 -0
- package/skills/keelson/references/interview.md +120 -0
- package/skills/keelson/references/land.md +47 -0
- package/skills/keelson/references/model.md +29 -0
- package/skills/keelson/references/plan.md +106 -0
- package/skills/keelson/references/reconcile.md +61 -0
- package/skills/keelson/references/shape.md +86 -0
- package/skills/keelson/references/verify.md +64 -0
- package/skills/keelson/templates/GLOSSARY.md +5 -0
- package/skills/keelson/templates/INTENT.md +22 -0
- package/skills/keelson/templates/NOW.md +9 -0
- package/skills/keelson/templates/README.md +60 -0
- package/skills/keelson/templates/ROADMAP.md +12 -0
- package/skills/keelson/templates/change-quick.md +16 -0
- package/skills/keelson/templates/change.md +32 -0
- package/skills/keelson/templates/delta-spec.md +12 -0
- package/skills/keelson/templates/handoff.md +27 -0
- package/skills/keelson/templates/ledger.md +3 -0
- package/skills/keelson/templates/resident-block.md +7 -0
- package/skills/keelson/templates/rules-general.md +10 -0
- package/skills/keelson/templates/rules-index.md +5 -0
- package/skills/keelson/templates/spec.md +14 -0
- package/skills/keelson/templates/tasks.md +9 -0
- package/skills/keelson/templates/workflow.md +18 -0
- package/skills/zh/keelson/SKILL.md +46 -0
- package/skills/zh/keelson/references/build.md +61 -0
- package/skills/zh/keelson/references/context.md +34 -0
- package/skills/zh/keelson/references/debug.md +46 -0
- package/skills/zh/keelson/references/design-lenses.md +78 -0
- package/skills/zh/keelson/references/discover.md +70 -0
- package/skills/zh/keelson/references/engineer.md +110 -0
- package/skills/zh/keelson/references/frontend-delivery.md +38 -0
- package/skills/zh/keelson/references/frontend-interaction.md +31 -0
- package/skills/zh/keelson/references/frontend-review.md +33 -0
- package/skills/zh/keelson/references/frontend-visual.md +31 -0
- package/skills/zh/keelson/references/frontend.md +33 -0
- package/skills/zh/keelson/references/handoff.md +43 -0
- package/skills/zh/keelson/references/harness.md +54 -0
- package/skills/zh/keelson/references/interview.md +120 -0
- package/skills/zh/keelson/references/land.md +47 -0
- package/skills/zh/keelson/references/model.md +29 -0
- package/skills/zh/keelson/references/plan.md +106 -0
- package/skills/zh/keelson/references/reconcile.md +61 -0
- package/skills/zh/keelson/references/shape.md +86 -0
- package/skills/zh/keelson/references/verify.md +64 -0
- package/skills/zh/keelson/templates/GLOSSARY.md +5 -0
- package/skills/zh/keelson/templates/INTENT.md +22 -0
- package/skills/zh/keelson/templates/NOW.md +9 -0
- package/skills/zh/keelson/templates/README.md +60 -0
- package/skills/zh/keelson/templates/ROADMAP.md +12 -0
- package/skills/zh/keelson/templates/change-quick.md +16 -0
- package/skills/zh/keelson/templates/change.md +32 -0
- package/skills/zh/keelson/templates/delta-spec.md +12 -0
- package/skills/zh/keelson/templates/handoff.md +27 -0
- package/skills/zh/keelson/templates/ledger.md +3 -0
- package/skills/zh/keelson/templates/resident-block.md +7 -0
- package/skills/zh/keelson/templates/rules-general.md +10 -0
- package/skills/zh/keelson/templates/rules-index.md +5 -0
- package/skills/zh/keelson/templates/spec.md +14 -0
- package/skills/zh/keelson/templates/tasks.md +9 -0
- package/skills/zh/keelson/templates/workflow.md +18 -0
- package/src/cli.js +87 -0
- package/src/commands/ablate.js +96 -0
- package/src/commands/ask.js +64 -0
- package/src/commands/attest.js +71 -0
- package/src/commands/check.js +127 -0
- package/src/commands/context.js +95 -0
- package/src/commands/design.js +63 -0
- package/src/commands/doctor.js +157 -0
- package/src/commands/focus.js +84 -0
- package/src/commands/guide.js +59 -0
- package/src/commands/handoff.js +41 -0
- package/src/commands/hook.js +23 -0
- package/src/commands/impact.js +58 -0
- package/src/commands/init.js +289 -0
- package/src/commands/land.js +258 -0
- package/src/commands/models.js +62 -0
- package/src/commands/new.js +70 -0
- package/src/commands/platforms.js +39 -0
- package/src/commands/retro.js +114 -0
- package/src/commands/status.js +115 -0
- package/src/commands/uninstall.js +30 -0
- package/src/commands/validate.js +117 -0
- package/src/lib/args.js +30 -0
- package/src/lib/changes.js +114 -0
- package/src/lib/check-activity.js +29 -0
- package/src/lib/config.js +102 -0
- package/src/lib/decisions.js +59 -0
- package/src/lib/evidence.js +127 -0
- package/src/lib/fs.js +126 -0
- package/src/lib/git.js +353 -0
- package/src/lib/glob.js +54 -0
- package/src/lib/health.js +113 -0
- package/src/lib/lifecycle.js +120 -0
- package/src/lib/maintenance.js +66 -0
- package/src/lib/markdown.js +438 -0
- package/src/lib/models.js +195 -0
- package/src/lib/out.js +13 -0
- package/src/lib/paths.js +82 -0
- package/src/lib/rules.js +27 -0
- package/src/lib/runtime-path.js +22 -0
- package/src/lib/session.js +100 -0
- package/src/lib/specs.js +345 -0
- package/src/lib/transaction.js +93 -0
- package/src/platforms/index.js +3 -0
- package/src/platforms/integration.js +384 -0
- package/src/platforms/registry.js +46 -0
- package/src/platforms/runtime.js +249 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { exists, read, readJson, readOr, rmrf, write, writeJson } from '../lib/fs.js';
|
|
5
|
+
import { installTargets, PLATFORMS } from './registry.js';
|
|
6
|
+
import { managedSkillShimMatches, residentBlock } from './runtime.js';
|
|
7
|
+
|
|
8
|
+
export const MANAGED_STATE = path.join('.keelson', 'manifest.json');
|
|
9
|
+
export const LEGACY_MANAGED_STATE = path.join('.keelson', '.managed.json');
|
|
10
|
+
|
|
11
|
+
// v0.3 copied these package scripts into the project. Retire only exact known
|
|
12
|
+
// copies, including the older installed snapshot committed in f6ce125 itself.
|
|
13
|
+
// A user amendment or symbolic link is their file, not ours.
|
|
14
|
+
const LEGACY_COPIED_HOOK_HASHES = new Map([
|
|
15
|
+
['session-start.mjs', new Set([
|
|
16
|
+
'2c425e68f52b1b9c8febeb410916b14b302893bf0b241ea1b9ba4275452ee217',
|
|
17
|
+
'2b2f3523abaf14671764a0f1967ad649881013cc8cc3cb70b51e91b4b5d9dee7',
|
|
18
|
+
])],
|
|
19
|
+
['prompt-state.mjs', new Set([
|
|
20
|
+
'cd7301e7efa00368a437d8a3e084844df1cd611969ae2465c82d8ac8f6707131',
|
|
21
|
+
'10cf6cb9781c58330160602d85dcd2d417bc11c1e99223a7e55477a2529448a7',
|
|
22
|
+
])],
|
|
23
|
+
['codebuddy-session.mjs', new Set(['cbdcbac42ab752387721c3b53f9fae50a22e04d2e04dbb612dba45d90efc25a8'])],
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const LEGACY_MANAGED_PATHS = [
|
|
27
|
+
'.cursor/skills/keelson',
|
|
28
|
+
'.cursor/rules/keelson.mdc',
|
|
29
|
+
'.github/skills/keelson',
|
|
30
|
+
'.kilocode/skills/keelson',
|
|
31
|
+
'.kilocode/rules/keelson.md',
|
|
32
|
+
'.kiro/steering/keelson.md',
|
|
33
|
+
'.qoder/skills/keelson',
|
|
34
|
+
'.qoder/rules/keelson.md',
|
|
35
|
+
'.codebuddy/rules/keelson.md',
|
|
36
|
+
'.pi/skills/keelson',
|
|
37
|
+
'.agent/skills/keelson',
|
|
38
|
+
'.agent/rules/keelson.md',
|
|
39
|
+
'.devin/skills/keelson',
|
|
40
|
+
'.factory/skills/keelson',
|
|
41
|
+
'.reasonix/skills/keelson',
|
|
42
|
+
'.zcode/skills/keelson',
|
|
43
|
+
'.trae/skills/keelson',
|
|
44
|
+
'.trae/rules/keelson.md',
|
|
45
|
+
'.grok/skills/keelson',
|
|
46
|
+
'.kimi/skills/keelson',
|
|
47
|
+
'.snow/skills/keelson',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const managedTarget = (t) => ({
|
|
51
|
+
id: t.id ?? null,
|
|
52
|
+
label: t.label ?? t.id ?? 'managed surface',
|
|
53
|
+
instructions: t.instructions,
|
|
54
|
+
skillsDir: t.skillsDir,
|
|
55
|
+
instructionsFormat: t.instructionsFormat ?? null,
|
|
56
|
+
rulesFile: t.rulesFile ?? null,
|
|
57
|
+
rulesFormat: t.rulesFormat ?? null,
|
|
58
|
+
hooks: Boolean(t.hooks),
|
|
59
|
+
sessionAdapter: t.sessionAdapter ?? null,
|
|
60
|
+
});
|
|
61
|
+
const sessionAdapterPaths = () => [];
|
|
62
|
+
const targetKey = (t) => [t.instructions, t.skillsDir, t.instructionsFormat ?? '', t.rulesFile ?? '', t.rulesFormat ?? '', Boolean(t.hooks), t.sessionAdapter ?? ''].join('|');
|
|
63
|
+
const managedSurfacePaths = (t) => [path.join(t.skillsDir, 'keelson'), t.instructions, t.rulesFile, ...sessionAdapterPaths(t)].filter(Boolean);
|
|
64
|
+
|
|
65
|
+
export function readManagedState(root) {
|
|
66
|
+
return readJson(path.join(root, MANAGED_STATE), null) ?? readJson(path.join(root, LEGACY_MANAGED_STATE), null);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function managedTargets(root) {
|
|
70
|
+
const state = readManagedState(root);
|
|
71
|
+
return Array.isArray(state?.targets) ? state.targets : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function removeLegacyCopiedHooks(root) {
|
|
75
|
+
const hooksDir = path.join(root, '.keelson', 'hooks');
|
|
76
|
+
const removed = [];
|
|
77
|
+
const preserved = [];
|
|
78
|
+
for (const [name, expectedHashes] of LEGACY_COPIED_HOOK_HASHES) {
|
|
79
|
+
const target = path.join(hooksDir, name);
|
|
80
|
+
const rel = path.join('.keelson', 'hooks', name);
|
|
81
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
82
|
+
if (stat?.isSymbolicLink()) { preserved.push(rel); continue; }
|
|
83
|
+
if (!stat?.isFile()) continue;
|
|
84
|
+
const actualHash = crypto.createHash('sha256').update(read(target).replace(/\r\n?/g, '\n')).digest('hex');
|
|
85
|
+
if (expectedHashes.has(actualHash)) {
|
|
86
|
+
rmrf(target);
|
|
87
|
+
removed.push(rel);
|
|
88
|
+
} else preserved.push(rel);
|
|
89
|
+
}
|
|
90
|
+
if (exists(hooksDir) && fs.readdirSync(hooksDir).length === 0) rmrf(hooksDir);
|
|
91
|
+
return { removed, preserved };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function managedStateMatches(root, targets) {
|
|
95
|
+
const state = readManagedState(root);
|
|
96
|
+
if (!state || Number(state.schema ?? state.version) !== 1) return false;
|
|
97
|
+
const a = JSON.stringify((state.targets ?? []).map(managedTarget).sort((x, y) => targetKey(x).localeCompare(targetKey(y))));
|
|
98
|
+
const b = JSON.stringify(targets.map(managedTarget).sort((x, y) => targetKey(x).localeCompare(targetKey(y))));
|
|
99
|
+
return a === b;
|
|
100
|
+
}
|
|
101
|
+
|
|
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) });
|
|
104
|
+
if (exists(path.join(root, LEGACY_MANAGED_STATE))) rmrf(path.join(root, LEGACY_MANAGED_STATE));
|
|
105
|
+
return MANAGED_STATE;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function staleManagedTargets(root, targets) {
|
|
109
|
+
const current = new Set(targets.map(targetKey));
|
|
110
|
+
return managedTargets(root).filter((t) => !current.has(targetKey(t)));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function legacyManagedRemovals(root, targets) {
|
|
114
|
+
// Older releases did not record byte-level ownership for these retired paths.
|
|
115
|
+
// A title or frontmatter is not sufficient proof that a directory is ours.
|
|
116
|
+
// Keep unknown legacy content for the owner to review instead of deleting it.
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function plannedManagedRemovals(root, targets) {
|
|
121
|
+
const keep = new Set(targets.flatMap(managedSurfacePaths));
|
|
122
|
+
return [...new Set([
|
|
123
|
+
...staleManagedTargets(root, targets).flatMap(managedSurfacePaths).filter((rel) => !keep.has(rel)),
|
|
124
|
+
...legacyManagedRemovals(root, targets),
|
|
125
|
+
])].filter((rel) => exists(path.join(root, rel)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const START = '<!-- keelson:start -->';
|
|
129
|
+
const END = '<!-- keelson:end -->';
|
|
130
|
+
|
|
131
|
+
export function upsertBlock(existing, block) {
|
|
132
|
+
const body = block.trim();
|
|
133
|
+
if (existing.includes(START) && existing.includes(END)) {
|
|
134
|
+
return existing.replace(new RegExp(`${START}[\\s\\S]*?${END}`), body);
|
|
135
|
+
}
|
|
136
|
+
const sep = existing.trim() ? (existing.endsWith('\n') ? '\n' : '\n\n') : '';
|
|
137
|
+
return existing + sep + body + '\n';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function removeBlock(existing) {
|
|
141
|
+
const stripped = existing.replace(new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n?`), '');
|
|
142
|
+
if (!stripped.trim()) return '';
|
|
143
|
+
return stripped.replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const stripMarkers = (block) => block.replace(START, '').replace(END, '').trim();
|
|
147
|
+
|
|
148
|
+
export function installInstructions(root, target, { lang }) {
|
|
149
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
150
|
+
const block = residentBlock(lang);
|
|
151
|
+
const touched = [];
|
|
152
|
+
const file = path.join(root, p.instructions);
|
|
153
|
+
if (p.instructionsFormat === 'kiro') {
|
|
154
|
+
const existing = readOr(file, '');
|
|
155
|
+
const front = existing.match(/^(---\n[\s\S]*?\n---\n?)/)?.[1];
|
|
156
|
+
if (front) write(file, front.replace(/\n*$/, '\n\n') + upsertBlock(existing.slice(front.length), block));
|
|
157
|
+
else if (existing) write(file, upsertBlock(existing, block));
|
|
158
|
+
else write(file, `---\ninclusion: always\n---\n\n${block.trim()}\n`);
|
|
159
|
+
} else {
|
|
160
|
+
write(file, upsertBlock(readOr(file, ''), block));
|
|
161
|
+
}
|
|
162
|
+
touched.push(path.relative(root, file));
|
|
163
|
+
if (p.rulesFile) {
|
|
164
|
+
const rf = path.join(root, p.rulesFile);
|
|
165
|
+
const body = stripMarkers(block);
|
|
166
|
+
write(rf, p.rulesFormat === 'mdc' ? `---\ndescription: Keelson project workflow\nalwaysApply: true\n---\n\n${body}\n` : `${body}\n`);
|
|
167
|
+
touched.push(path.relative(root, rf));
|
|
168
|
+
}
|
|
169
|
+
return touched;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const LEGACY_CLAUDE_HOOK_COMMANDS = new Set([
|
|
173
|
+
'node "$CLAUDE_PROJECT_DIR/.keelson/hooks/session-start.mjs"',
|
|
174
|
+
'node "$CLAUDE_PROJECT_DIR/.keelson/hooks/prompt-state.mjs"',
|
|
175
|
+
]);
|
|
176
|
+
const CLAUDE_HOOK_COMMANDS = new Set(['keelson hook session-start', 'keelson hook prompt-state', ...LEGACY_CLAUDE_HOOK_COMMANDS]);
|
|
177
|
+
const isClaudeHook = (hook) => hook?.type === 'command' && CLAUDE_HOOK_COMMANDS.has(String(hook.command));
|
|
178
|
+
|
|
179
|
+
export function installHooks(root) {
|
|
180
|
+
removeHooks(root, { legacyOnly: true });
|
|
181
|
+
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
182
|
+
const settings = readJson(settingsPath, {}) ?? {};
|
|
183
|
+
settings.hooks ??= {};
|
|
184
|
+
const ensure = (event, matcher, script) => {
|
|
185
|
+
settings.hooks[event] ??= [];
|
|
186
|
+
const command = `keelson hook ${script.replace(/\.mjs$/, '')}`;
|
|
187
|
+
const already = settings.hooks[event].some((g) => (g.hooks ?? []).some((h) => h?.type === 'command' && h.command === command));
|
|
188
|
+
if (already) return;
|
|
189
|
+
const group = { hooks: [{ type: 'command', command, timeout: 10 }] };
|
|
190
|
+
if (matcher) group.matcher = matcher;
|
|
191
|
+
settings.hooks[event].push(group);
|
|
192
|
+
};
|
|
193
|
+
ensure('SessionStart', 'startup|resume|clear|compact', 'session-start.mjs');
|
|
194
|
+
ensure('UserPromptSubmit', null, 'prompt-state.mjs');
|
|
195
|
+
writeJson(settingsPath, settings);
|
|
196
|
+
return ['.claude/settings.json'];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function removeHooks(root, { legacyOnly = false } = {}) {
|
|
200
|
+
const settingsPath = path.join(root, '.claude', 'settings.json');
|
|
201
|
+
const settings = readJson(settingsPath, null);
|
|
202
|
+
if (!settings?.hooks) return;
|
|
203
|
+
let changed = false;
|
|
204
|
+
for (const ev of Object.keys(settings.hooks)) {
|
|
205
|
+
const groups = settings.hooks[ev].map((g) => {
|
|
206
|
+
const hooks = (g.hooks ?? []).filter((h) => !isClaudeHook(h) || (legacyOnly && !LEGACY_CLAUDE_HOOK_COMMANDS.has(h.command)));
|
|
207
|
+
if (hooks.length === (g.hooks ?? []).length) return g;
|
|
208
|
+
changed = true;
|
|
209
|
+
return hooks.length ? { ...g, hooks } : null;
|
|
210
|
+
}).filter(Boolean);
|
|
211
|
+
settings.hooks[ev] = groups;
|
|
212
|
+
if (!settings.hooks[ev].length) delete settings.hooks[ev];
|
|
213
|
+
}
|
|
214
|
+
if (!changed) return;
|
|
215
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
216
|
+
writeJson(settingsPath, settings);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const CODEBUDDY_SESSION_COMMAND = 'keelson hook codebuddy-session';
|
|
220
|
+
const LEGACY_CODEBUDDY_SESSION_COMMAND = 'node "$CODEBUDDY_PROJECT_DIR/.keelson/hooks/codebuddy-session.mjs"';
|
|
221
|
+
const isCodeBuddyHook = (hook) => hook?.type === 'command' && hook.command === CODEBUDDY_SESSION_COMMAND;
|
|
222
|
+
|
|
223
|
+
function ensureCodeBuddyHook(settings, event, matcher = null) {
|
|
224
|
+
settings.hooks ??= {};
|
|
225
|
+
settings.hooks[event] ??= [];
|
|
226
|
+
const found = settings.hooks[event].find((g) =>
|
|
227
|
+
(g.hooks ?? []).some(isCodeBuddyHook),
|
|
228
|
+
);
|
|
229
|
+
if (found) {
|
|
230
|
+
if (matcher && found.matcher !== matcher) found.matcher = matcher;
|
|
231
|
+
else if (!matcher && 'matcher' in found) delete found.matcher;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const group = {
|
|
235
|
+
hooks: [{
|
|
236
|
+
type: 'command',
|
|
237
|
+
command: CODEBUDDY_SESSION_COMMAND,
|
|
238
|
+
timeout: 10,
|
|
239
|
+
}],
|
|
240
|
+
};
|
|
241
|
+
if (matcher) group.matcher = matcher;
|
|
242
|
+
settings.hooks[event].push(group);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function installSessionAdapter(root, target) {
|
|
246
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
247
|
+
if (!p?.sessionAdapter || p.sessionAdapter === 'pi-env' || p.sessionAdapter === 'claude-hooks') return [];
|
|
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');
|
|
283
|
+
const settings = readJson(settingsPath, null);
|
|
284
|
+
if (!settings?.hooks) return false;
|
|
285
|
+
let changed = false;
|
|
286
|
+
for (const event of Object.keys(settings.hooks)) {
|
|
287
|
+
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) && (legacyOnly || !isCodeBuddyHook(h)),
|
|
290
|
+
);
|
|
291
|
+
if (hooks.length === (g.hooks ?? []).length) return g;
|
|
292
|
+
changed = true;
|
|
293
|
+
return hooks.length ? { ...g, hooks } : null;
|
|
294
|
+
}).filter(Boolean);
|
|
295
|
+
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
296
|
+
}
|
|
297
|
+
if (!changed) return false;
|
|
298
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
299
|
+
if (!Object.keys(settings).length) rmrf(settingsPath);
|
|
300
|
+
else writeJson(settingsPath, settings);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function removeSessionAdapter(root, target, keep = new Set()) {
|
|
305
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
306
|
+
const removed = [];
|
|
307
|
+
if (p?.sessionAdapter === 'codebuddy-hooks') {
|
|
308
|
+
if (removeCodeBuddyHooks(root)) removed.push('.codebuddy/settings.json (Keelson hooks)');
|
|
309
|
+
}
|
|
310
|
+
return removed;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function sessionAdapterProblems(root, target) {
|
|
314
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
315
|
+
const problems = [];
|
|
316
|
+
if (!p?.sessionAdapter || p.sessionAdapter === 'pi-env' || p.sessionAdapter === 'claude-hooks') return problems;
|
|
317
|
+
|
|
318
|
+
if (p.sessionAdapter === 'codebuddy-hooks') {
|
|
319
|
+
const settings = readJson(path.join(root, '.codebuddy', 'settings.json'), {}) ?? {};
|
|
320
|
+
const has = (event, matcher = null) => (settings.hooks?.[event] ?? []).some((g) =>
|
|
321
|
+
(matcher === null || g.matcher === matcher) &&
|
|
322
|
+
(g.hooks ?? []).some(isCodeBuddyHook),
|
|
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
|
+
}
|
|
328
|
+
|
|
329
|
+
return problems;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function removeTargetSurfaces(root, p, keep = new Set()) {
|
|
333
|
+
const removed = [];
|
|
334
|
+
const skillRel = path.join(p.skillsDir, 'keelson');
|
|
335
|
+
const skill = path.join(root, skillRel);
|
|
336
|
+
const managed = readManagedState(root);
|
|
337
|
+
if (!keep.has(skillRel) && exists(skill) && managedSkillShimMatches(root, p, managed?.packageVersion)) {
|
|
338
|
+
rmrf(skill);
|
|
339
|
+
removed.push(path.relative(root, skill));
|
|
340
|
+
}
|
|
341
|
+
const insRel = p.instructions;
|
|
342
|
+
const ins = path.join(root, insRel);
|
|
343
|
+
if (!keep.has(insRel) && exists(ins)) {
|
|
344
|
+
write(ins, removeBlock(read(ins)));
|
|
345
|
+
removed.push(path.relative(root, ins));
|
|
346
|
+
}
|
|
347
|
+
if (p.rulesFile && !keep.has(p.rulesFile) && exists(path.join(root, p.rulesFile))) {
|
|
348
|
+
rmrf(path.join(root, p.rulesFile));
|
|
349
|
+
removed.push(p.rulesFile);
|
|
350
|
+
}
|
|
351
|
+
if (p.hooks && !keep.has('.claude/settings.json')) removeHooks(root);
|
|
352
|
+
removed.push(...removeSessionAdapter(root, p, keep));
|
|
353
|
+
return removed;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function reconcileManagedTargets(root, targets) {
|
|
357
|
+
const removed = [];
|
|
358
|
+
const stale = staleManagedTargets(root, targets);
|
|
359
|
+
const keep = new Set(targets.flatMap(managedSurfacePaths));
|
|
360
|
+
if (targets.some((p) => p.hooks)) keep.add('.claude/settings.json');
|
|
361
|
+
for (const p of stale) removed.push(...removeTargetSurfaces(root, p, keep));
|
|
362
|
+
for (const rel of legacyManagedRemovals(root, targets)) {
|
|
363
|
+
rmrf(path.join(root, rel));
|
|
364
|
+
removed.push(rel);
|
|
365
|
+
}
|
|
366
|
+
if (stale.some((p) => p.hooks) && !targets.some((p) => p.hooks)) {
|
|
367
|
+
removeHooks(root);
|
|
368
|
+
}
|
|
369
|
+
return [...new Set(removed)];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function removeSurfaces(root, tools, cfg = null) {
|
|
373
|
+
const stateTargets = managedTargets(root);
|
|
374
|
+
const targets = stateTargets.length ? stateTargets : installTargets(tools.filter((t) => PLATFORMS[t]), cfg);
|
|
375
|
+
const removed = [];
|
|
376
|
+
for (const p of targets) removed.push(...removeTargetSurfaces(root, p));
|
|
377
|
+
removeHooks(root);
|
|
378
|
+
for (const rel of [MANAGED_STATE, LEGACY_MANAGED_STATE]) {
|
|
379
|
+
if (!exists(path.join(root, rel))) continue;
|
|
380
|
+
rmrf(path.join(root, rel));
|
|
381
|
+
removed.push(rel);
|
|
382
|
+
}
|
|
383
|
+
return [...new Set(removed)];
|
|
384
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const REGISTRY = require('../../registry/platforms.json');
|
|
5
|
+
|
|
6
|
+
/** Host discovery registry. Runtime guidance itself always lives under .keelson/. */
|
|
7
|
+
export const PLATFORMS = Object.fromEntries(Object.entries(REGISTRY.platforms).map(([id, p]) => [id, { id, ...p }]));
|
|
8
|
+
export const PLATFORM_IDS = Object.keys(PLATFORMS);
|
|
9
|
+
export const RETIRED_PLATFORM_IDS = ['cursor', 'copilot', 'kilo', 'antigravity', 'devin', 'qoder', 'droid', 'ohmypi', 'reasonix', 'zcode', 'trae', 'grok', 'kimi', 'snow'];
|
|
10
|
+
|
|
11
|
+
export const CROSS_TOOL = {
|
|
12
|
+
id: 'agents',
|
|
13
|
+
label: 'cross-tool layer',
|
|
14
|
+
instructions: 'AGENTS.md',
|
|
15
|
+
skillsDir: '.agents/skills',
|
|
16
|
+
hooks: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Merge per-project discovery overrides on top of the first-class registry. */
|
|
20
|
+
export function platformFor(id, cfg = null) {
|
|
21
|
+
const base = PLATFORMS[id];
|
|
22
|
+
if (!base) return null;
|
|
23
|
+
const over = cfg?.platforms?.[id];
|
|
24
|
+
return over && typeof over === 'object' ? { ...base, ...over } : base;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Expand only explicitly selected hosts into unique concrete discovery targets. */
|
|
28
|
+
export function installTargets(tools, cfg = null) {
|
|
29
|
+
const targets = [];
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
const push = (t) => {
|
|
32
|
+
// Host identity is part of the target contract now: two hosts may reuse the
|
|
33
|
+
// same discovery files but still require different session adapters.
|
|
34
|
+
const key = t.id ?? `${t.instructions}|${t.skillsDir}|${t.rulesFile ?? ''}`;
|
|
35
|
+
if (seen.has(key)) return;
|
|
36
|
+
seen.add(key);
|
|
37
|
+
targets.push(t);
|
|
38
|
+
};
|
|
39
|
+
for (const id of tools) {
|
|
40
|
+
const p = platformFor(id, cfg);
|
|
41
|
+
if (!p) throw new Error(`unknown tool "${id}". Known: ${PLATFORM_IDS.join(', ')}`);
|
|
42
|
+
const sessionAdapter = p.sessionAdapter === 'pi-env' ? 'pi-env' : cfg?.hooks === false ? null : p.sessionAdapter ?? null;
|
|
43
|
+
push({ ...p, hooks: Boolean(p.hooks && cfg?.hooks !== false), sessionAdapter });
|
|
44
|
+
}
|
|
45
|
+
return targets;
|
|
46
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { PKG_ROOT } from '../lib/paths.js';
|
|
5
|
+
import { exists, read, walk, write, rmrf, replaceDirSafe, withLock } from '../lib/fs.js';
|
|
6
|
+
import { PLATFORMS } from './registry.js';
|
|
7
|
+
|
|
8
|
+
export const CANONICAL_SKILL_DIR = path.join('.keelson', 'skill');
|
|
9
|
+
export const CANONICAL_WORKFLOW = path.join('.keelson', 'workflow.md');
|
|
10
|
+
|
|
11
|
+
export function skillSource(lang) {
|
|
12
|
+
const dir = lang === 'zh' ? path.join(PKG_ROOT, 'skills', 'zh', 'keelson') : path.join(PKG_ROOT, 'skills', 'keelson');
|
|
13
|
+
return exists(dir) ? dir : path.join(PKG_ROOT, 'skills', 'keelson');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Strip guided-only blocks from the lean profile. */
|
|
17
|
+
export function applyProfile(text, profile) {
|
|
18
|
+
if (profile === 'guided') return text.replace(/<!-- \/?guided -->\n?/g, '');
|
|
19
|
+
return text.replace(/<!-- guided -->[\s\S]*?<!-- \/guided -->\n?/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stamp package version into SKILL.md frontmatter and normalize package-owned Markdown to LF. */
|
|
23
|
+
export function stampVersion(text, version) {
|
|
24
|
+
text = text.replace(/\r\n?/g, '\n');
|
|
25
|
+
if (!version) return text;
|
|
26
|
+
if (/^version:\s*/m.test(text.split('\n---')[0] ?? '')) return text.replace(/^version:.*$/m, `version: ${version}`);
|
|
27
|
+
return text.replace(/^---\n([\s\S]*?)\n---/, (_, fm) => `---\n${fm}\nversion: ${version}\n---`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderSkillFiles(lang, profile, version) {
|
|
31
|
+
const src = skillSource(lang);
|
|
32
|
+
const out = [];
|
|
33
|
+
const visit = (d, rel) => {
|
|
34
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
35
|
+
if (e.name === 'templates') continue;
|
|
36
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
37
|
+
if (e.isDirectory()) visit(path.join(d, e.name), r);
|
|
38
|
+
else {
|
|
39
|
+
let content = fs.readFileSync(path.join(d, e.name), 'utf8');
|
|
40
|
+
if (r.endsWith('.md')) content = applyProfile(content.replace(/\r\n?/g, '\n'), profile);
|
|
41
|
+
if (r === 'SKILL.md') content = stampVersion(content, version);
|
|
42
|
+
out.push({ rel: r, content });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
visit(src, '');
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderSkillShim(lang, version) {
|
|
51
|
+
const full = stampVersion(read(path.join(skillSource(lang), 'SKILL.md')).replace(/\r\n?/g, '\n'), version);
|
|
52
|
+
const front = full.match(/^---\n[\s\S]*?\n---/)?.[0] ?? '---\nname: keelson\ndescription: Keelson project skill\n---';
|
|
53
|
+
const body = lang === 'zh'
|
|
54
|
+
? '# Keelson\n\n这是发现入口,不是技能真源。请立即运行 `keelson guide` 并按其执行;需要更深指导时运行 `keelson guide <reference>`。只有项目需要提交审计副本时才使用 `keelson init --vendor`。\n'
|
|
55
|
+
: '# Keelson\n\nThis is a discovery shim, not the source of truth. Run `keelson guide` now and follow it; for a routed topic run `keelson guide <reference>`. Use `keelson init --vendor` only when this project needs a checked-in copy of the guidance.\n';
|
|
56
|
+
return `${front}\n\n${body}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function plannedCanonicalSkillFiles(root, { lang, profile, version }) {
|
|
60
|
+
const dest = path.join(root, CANONICAL_SKILL_DIR);
|
|
61
|
+
return renderSkillFiles(lang, profile, version).map((f) => {
|
|
62
|
+
const target = path.join(dest, f.rel);
|
|
63
|
+
const status = !exists(target) ? 'create' : fs.readFileSync(target, 'utf8') === f.content ? 'unchanged' : 'update';
|
|
64
|
+
return { path: path.relative(root, target), status };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function plannedSkillFiles(root, target, { lang, version }) {
|
|
69
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
70
|
+
const targetPath = path.join(root, p.skillsDir, 'keelson', 'SKILL.md');
|
|
71
|
+
const content = renderSkillShim(lang, version);
|
|
72
|
+
const status = !exists(targetPath) ? 'create' : fs.readFileSync(targetPath, 'utf8') === content ? 'unchanged' : 'update';
|
|
73
|
+
return [{ path: path.relative(root, targetPath), status }];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalize(text) {
|
|
77
|
+
return String(text).replace(/\r\n?/g, '\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Captured by running f6ce125's v0.3 CLI for every supported language/profile
|
|
81
|
+
// combination. A tree hash commits to both paths and normalized file bytes, so
|
|
82
|
+
// adding a neighbor or editing one byte makes this fail closed.
|
|
83
|
+
const LEGACY_V03_SKILL_TREE_HASHES = new Map([
|
|
84
|
+
['en/lean', '07ac8e2cb1010956fb172b72661a29ec3c6552ce19433fc4b1a31d012d65272b'],
|
|
85
|
+
['en/guided', 'a3a6572d22d88f872eaf0326b0af3c674064cd027abba449715bf64e45201652'],
|
|
86
|
+
['zh/lean', '08dd2c503b58372ef85c460867c6e5ef116919b1f69a00449f1ef750cd7603a0'],
|
|
87
|
+
['zh/guided', 'd00636b7408c34cabf097d22d347d1e551f629f126f7c9e156760cb73d7d9506'],
|
|
88
|
+
]);
|
|
89
|
+
const LEGACY_V03_SKILL_SHIM_HASHES = new Set([
|
|
90
|
+
'68a1924866f8ee5af87d0cd75884d3d1ba5bb3138c4daa2d6bd0f6a7d53cd2ef',
|
|
91
|
+
'4157f6ca8898e6e94a351d1fa63da580c54574049cc4af549834ee4fcb4a237b',
|
|
92
|
+
]);
|
|
93
|
+
const LEGACY_V03_WORKFLOW_HASHES = new Set([
|
|
94
|
+
'8f8b682ce18a8b9fe6acf16fb528a2a752a4c874db7fc23b3029b7d83ba20dd1',
|
|
95
|
+
'eab1d92c97ca695b34ae2290d6533858da7ab89f912ce31acbb89e979b75945b',
|
|
96
|
+
'd8c89ed738bf8c82e643aea41d1cf30cd48b218035234a60b09a2ba6183ae7a4',
|
|
97
|
+
'a3495a7de442c25aa8e41a3d680cf396c6207d2bdc8566d6d1644aa28bd7f5ba',
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function fileHash(text) {
|
|
101
|
+
return crypto.createHash('sha256').update(normalize(text)).digest('hex');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ownedWalk(dir) {
|
|
105
|
+
return walk(dir, { ignore: [] });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hasSymbolicLink(dir) {
|
|
109
|
+
const visit = (current) => {
|
|
110
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
111
|
+
if (entry.isSymbolicLink()) return true;
|
|
112
|
+
if (entry.isDirectory() && visit(path.join(current, entry.name))) return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
};
|
|
116
|
+
try { return fs.lstatSync(dir).isSymbolicLink() || visit(dir); } catch { return true; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function treeHash(dir) {
|
|
120
|
+
if (hasSymbolicLink(dir)) return null;
|
|
121
|
+
try {
|
|
122
|
+
return crypto.createHash('sha256').update(ownedWalk(dir)
|
|
123
|
+
.map((rel) => `${rel}\0${fileHash(read(path.join(dir, rel)))}\n`).join('')).digest('hex');
|
|
124
|
+
} catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function canonicalSkillMatches(root, { lang, profile, version }) {
|
|
128
|
+
const dest = path.join(root, CANONICAL_SKILL_DIR);
|
|
129
|
+
const files = renderSkillFiles(lang, profile, version);
|
|
130
|
+
if (!hasSymbolicLink(dest) && JSON.stringify(ownedWalk(dest)) === JSON.stringify(files.map((file) => file.rel).sort()) &&
|
|
131
|
+
files.every((file) => exists(path.join(dest, file.rel)) && normalize(read(path.join(dest, file.rel))) === file.content)) return true;
|
|
132
|
+
return version === '0.3.0' && treeHash(dest) === LEGACY_V03_SKILL_TREE_HASHES.get(`${lang}/${profile}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function installCanonicalSkill(root, { lang, profile, version, force = false }) {
|
|
136
|
+
const dest = path.join(root, CANONICAL_SKILL_DIR);
|
|
137
|
+
const files = renderSkillFiles(lang, profile, version);
|
|
138
|
+
if (fs.lstatSync(dest, { throwIfNoEntry: false }) && !canonicalSkillMatches(root, { lang, profile, version }) && !force) {
|
|
139
|
+
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
|
+
}
|
|
141
|
+
withLock(dest, () => {
|
|
142
|
+
replaceDirSafe(dest, (tmp) => {
|
|
143
|
+
for (const f of files) write(path.join(tmp, f.rel), f.content);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
return path.relative(root, dest);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function legacyV03SkillShimMatches(dest, legacyVersion) {
|
|
150
|
+
const skill = path.join(dest, 'SKILL.md');
|
|
151
|
+
return legacyVersion === '0.3.0' && exists(skill) && !hasSymbolicLink(dest) && ownedWalk(dest).length === 1 && LEGACY_V03_SKILL_SHIM_HASHES.has(fileHash(read(skill)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function skillShimMatches(dest, content, legacyVersion) {
|
|
155
|
+
const skill = path.join(dest, 'SKILL.md');
|
|
156
|
+
return exists(skill) && !hasSymbolicLink(dest) && ownedWalk(dest).length === 1 &&
|
|
157
|
+
(normalize(read(skill)) === content || legacyV03SkillShimMatches(dest, legacyVersion));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertSkillInstallable(root, target, { lang, version, legacyVersion, previousVersion, force }) {
|
|
161
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
162
|
+
const dest = path.join(root, p.skillsDir, 'keelson');
|
|
163
|
+
const priorOutput = previousVersion && managedSkillShimMatches(root, p, previousVersion);
|
|
164
|
+
if (fs.lstatSync(dest, { throwIfNoEntry: false }) && !skillShimMatches(dest, renderSkillShim(lang, version), legacyVersion) && !priorOutput && !force) {
|
|
165
|
+
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
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Validate discovery replacements before init changes config or legacy runtime. */
|
|
170
|
+
export function assertSkillsInstallable(root, targets, options) {
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
for (const target of targets) {
|
|
173
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
174
|
+
if (seen.has(p.skillsDir)) continue;
|
|
175
|
+
seen.add(p.skillsDir);
|
|
176
|
+
assertSkillInstallable(root, p, options);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** True only for a complete current shim or the byte-exact v0.3 shim. */
|
|
181
|
+
export function managedSkillShimMatches(root, target, version) {
|
|
182
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
183
|
+
const dest = path.join(root, p.skillsDir, 'keelson');
|
|
184
|
+
return ['en', 'zh'].some((lang) => skillShimMatches(dest, renderSkillShim(lang, version), version));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function installSkill(root, target, { lang, version, legacyVersion = null, previousVersion = null, force = false }) {
|
|
188
|
+
const p = typeof target === 'string' ? PLATFORMS[target] : target;
|
|
189
|
+
const dest = path.join(root, p.skillsDir, 'keelson');
|
|
190
|
+
const content = renderSkillShim(lang, version);
|
|
191
|
+
assertSkillInstallable(root, p, { lang, version, legacyVersion, previousVersion, force });
|
|
192
|
+
withLock(dest, () => replaceDirSafe(dest, (tmp) => write(path.join(tmp, 'SKILL.md'), content)));
|
|
193
|
+
return path.relative(root, dest);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function workflowContent(lang, guide = false) {
|
|
197
|
+
const file = path.join(skillSource(lang), 'templates', 'workflow.md');
|
|
198
|
+
let text = read(file).replace(/\r\n?/g, '\n');
|
|
199
|
+
if (!guide) return text;
|
|
200
|
+
const line = lang === 'zh'
|
|
201
|
+
? '> 引导模式:项目所有者希望边做边学。基础提问仍保持场景化;额外解释已采用的工程概念、约束理由,并在 spec 变更收尾时附一段简短教学说明。'
|
|
202
|
+
: '> Guided mode: the owner wants to learn while building. Keep the same accessible questions; additionally name the engineering ideas behind decisions, explain constraint rationale, and close spec changes with a short teaching note.';
|
|
203
|
+
return text.replace(/^# ([^\n]+)\n/, (m) => `${m}\n${line}\n`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function plannedWorkflowFile(root, { lang, guide = false }) {
|
|
207
|
+
const target = path.join(root, CANONICAL_WORKFLOW);
|
|
208
|
+
const content = workflowContent(lang, guide);
|
|
209
|
+
const status = !exists(target) ? 'create' : fs.readFileSync(target, 'utf8') === content ? 'unchanged' : 'update';
|
|
210
|
+
return { path: path.relative(root, target), status };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function installWorkflow(root, { lang, guide = false, force = false }) {
|
|
214
|
+
const target = path.join(root, CANONICAL_WORKFLOW);
|
|
215
|
+
const content = workflowContent(lang, guide);
|
|
216
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
217
|
+
if (stat && (stat.isSymbolicLink() || normalize(read(target)) !== content) && !force) {
|
|
218
|
+
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
|
+
}
|
|
220
|
+
write(target, content);
|
|
221
|
+
return path.relative(root, target);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function residentBlock(lang) {
|
|
225
|
+
return read(path.join(skillSource(lang), 'templates', 'resident-block.md'));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function removeCanonicalRuntime(root, { lang, profile, version, guide = false } = {}) {
|
|
229
|
+
const removed = [];
|
|
230
|
+
const preserved = [];
|
|
231
|
+
const skill = path.join(root, CANONICAL_SKILL_DIR);
|
|
232
|
+
if (exists(skill)) {
|
|
233
|
+
if (canonicalSkillMatches(root, { lang, profile, version })) {
|
|
234
|
+
rmrf(skill);
|
|
235
|
+
removed.push(CANONICAL_SKILL_DIR);
|
|
236
|
+
} else preserved.push(CANONICAL_SKILL_DIR);
|
|
237
|
+
}
|
|
238
|
+
const workflow = path.join(root, CANONICAL_WORKFLOW);
|
|
239
|
+
if (fs.lstatSync(workflow, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
240
|
+
preserved.push(CANONICAL_WORKFLOW);
|
|
241
|
+
} else if (exists(workflow)) {
|
|
242
|
+
const legacyWorkflow = version === '0.3.0' && LEGACY_V03_WORKFLOW_HASHES.has(fileHash(read(workflow)));
|
|
243
|
+
if (normalize(read(workflow)) === workflowContent(lang, guide) || legacyWorkflow) {
|
|
244
|
+
rmrf(workflow);
|
|
245
|
+
removed.push(CANONICAL_WORKFLOW);
|
|
246
|
+
} else preserved.push(CANONICAL_WORKFLOW);
|
|
247
|
+
}
|
|
248
|
+
return { removed, preserved };
|
|
249
|
+
}
|