@boopkit/cli 0.6.12 → 0.6.14

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.
Files changed (43) hide show
  1. package/README.md +27 -6
  2. package/dist/capability-versions.json +16 -16
  3. package/dist/cli.js +9 -1
  4. package/dist/commands/add.js +49 -2
  5. package/dist/commands/init.d.ts +17 -1
  6. package/dist/commands/init.js +151 -26
  7. package/dist/commands/loop-merge.d.ts +1 -0
  8. package/dist/commands/loop-merge.js +4 -2
  9. package/dist/commands/loop.d.ts +21 -2
  10. package/dist/commands/loop.js +105 -16
  11. package/dist/commands/loop.js.orig-pre-maxbuffer +1455 -0
  12. package/dist/commands/migrate-skill-edits.d.ts +24 -0
  13. package/dist/commands/migrate-skill-edits.js +190 -0
  14. package/dist/commands/status.d.ts +8 -0
  15. package/dist/commands/status.js +13 -0
  16. package/dist/commands/update.js +55 -7
  17. package/dist/lib/apply-skill-edits.d.ts +76 -0
  18. package/dist/lib/apply-skill-edits.js +307 -0
  19. package/dist/lib/execute.js +1 -0
  20. package/dist/lib/original-text.d.ts +43 -0
  21. package/dist/lib/original-text.js +165 -0
  22. package/dist/lib/scaffold.d.ts +41 -7
  23. package/dist/lib/scaffold.js +96 -15
  24. package/dist/lib/skill-edits.d.ts +88 -0
  25. package/dist/lib/skill-edits.js +293 -0
  26. package/dist/lib/update.d.ts +38 -2
  27. package/dist/lib/update.js +149 -6
  28. package/docs/init.md +132 -86
  29. package/docs/loop.md +16 -0
  30. package/docs/skills.md +1 -0
  31. package/package.json +3 -2
  32. package/profiles/base/profile.md +105 -0
  33. package/profiles/base/templates/.github/copilot-instructions.md +3 -0
  34. package/profiles/base/templates/.gitignore.hbs +6 -0
  35. package/profiles/base/templates/AGENTS.md.hbs +50 -0
  36. package/profiles/base/templates/CLAUDE.md.hbs +3 -0
  37. package/profiles/base/templates/README.md.hbs +15 -0
  38. package/profiles/base/templates/docs/README.md +12 -0
  39. package/profiles/base/templates/package.json.hbs +7 -0
  40. package/profiles/base/templates/reference/README.md +16 -0
  41. package/profiles/base/templates/specs/README.md +12 -0
  42. package/profiles/development/profile.md +8 -1
  43. package/src/templates/skill-project/PROJECT.md +28 -0
@@ -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
+ }
@@ -101,6 +101,7 @@ function executeTask(task, manifest) {
101
101
  case 'copy-files': {
102
102
  (0, scaffold_1.copyCapabilityFiles)(targetDir, capabilityName);
103
103
  (0, scaffold_1.linkSkillsToClaude)(targetDir);
104
+ (0, scaffold_1.createSkillProjectFiles)(targetDir);
104
105
  break;
105
106
  }
106
107
  case 'merge-deps': {
@@ -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
+ };
@@ -1,9 +1,9 @@
1
1
  import { CapabilityHook } from './config';
2
2
  export interface TemplateVariables {
3
3
  projectName: string;
4
- port: number;
5
- scripts: Record<string, string>;
6
- nodeVersion: string;
4
+ port?: number;
5
+ scripts?: Record<string, string>;
6
+ nodeVersion?: string;
7
7
  hookImports?: string;
8
8
  hookInit?: string;
9
9
  hookPreRoutes?: string;
@@ -21,15 +21,31 @@ export interface TemplateVariables {
21
21
  */
22
22
  export declare function renderTemplate(templateContent: string, variables: TemplateVariables): string;
23
23
  /**
24
- * Copy and render all kernel templates into a target directory.
25
- * Creates directories as needed. Does not overwrite existing files
26
- * unless overwrite is true.
24
+ * Resolve the template directory `boop init` renders for a profile.
25
+ *
26
+ * A profile that ships its own `profiles/<name>/templates/` folder gets that
27
+ * folder, which must be complete on its own — nothing is merged from the kernel.
28
+ * Every other profile falls back to `src/templates/kernel/`. The fallback is
29
+ * what keeps `web-app`, which ships no templates/ folder, scaffolding exactly
30
+ * what it always has.
31
+ */
32
+ export declare function getTemplatesDir(profileName: string): string;
33
+ export declare function profileHasTemplates(profileName: string): boolean;
34
+ /**
35
+ * Copy and render every file in a templates directory (the kernel templates
36
+ * by default) into a target directory. Creates directories as needed. Does
37
+ * not overwrite existing files unless overwrite is true.
27
38
  */
28
- export declare function scaffoldKernel(targetDir: string, variables: TemplateVariables, overwrite?: boolean): string[];
39
+ export declare function scaffoldKernel(targetDir: string, variables: TemplateVariables, overwrite?: boolean, templatesDir?: string): string[];
29
40
  /**
30
41
  * Compute SHA-256 hash of a file's content.
31
42
  */
32
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;
33
49
  /**
34
50
  * Copy capability-specific files into a target project directory.
35
51
  * Tries package resolution first (scaffold/ and skills/ dirs), falls back to source tree.
@@ -53,6 +69,24 @@ export declare function copyCapabilityFiles(targetDir: string, capabilityName: s
53
69
  * Idempotent. Returns the relative paths of symlinks created or already present.
54
70
  */
55
71
  export declare function linkSkillsToClaude(targetDir: string, only?: Iterable<string>): string[];
72
+ /**
73
+ * Give every skill folder under `<targetDir>/skills/` that lacks one a
74
+ * `PROJECT.md`, where the project records how it applies the skill. Each boop
75
+ * `SKILL.md` opens by pointing at it; the file being present is the backstop
76
+ * for an agent that skipped the pointer.
77
+ *
78
+ * The file is the project's: boop never hashes it, so `boop update` can never
79
+ * report it, and an existing one is never opened for writing.
80
+ *
81
+ * Returns the relative paths of the files created.
82
+ */
83
+ export declare function createSkillProjectFiles(targetDir: string): string[];
84
+ /**
85
+ * Whether a skill's PROJECT.md has rules under "Offered to boop" — anything in
86
+ * that section beyond the template's own explanation of it.
87
+ */
88
+ export declare function hasOfferedRules(projectFilePath: string): boolean;
89
+ export declare function readSkillProjectTemplate(): string;
56
90
  /**
57
91
  * Merge dependencies into an existing package.json.
58
92
  * Does not overwrite existing dependency versions.