@boopkit/cli 0.6.13 → 0.6.15
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 +1 -1
- package/dist/capability-versions.json +16 -16
- package/dist/cli.js +7 -0
- package/dist/commands/init.js +2 -1
- package/dist/commands/migrate-skill-edits.d.ts +24 -0
- package/dist/commands/migrate-skill-edits.js +190 -0
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.js +13 -0
- package/dist/commands/update.js +84 -7
- package/dist/lib/apply-skill-edits.d.ts +76 -0
- package/dist/lib/apply-skill-edits.js +307 -0
- package/dist/lib/execute.js +4 -12
- package/dist/lib/original-text.d.ts +43 -0
- package/dist/lib/original-text.js +165 -0
- package/dist/lib/scaffold.d.ts +53 -0
- package/dist/lib/scaffold.js +134 -1
- package/dist/lib/script-merge.d.ts +55 -0
- package/dist/lib/script-merge.js +144 -0
- package/dist/lib/skill-edits.d.ts +88 -0
- package/dist/lib/skill-edits.js +293 -0
- package/dist/lib/stale-scripts.d.ts +3 -2
- package/dist/lib/stale-scripts.js +3 -2
- package/dist/lib/update.d.ts +45 -2
- package/dist/lib/update.js +207 -44
- package/docs/skills.md +1 -0
- package/package.json +1 -1
- package/src/templates/skill-project/PROJECT.md +28 -0
- package/dist/commands/loop.js.orig-pre-maxbuffer +0 -1455
- package/dist/commands/status.test.d.ts +0 -1
- package/dist/commands/status.test.js +0 -118
- package/dist/lib/project.test.d.ts +0 -1
- package/dist/lib/project.test.js +0 -118
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The applying half of `boop migrate-skill-edits`: check each decided plan,
|
|
4
|
+
* move its kept and offered rules into PROJECT.md, hand SKILL.md back to boop,
|
|
5
|
+
* and record boop's hash, so the skill reads as unmodified at once.
|
|
6
|
+
*/
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
41
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
42
|
+
};
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.RULE_HEADINGS = exports.POINTER_BLOCK = void 0;
|
|
45
|
+
exports.applySkillEditPlans = applySkillEditPlans;
|
|
46
|
+
exports.opensWithPointer = opensWithPointer;
|
|
47
|
+
exports.addRule = addRule;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const gray_matter_1 = __importDefault(require("gray-matter"));
|
|
51
|
+
const capability_1 = require("./capability");
|
|
52
|
+
const install_layout_1 = require("./install-layout");
|
|
53
|
+
const project_1 = require("./project");
|
|
54
|
+
const scaffold_1 = require("./scaffold");
|
|
55
|
+
const skill_edits_1 = require("./skill-edits");
|
|
56
|
+
const update_1 = require("./update");
|
|
57
|
+
/**
|
|
58
|
+
* The block every boop SKILL.md opens with. Keep in step with POINTER_BLOCK in
|
|
59
|
+
* tools/audit-skill-links.js, which holds the shipped skills to it.
|
|
60
|
+
*/
|
|
61
|
+
exports.POINTER_BLOCK = [
|
|
62
|
+
'> **Read `PROJECT.md`, beside this file, first.**',
|
|
63
|
+
'> It records how this project applies the guidance below, and where it deliberately differs.',
|
|
64
|
+
'> Where the two conflict, `PROJECT.md` wins.',
|
|
65
|
+
'> Record project-specific rules there, never in this file.',
|
|
66
|
+
].join('\n');
|
|
67
|
+
/** The PROJECT.md heading each kind of rule goes under */
|
|
68
|
+
exports.RULE_HEADINGS = {
|
|
69
|
+
adds: 'Adds',
|
|
70
|
+
narrows: 'Narrows',
|
|
71
|
+
overrides: 'Overrides',
|
|
72
|
+
offer: 'Offered to boop',
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Check every plan in `.boop/skill-edits/`, apply each one that passes, and
|
|
76
|
+
* record the hash of every skill that already holds boop's copy. A plan that
|
|
77
|
+
* fails any check, or whose drops nobody confirms, has nothing applied.
|
|
78
|
+
*/
|
|
79
|
+
async function applySkillEditPlans(projectDir, manifest, deps) {
|
|
80
|
+
const outcomes = [];
|
|
81
|
+
for (const planPath of listPlans(projectDir)) {
|
|
82
|
+
const checked = checkPlan(projectDir, manifest, planPath);
|
|
83
|
+
if ('reasons' in checked) {
|
|
84
|
+
outcomes.push({ kind: 'refused', planPath, reasons: checked.reasons });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (checked.drops.length > 0) {
|
|
88
|
+
if (!deps.interactive) {
|
|
89
|
+
outcomes.push({ kind: 'needs-person', planPath, drops: checked.drops });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const declined = [];
|
|
93
|
+
for (const drop of checked.drops) {
|
|
94
|
+
const yes = await deps.confirm(`Drop ${drop.edit} from ${checked.skillPath}? The reason given: ${drop.reason}`);
|
|
95
|
+
if (!yes)
|
|
96
|
+
declined.push(drop);
|
|
97
|
+
}
|
|
98
|
+
if (declined.length > 0) {
|
|
99
|
+
outcomes.push({ kind: 'declined', planPath, declined });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
outcomes.push(applyPlan(projectDir, manifest, checked));
|
|
104
|
+
// Written per plan, so a later prompt that is interrupted cannot leave a restored skill without its hash
|
|
105
|
+
(0, project_1.writeProjectManifest)(projectDir, manifest);
|
|
106
|
+
}
|
|
107
|
+
const recordedHashes = recordMatchingSkillHashes(projectDir, manifest);
|
|
108
|
+
if (recordedHashes.length > 0) {
|
|
109
|
+
(0, project_1.writeProjectManifest)(projectDir, manifest);
|
|
110
|
+
}
|
|
111
|
+
return { outcomes, recordedHashes };
|
|
112
|
+
}
|
|
113
|
+
function listPlans(projectDir) {
|
|
114
|
+
const dir = path.join(projectDir, skill_edits_1.SKILL_EDITS_DIR);
|
|
115
|
+
if (!fs.existsSync(dir))
|
|
116
|
+
return [];
|
|
117
|
+
return fs.readdirSync(dir)
|
|
118
|
+
.filter(name => name.endsWith('.md'))
|
|
119
|
+
.sort()
|
|
120
|
+
.map(name => path.join(skill_edits_1.SKILL_EDITS_DIR, name));
|
|
121
|
+
}
|
|
122
|
+
function checkPlan(projectDir, manifest, planPath) {
|
|
123
|
+
const rewrite = 'Run `boop migrate-skill-edits` to write a new plan for the current text, and decide it again.';
|
|
124
|
+
let plan;
|
|
125
|
+
try {
|
|
126
|
+
// An empty options object keeps gray-matter from answering from its cache
|
|
127
|
+
plan = (0, gray_matter_1.default)(fs.readFileSync(path.join(projectDir, planPath), 'utf-8'), {});
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return { reasons: [`Its frontmatter does not parse: ${err.message.split('\n')[0]}. Fix the YAML and run again.`] };
|
|
131
|
+
}
|
|
132
|
+
const data = plan.data;
|
|
133
|
+
const skillPath = typeof data.path === 'string' ? data.path : '';
|
|
134
|
+
if (!/^skills\/[^/]+\/SKILL\.md$/.test(skillPath) || typeof data.capability !== 'string'
|
|
135
|
+
|| typeof data.hashes?.project !== 'string' || typeof data.hashes?.boop !== 'string'
|
|
136
|
+
|| typeof data.decisions !== 'object' || data.decisions === null) {
|
|
137
|
+
return { reasons: [`Its frontmatter is missing the path, capability, hashes or decisions the command wrote. ${rewrite}`] };
|
|
138
|
+
}
|
|
139
|
+
const reasons = [];
|
|
140
|
+
const rules = [];
|
|
141
|
+
const drops = [];
|
|
142
|
+
const editIds = editHeadings(plan.content);
|
|
143
|
+
const decisions = data.decisions;
|
|
144
|
+
for (const id of editIds) {
|
|
145
|
+
const entry = decisions[id];
|
|
146
|
+
const decision = entry?.decision;
|
|
147
|
+
const rule = nonBlank(entry?.rule);
|
|
148
|
+
const reason = nonBlank(entry?.reason);
|
|
149
|
+
if (decision === null || decision === undefined) {
|
|
150
|
+
reasons.push(`${id} has no decision. Record keep, offer or drop, following the triage skill.`);
|
|
151
|
+
}
|
|
152
|
+
else if (decision === 'keep') {
|
|
153
|
+
if (!rule)
|
|
154
|
+
reasons.push(`${id} is kept but has no rule. Write the edit as a rule that stands on its own.`);
|
|
155
|
+
if (!isKeepKind(entry.kind))
|
|
156
|
+
reasons.push(`${id} is kept but its kind is not adds, narrows or overrides.`);
|
|
157
|
+
if (rule && isKeepKind(entry.kind))
|
|
158
|
+
rules.push({ edit: id, heading: exports.RULE_HEADINGS[entry.kind], rule, ...(reason && { reason }) });
|
|
159
|
+
}
|
|
160
|
+
else if (decision === 'offer') {
|
|
161
|
+
if (rule)
|
|
162
|
+
rules.push({ edit: id, heading: exports.RULE_HEADINGS.offer, rule, ...(reason && { reason }) });
|
|
163
|
+
else
|
|
164
|
+
reasons.push(`${id} is offered to boop but has no rule. Write the edit as a rule for any project.`);
|
|
165
|
+
}
|
|
166
|
+
else if (decision === 'drop') {
|
|
167
|
+
if (reason)
|
|
168
|
+
drops.push({ edit: id, reason });
|
|
169
|
+
else
|
|
170
|
+
reasons.push(`${id} is dropped but has no reason. Say why, in one sentence, for the person who confirms it.`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
reasons.push(`${id} has the decision "${String(decision)}", which is not keep, offer or drop.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const id of Object.keys(decisions)) {
|
|
177
|
+
if (!editIds.includes(id))
|
|
178
|
+
reasons.push(`${id} is not an edit in this plan. Remove it from decisions.`);
|
|
179
|
+
}
|
|
180
|
+
const skillFile = path.join(projectDir, skillPath);
|
|
181
|
+
if (!fs.existsSync(skillFile)) {
|
|
182
|
+
reasons.push(`${skillPath} no longer exists. ${rewrite}`);
|
|
183
|
+
}
|
|
184
|
+
else if ((0, scaffold_1.hashContent)(fs.readFileSync(skillFile)) !== data.hashes.project) {
|
|
185
|
+
reasons.push(`${skillPath} has changed since this plan was written, so its decisions were made about other text. ${rewrite}`);
|
|
186
|
+
}
|
|
187
|
+
const boopCopyPath = manifest.capabilities.includes(data.capability) ? findBoopCopy(projectDir, data.capability, skillPath) : null;
|
|
188
|
+
if (!boopCopyPath) {
|
|
189
|
+
reasons.push(`boop has no copy of ${skillPath} in the ${data.capability} capability to restore. ${rewrite}`);
|
|
190
|
+
return { reasons };
|
|
191
|
+
}
|
|
192
|
+
const boopText = fs.readFileSync(boopCopyPath);
|
|
193
|
+
const boopHash = (0, scaffold_1.hashContent)(boopText);
|
|
194
|
+
if (boopHash !== data.hashes.boop) {
|
|
195
|
+
reasons.push(`boop's copy of ${skillPath} has changed since this plan was written, probably in a boop update. ${rewrite}`);
|
|
196
|
+
}
|
|
197
|
+
if (!opensWithPointer(boopText.toString('utf-8'))) {
|
|
198
|
+
reasons.push(`boop's copy of ${skillPath} does not open with the block that sends an agent to PROJECT.md, ` +
|
|
199
|
+
"so restoring it would leave this project's rules unread. Run `boop update` to get a release whose skills carry the block, then write the plan again.");
|
|
200
|
+
}
|
|
201
|
+
if (reasons.length > 0)
|
|
202
|
+
return { reasons };
|
|
203
|
+
return { planPath, capability: data.capability, skillPath, boopCopyPath, boopHash, rules, drops };
|
|
204
|
+
}
|
|
205
|
+
function applyPlan(projectDir, manifest, plan) {
|
|
206
|
+
const projectFile = path.join(path.dirname(plan.skillPath), 'PROJECT.md');
|
|
207
|
+
const projectFilePath = path.join(projectDir, projectFile);
|
|
208
|
+
let rules = fs.existsSync(projectFilePath) ? fs.readFileSync(projectFilePath, 'utf-8') : (0, scaffold_1.readSkillProjectTemplate)();
|
|
209
|
+
for (const rule of plan.rules)
|
|
210
|
+
rules = addRule(rules, rule.heading, rule.rule);
|
|
211
|
+
fs.writeFileSync(projectFilePath, rules);
|
|
212
|
+
// The edited copy is kept under .boop/, which a scaffolded project never commits; beside SKILL.md, `git add -A` would ship it
|
|
213
|
+
const skillFile = path.join(projectDir, plan.skillPath);
|
|
214
|
+
const backupPath = path.join(skill_edits_1.SKILL_EDITS_DIR, 'backups', path.basename(path.dirname(plan.skillPath)), 'SKILL.md');
|
|
215
|
+
fs.mkdirSync(path.join(projectDir, path.dirname(backupPath)), { recursive: true });
|
|
216
|
+
fs.copyFileSync(skillFile, path.join(projectDir, backupPath));
|
|
217
|
+
fs.copyFileSync(plan.boopCopyPath, skillFile);
|
|
218
|
+
// Recorded here, not left to the next update: a release in between would make the restored copy read as a conflict
|
|
219
|
+
const meta = manifest.capabilityMeta?.[plan.capability];
|
|
220
|
+
if (meta)
|
|
221
|
+
meta.fileHashes[plan.skillPath] = plan.boopHash;
|
|
222
|
+
fs.unlinkSync(path.join(projectDir, plan.planPath));
|
|
223
|
+
return {
|
|
224
|
+
kind: 'applied',
|
|
225
|
+
planPath: plan.planPath,
|
|
226
|
+
skillPath: plan.skillPath,
|
|
227
|
+
projectFile,
|
|
228
|
+
backupPath,
|
|
229
|
+
rules: plan.rules,
|
|
230
|
+
drops: plan.drops,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** Move the stored hash forward for every skill that already holds what boop ships. */
|
|
234
|
+
function recordMatchingSkillHashes(projectDir, manifest) {
|
|
235
|
+
const recorded = [];
|
|
236
|
+
for (const capability of manifest.capabilities) {
|
|
237
|
+
const meta = manifest.capabilityMeta?.[capability];
|
|
238
|
+
const plan = (0, update_1.planCapabilityUpdate)(projectDir, capability, manifest);
|
|
239
|
+
for (const action of plan?.actions ?? []) {
|
|
240
|
+
if (action.action !== 'record-hash' || action.fileKind !== 'skill' || !meta)
|
|
241
|
+
continue;
|
|
242
|
+
meta.fileHashes[action.relativePath] = action.newHash;
|
|
243
|
+
recorded.push(action.relativePath);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return recorded;
|
|
247
|
+
}
|
|
248
|
+
function findBoopCopy(projectDir, capability, skillPath) {
|
|
249
|
+
const packageDir = (0, capability_1.getPackageDir)(capability, [projectDir]);
|
|
250
|
+
if (!packageDir)
|
|
251
|
+
return null;
|
|
252
|
+
return (0, install_layout_1.capabilityPlacementsFromDir)(packageDir).find(p => p.dest.split(path.sep).join('/') === skillPath)?.source ?? null;
|
|
253
|
+
}
|
|
254
|
+
/** Whether a SKILL.md's body, below its frontmatter, opens with the pointer block. */
|
|
255
|
+
function opensWithPointer(markdown) {
|
|
256
|
+
const body = markdown.replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---[ \t]*(?:\n|$)/, '').replace(/^\s*\n/, '');
|
|
257
|
+
return body === exports.POINTER_BLOCK || body.startsWith(`${exports.POINTER_BLOCK}\n`);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Append a rule to the end of a section of PROJECT.md, adding the heading at
|
|
261
|
+
* the end of the file when the project has removed it.
|
|
262
|
+
*/
|
|
263
|
+
function addRule(text, heading, rule) {
|
|
264
|
+
const lines = text.split('\n');
|
|
265
|
+
const headings = markdownHeadings(lines);
|
|
266
|
+
const at = headings.findIndex(h => h.level === 2 && h.text === heading);
|
|
267
|
+
if (at === -1) {
|
|
268
|
+
return `${text.replace(/\s*$/, '')}\n\n## ${heading}\n\n${rule}\n`;
|
|
269
|
+
}
|
|
270
|
+
const next = headings.slice(at + 1).find(h => h.level <= 2);
|
|
271
|
+
const before = lines.slice(0, next ? next.index : lines.length).join('\n').replace(/\s*$/, '');
|
|
272
|
+
const after = next ? lines.slice(next.index).join('\n') : '';
|
|
273
|
+
return after ? `${before}\n\n${rule}\n\n${after}` : `${before}\n\n${rule}\n`;
|
|
274
|
+
}
|
|
275
|
+
/** The edit ids a plan's body heads a section with. */
|
|
276
|
+
function editHeadings(body) {
|
|
277
|
+
return markdownHeadings(body.split('\n'))
|
|
278
|
+
.filter(h => h.level === 2 && /^edit-\d+$/.test(h.text))
|
|
279
|
+
.map(h => h.text);
|
|
280
|
+
}
|
|
281
|
+
/** ATX headings outside fenced code, where a `#` line is a comment rather than a heading. */
|
|
282
|
+
function markdownHeadings(lines) {
|
|
283
|
+
const headings = [];
|
|
284
|
+
let fence = null;
|
|
285
|
+
lines.forEach((line, index) => {
|
|
286
|
+
const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
287
|
+
if (fence) {
|
|
288
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length && line.trim() === fenceMatch[1])
|
|
289
|
+
fence = null;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (fenceMatch) {
|
|
293
|
+
fence = fenceMatch[1];
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const heading = /^(#{1,6})[ \t]+(.*?)[ \t#]*$/.exec(line);
|
|
297
|
+
if (heading)
|
|
298
|
+
headings.push({ index, level: heading[1].length, text: heading[2] });
|
|
299
|
+
});
|
|
300
|
+
return headings;
|
|
301
|
+
}
|
|
302
|
+
function isKeepKind(kind) {
|
|
303
|
+
return kind === 'adds' || kind === 'narrows' || kind === 'overrides';
|
|
304
|
+
}
|
|
305
|
+
function nonBlank(value) {
|
|
306
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
307
|
+
}
|
package/dist/lib/execute.js
CHANGED
|
@@ -50,6 +50,7 @@ const project_1 = require("./project");
|
|
|
50
50
|
const env_1 = require("./env");
|
|
51
51
|
const rewire_1 = require("./rewire");
|
|
52
52
|
const safe_io_1 = require("./safe-io");
|
|
53
|
+
const script_merge_1 = require("./script-merge");
|
|
53
54
|
/**
|
|
54
55
|
* Execute all tasks in a manifest. Returns the result with per-task status.
|
|
55
56
|
*/
|
|
@@ -101,6 +102,7 @@ function executeTask(task, manifest) {
|
|
|
101
102
|
case 'copy-files': {
|
|
102
103
|
(0, scaffold_1.copyCapabilityFiles)(targetDir, capabilityName);
|
|
103
104
|
(0, scaffold_1.linkSkillsToClaude)(targetDir);
|
|
105
|
+
(0, scaffold_1.createSkillProjectFiles)(targetDir);
|
|
104
106
|
break;
|
|
105
107
|
}
|
|
106
108
|
case 'merge-deps': {
|
|
@@ -109,19 +111,9 @@ function executeTask(task, manifest) {
|
|
|
109
111
|
break;
|
|
110
112
|
}
|
|
111
113
|
case 'merge-scripts': {
|
|
114
|
+
// One answer to "does this script go in", shared with `boop update`
|
|
112
115
|
const data = task.data;
|
|
113
|
-
|
|
114
|
-
if (fs.existsSync(pkgPath)) {
|
|
115
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
116
|
-
if (!pkg.scripts)
|
|
117
|
-
pkg.scripts = {};
|
|
118
|
-
for (const [scriptName, scriptDef] of Object.entries(data.scripts)) {
|
|
119
|
-
if (!(scriptName in pkg.scripts)) {
|
|
120
|
-
pkg.scripts[scriptName] = scriptDef.cmd;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
|
124
|
-
}
|
|
116
|
+
(0, script_merge_1.applyScriptUpdates)(targetDir, (0, script_merge_1.planScriptUpdates)(data.scripts, (0, script_merge_1.readProjectScripts)(targetDir)));
|
|
125
117
|
break;
|
|
126
118
|
}
|
|
127
119
|
case 'link-package': {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recover the exact text boop installed for a tracked file.
|
|
3
|
+
*
|
|
4
|
+
* `.boop/config.json` keeps a hash per installed file but not the text, and the
|
|
5
|
+
* capability version it records cannot stand in for it: when `boop update`
|
|
6
|
+
* skips an edited file it keeps that file's old hash but records the new
|
|
7
|
+
* version, so after one skipped update the two describe different releases.
|
|
8
|
+
* The hash names the original exactly, whichever release it came from, so the
|
|
9
|
+
* lookup searches for content with that hash — first in the project's own git
|
|
10
|
+
* history of the file, then in the capability's published versions on npm.
|
|
11
|
+
*/
|
|
12
|
+
export type OriginalTextSource = {
|
|
13
|
+
kind: 'git';
|
|
14
|
+
commit: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'npm';
|
|
17
|
+
package: string;
|
|
18
|
+
version: string;
|
|
19
|
+
};
|
|
20
|
+
export type OriginalText = {
|
|
21
|
+
found: true;
|
|
22
|
+
content: string;
|
|
23
|
+
source: OriginalTextSource;
|
|
24
|
+
} | {
|
|
25
|
+
found: false;
|
|
26
|
+
};
|
|
27
|
+
/** Where published capability packages come from. The default is the npm CLI, so the user's registry and auth apply. */
|
|
28
|
+
export interface PackageRegistry {
|
|
29
|
+
/** Every published version, oldest first. */
|
|
30
|
+
listVersions(packageName: string): string[];
|
|
31
|
+
/** Unpack one version into `workDir` and return the package directory. */
|
|
32
|
+
unpack(packageName: string, version: string, workDir: string): string;
|
|
33
|
+
}
|
|
34
|
+
export interface FindOriginalTextOptions {
|
|
35
|
+
registry?: PackageRegistry;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Find the content whose hash is `storedHash` for the file at `relPath`
|
|
39
|
+
* (POSIX, relative to `projectDir`). Never throws: a failure in either source
|
|
40
|
+
* is a miss for that source, and a miss in both is `{ found: false }`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findOriginalText(projectDir: string, relPath: string, storedHash: string, options?: FindOriginalTextOptions): OriginalText;
|
|
43
|
+
export declare const npmRegistry: PackageRegistry;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Recover the exact text boop installed for a tracked file.
|
|
4
|
+
*
|
|
5
|
+
* `.boop/config.json` keeps a hash per installed file but not the text, and the
|
|
6
|
+
* capability version it records cannot stand in for it: when `boop update`
|
|
7
|
+
* skips an edited file it keeps that file's old hash but records the new
|
|
8
|
+
* version, so after one skipped update the two describe different releases.
|
|
9
|
+
* The hash names the original exactly, whichever release it came from, so the
|
|
10
|
+
* lookup searches for content with that hash — first in the project's own git
|
|
11
|
+
* history of the file, then in the capability's published versions on npm.
|
|
12
|
+
*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.npmRegistry = void 0;
|
|
48
|
+
exports.findOriginalText = findOriginalText;
|
|
49
|
+
const child_process_1 = require("child_process");
|
|
50
|
+
const fs = __importStar(require("fs"));
|
|
51
|
+
const os = __importStar(require("os"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
const capability_1 = require("./capability");
|
|
54
|
+
const install_layout_1 = require("./install-layout");
|
|
55
|
+
const project_1 = require("./project");
|
|
56
|
+
const scaffold_1 = require("./scaffold");
|
|
57
|
+
/**
|
|
58
|
+
* Find the content whose hash is `storedHash` for the file at `relPath`
|
|
59
|
+
* (POSIX, relative to `projectDir`). Never throws: a failure in either source
|
|
60
|
+
* is a miss for that source, and a miss in both is `{ found: false }`.
|
|
61
|
+
*/
|
|
62
|
+
function findOriginalText(projectDir, relPath, storedHash, options = {}) {
|
|
63
|
+
return (findInGitHistory(projectDir, relPath, storedHash) ??
|
|
64
|
+
findInPublishedVersions(projectDir, relPath, storedHash, options.registry ?? exports.npmRegistry));
|
|
65
|
+
}
|
|
66
|
+
function findInGitHistory(projectDir, relPath, storedHash) {
|
|
67
|
+
let commits;
|
|
68
|
+
try {
|
|
69
|
+
commits = git(projectDir, 'rev-list', 'HEAD', '--', relPath).toString('utf-8').split('\n').filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
for (const commit of commits) {
|
|
75
|
+
let content;
|
|
76
|
+
try {
|
|
77
|
+
// `./` makes the path relative to projectDir, which may sit below the repository root.
|
|
78
|
+
content = git(projectDir, 'show', `${commit}:./${relPath}`);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
continue; // the commit that deleted the file
|
|
82
|
+
}
|
|
83
|
+
if ((0, scaffold_1.hashContent)(content) === storedHash) {
|
|
84
|
+
return { found: true, content: content.toString('utf-8'), source: { kind: 'git', commit } };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function git(cwd, ...args) {
|
|
90
|
+
return (0, child_process_1.execFileSync)('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
91
|
+
}
|
|
92
|
+
function findInPublishedVersions(projectDir, relPath, storedHash, registry) {
|
|
93
|
+
const capability = owningCapability(projectDir, relPath);
|
|
94
|
+
if (!capability)
|
|
95
|
+
return { found: false };
|
|
96
|
+
const packageName = packageNameFor(capability, projectDir);
|
|
97
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boop-original-text-'));
|
|
98
|
+
try {
|
|
99
|
+
for (const version of registry.listVersions(packageName).slice().reverse()) {
|
|
100
|
+
const pkgDir = registry.unpack(packageName, version, path.join(workDir, version));
|
|
101
|
+
const placement = (0, install_layout_1.capabilityPlacementsFromDir)(pkgDir).find(p => p.dest === relPath);
|
|
102
|
+
if (!placement)
|
|
103
|
+
continue;
|
|
104
|
+
const content = fs.readFileSync(placement.source);
|
|
105
|
+
if ((0, scaffold_1.hashContent)(content) === storedHash) {
|
|
106
|
+
return { found: true, content: content.toString('utf-8'), source: { kind: 'npm', package: packageName, version } };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// An unreachable registry or a broken tarball is a miss, not a failure: the caller has a fallback.
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
return { found: false };
|
|
117
|
+
}
|
|
118
|
+
function owningCapability(projectDir, relPath) {
|
|
119
|
+
let manifest;
|
|
120
|
+
try {
|
|
121
|
+
manifest = (0, project_1.readProjectManifest)(projectDir);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const entry = Object.entries(manifest?.capabilityMeta ?? {}).find(([, meta]) => relPath in (meta.fileHashes ?? {}));
|
|
127
|
+
return entry ? entry[0] : null;
|
|
128
|
+
}
|
|
129
|
+
function packageNameFor(capability, projectDir) {
|
|
130
|
+
// The installed package knows its own name; `users-and-accounts` ships as @boopkit/cap-users.
|
|
131
|
+
const pkgDir = (0, capability_1.getPackageDir)(capability, [projectDir]);
|
|
132
|
+
if (pkgDir) {
|
|
133
|
+
try {
|
|
134
|
+
const { name } = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
|
|
135
|
+
if (typeof name === 'string')
|
|
136
|
+
return name;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// fall through to the conventional name
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return `@boopkit/cap-${capability}`;
|
|
143
|
+
}
|
|
144
|
+
exports.npmRegistry = {
|
|
145
|
+
listVersions(packageName) {
|
|
146
|
+
const out = (0, child_process_1.execFileSync)('npm', ['view', packageName, 'versions', '--json'], {
|
|
147
|
+
encoding: 'utf-8',
|
|
148
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
149
|
+
});
|
|
150
|
+
// npm prints a bare string, not an array, for a package with one version.
|
|
151
|
+
const versions = JSON.parse(out);
|
|
152
|
+
return Array.isArray(versions) ? versions : [versions];
|
|
153
|
+
},
|
|
154
|
+
unpack(packageName, version, workDir) {
|
|
155
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
156
|
+
const out = (0, child_process_1.execFileSync)('npm', ['pack', `${packageName}@${version}`, '--pack-destination', workDir, '--json'], {
|
|
157
|
+
cwd: workDir,
|
|
158
|
+
encoding: 'utf-8',
|
|
159
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
160
|
+
});
|
|
161
|
+
const [{ filename }] = JSON.parse(out);
|
|
162
|
+
(0, child_process_1.execFileSync)('tar', ['-xzf', path.join(workDir, filename), '-C', workDir], { stdio: 'ignore' });
|
|
163
|
+
return path.join(workDir, 'package');
|
|
164
|
+
},
|
|
165
|
+
};
|
package/dist/lib/scaffold.d.ts
CHANGED
|
@@ -41,6 +41,11 @@ export declare function scaffoldKernel(targetDir: string, variables: TemplateVar
|
|
|
41
41
|
* Compute SHA-256 hash of a file's content.
|
|
42
42
|
*/
|
|
43
43
|
export declare function hashFile(filePath: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* The hash `hashFile` records, for content that is not on disk — a past version
|
|
46
|
+
* read from git, or a file inside a downloaded package.
|
|
47
|
+
*/
|
|
48
|
+
export declare function hashContent(content: Buffer): string;
|
|
44
49
|
/**
|
|
45
50
|
* Copy capability-specific files into a target project directory.
|
|
46
51
|
* Tries package resolution first (scaffold/ and skills/ dirs), falls back to source tree.
|
|
@@ -64,6 +69,54 @@ export declare function copyCapabilityFiles(targetDir: string, capabilityName: s
|
|
|
64
69
|
* Idempotent. Returns the relative paths of symlinks created or already present.
|
|
65
70
|
*/
|
|
66
71
|
export declare function linkSkillsToClaude(targetDir: string, only?: Iterable<string>): string[];
|
|
72
|
+
/**
|
|
73
|
+
* The names of the skill folders under `<targetDir>/skills/` that boop installed.
|
|
74
|
+
*
|
|
75
|
+
* A project's `skills/` is not all boop's: a project writes skills of its own
|
|
76
|
+
* beside the ones its capabilities ship, and those folders are already entirely
|
|
77
|
+
* the project's file. Two records say which are boop's, and the union is taken
|
|
78
|
+
* because neither is complete on its own:
|
|
79
|
+
*
|
|
80
|
+
* - the `fileHashes` a capability stored in `.boop/config.json`, which is the
|
|
81
|
+
* record of what was actually copied into this project;
|
|
82
|
+
* - the skill placements of an installed capability that has no stored hashes —
|
|
83
|
+
* a manifest written before `capabilityMeta` existed, or one whose hash
|
|
84
|
+
* storage was skipped.
|
|
85
|
+
*
|
|
86
|
+
* Returns null when the directory holds no `.boop/config.json`, i.e. nothing
|
|
87
|
+
* here is a boop project and no folder can be attributed either way.
|
|
88
|
+
*/
|
|
89
|
+
export declare function installedSkillNames(targetDir: string, searchPaths?: string[]): Set<string> | null;
|
|
90
|
+
/**
|
|
91
|
+
* Give every boop-installed skill folder under `<targetDir>/skills/` that lacks
|
|
92
|
+
* one a `PROJECT.md`, where the project records how it applies the skill. Each
|
|
93
|
+
* boop `SKILL.md` opens by pointing at it; the file being present is the
|
|
94
|
+
* backstop for an agent that skipped the pointer.
|
|
95
|
+
*
|
|
96
|
+
* Only a folder boop installed gets one. A skill the project wrote itself is
|
|
97
|
+
* already the project's file outright, so a `PROJECT.md` beside it separates
|
|
98
|
+
* nothing — and because this runs on every init, add and update, a project
|
|
99
|
+
* could never delete the stub it left there.
|
|
100
|
+
*
|
|
101
|
+
* For the same reason it takes back a stub an earlier boop wrote beside a
|
|
102
|
+
* project-owned skill, but only while the file's bytes are still exactly the
|
|
103
|
+
* template's: anything the project typed into a `PROJECT.md` keeps it.
|
|
104
|
+
*
|
|
105
|
+
* The file is the project's: boop never hashes it, so `boop update` can never
|
|
106
|
+
* report it, and an existing one is never opened for writing.
|
|
107
|
+
*
|
|
108
|
+
* Returns the relative paths of the files created and of the stubs removed.
|
|
109
|
+
*/
|
|
110
|
+
export declare function createSkillProjectFiles(targetDir: string, searchPaths?: string[]): {
|
|
111
|
+
created: string[];
|
|
112
|
+
removed: string[];
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Whether a skill's PROJECT.md has rules under "Offered to boop" — anything in
|
|
116
|
+
* that section beyond the template's own explanation of it.
|
|
117
|
+
*/
|
|
118
|
+
export declare function hasOfferedRules(projectFilePath: string): boolean;
|
|
119
|
+
export declare function readSkillProjectTemplate(): string;
|
|
67
120
|
/**
|
|
68
121
|
* Merge dependencies into an existing package.json.
|
|
69
122
|
* Does not overwrite existing dependency versions.
|