@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.
@@ -41,8 +41,13 @@ exports.getTemplatesDir = getTemplatesDir;
41
41
  exports.profileHasTemplates = profileHasTemplates;
42
42
  exports.scaffoldKernel = scaffoldKernel;
43
43
  exports.hashFile = hashFile;
44
+ exports.hashContent = hashContent;
44
45
  exports.copyCapabilityFiles = copyCapabilityFiles;
45
46
  exports.linkSkillsToClaude = linkSkillsToClaude;
47
+ exports.installedSkillNames = installedSkillNames;
48
+ exports.createSkillProjectFiles = createSkillProjectFiles;
49
+ exports.hasOfferedRules = hasOfferedRules;
50
+ exports.readSkillProjectTemplate = readSkillProjectTemplate;
46
51
  exports.getBoopkitVersion = getBoopkitVersion;
47
52
  exports.formatUnpinnedDependencyWarning = formatUnpinnedDependencyWarning;
48
53
  exports.mergePackageJson = mergePackageJson;
@@ -56,6 +61,7 @@ const config_1 = require("./config");
56
61
  const capability_1 = require("./capability");
57
62
  const safe_io_1 = require("./safe-io");
58
63
  const install_layout_1 = require("./install-layout");
64
+ const project_1 = require("./project");
59
65
  /**
60
66
  * Render a single Handlebars template string with variables.
61
67
  *
@@ -135,7 +141,13 @@ function scaffoldKernel(targetDir, variables, overwrite = false, templatesDir =
135
141
  * Compute SHA-256 hash of a file's content.
136
142
  */
137
143
  function hashFile(filePath) {
138
- const content = fs.readFileSync(filePath);
144
+ return hashContent(fs.readFileSync(filePath));
145
+ }
146
+ /**
147
+ * The hash `hashFile` records, for content that is not on disk — a past version
148
+ * read from git, or a file inside a downloaded package.
149
+ */
150
+ function hashContent(content) {
139
151
  const hash = crypto.createHash('sha256').update(content).digest('hex');
140
152
  return `sha256:${hash}`;
141
153
  }
@@ -243,6 +255,127 @@ function linkSkillsToClaude(targetDir, only) {
243
255
  }
244
256
  return linked;
245
257
  }
258
+ /**
259
+ * The names of the skill folders under `<targetDir>/skills/` that boop installed.
260
+ *
261
+ * A project's `skills/` is not all boop's: a project writes skills of its own
262
+ * beside the ones its capabilities ship, and those folders are already entirely
263
+ * the project's file. Two records say which are boop's, and the union is taken
264
+ * because neither is complete on its own:
265
+ *
266
+ * - the `fileHashes` a capability stored in `.boop/config.json`, which is the
267
+ * record of what was actually copied into this project;
268
+ * - the skill placements of an installed capability that has no stored hashes —
269
+ * a manifest written before `capabilityMeta` existed, or one whose hash
270
+ * storage was skipped.
271
+ *
272
+ * Returns null when the directory holds no `.boop/config.json`, i.e. nothing
273
+ * here is a boop project and no folder can be attributed either way.
274
+ */
275
+ function installedSkillNames(targetDir, searchPaths) {
276
+ const manifest = (0, project_1.readProjectManifest)(targetDir);
277
+ if (!manifest)
278
+ return null;
279
+ const names = new Set();
280
+ const addDest = (dest) => {
281
+ // Stored destinations are POSIX, but tolerate a manifest written on Windows.
282
+ const [top, skillName] = dest.split(/[\\/]/);
283
+ if (top === 'skills' && skillName)
284
+ names.add(skillName);
285
+ };
286
+ const meta = manifest.capabilityMeta ?? {};
287
+ for (const entry of Object.values(meta)) {
288
+ for (const dest of Object.keys(entry?.fileHashes ?? {}))
289
+ addDest(dest);
290
+ }
291
+ // Walking a package's files is the expensive half, so it runs only for a
292
+ // capability whose hashes are missing — where it is the only record left.
293
+ for (const capability of manifest.capabilities ?? []) {
294
+ if (Object.keys(meta[capability]?.fileHashes ?? {}).length > 0)
295
+ continue;
296
+ for (const placement of (0, install_layout_1.capabilityPlacements)(capability, searchPaths ?? [targetDir])) {
297
+ if (placement.kind === 'skill')
298
+ addDest(placement.dest);
299
+ }
300
+ }
301
+ return names;
302
+ }
303
+ /**
304
+ * Give every boop-installed skill folder under `<targetDir>/skills/` that lacks
305
+ * one a `PROJECT.md`, where the project records how it applies the skill. Each
306
+ * boop `SKILL.md` opens by pointing at it; the file being present is the
307
+ * backstop for an agent that skipped the pointer.
308
+ *
309
+ * Only a folder boop installed gets one. A skill the project wrote itself is
310
+ * already the project's file outright, so a `PROJECT.md` beside it separates
311
+ * nothing — and because this runs on every init, add and update, a project
312
+ * could never delete the stub it left there.
313
+ *
314
+ * For the same reason it takes back a stub an earlier boop wrote beside a
315
+ * project-owned skill, but only while the file's bytes are still exactly the
316
+ * template's: anything the project typed into a `PROJECT.md` keeps it.
317
+ *
318
+ * The file is the project's: boop never hashes it, so `boop update` can never
319
+ * report it, and an existing one is never opened for writing.
320
+ *
321
+ * Returns the relative paths of the files created and of the stubs removed.
322
+ */
323
+ function createSkillProjectFiles(targetDir, searchPaths) {
324
+ const skillsDir = path.join(targetDir, 'skills');
325
+ if (!fs.existsSync(skillsDir))
326
+ return { created: [], removed: [] };
327
+ const installed = installedSkillNames(targetDir, searchPaths);
328
+ if (installed === null)
329
+ return { created: [], removed: [] };
330
+ const template = readSkillProjectTemplate();
331
+ const created = [];
332
+ const removed = [];
333
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
334
+ // A symlinked skill folder is someone else's. In the boop repository each one
335
+ // points into a capability package, from which a PROJECT.md would ship to every project.
336
+ if (!entry.isDirectory())
337
+ continue;
338
+ const filePath = path.join(skillsDir, entry.name, 'PROJECT.md');
339
+ const relPath = path.join('skills', entry.name, 'PROJECT.md');
340
+ const existing = fs.lstatSync(filePath, { throwIfNoEntry: false });
341
+ if (!installed.has(entry.name)) {
342
+ if (existing?.isFile() && fs.readFileSync(filePath, 'utf-8') === template) {
343
+ fs.unlinkSync(filePath);
344
+ removed.push(relPath);
345
+ }
346
+ continue;
347
+ }
348
+ if (existing)
349
+ continue;
350
+ fs.writeFileSync(filePath, template, { flag: 'wx' });
351
+ created.push(relPath);
352
+ }
353
+ return { created, removed };
354
+ }
355
+ /**
356
+ * Whether a skill's PROJECT.md has rules under "Offered to boop" — anything in
357
+ * that section beyond the template's own explanation of it.
358
+ */
359
+ function hasOfferedRules(projectFilePath) {
360
+ if (!fs.existsSync(projectFilePath))
361
+ return false;
362
+ const section = offeredToBoopSection(fs.readFileSync(projectFilePath, 'utf-8'));
363
+ if (section === null)
364
+ return false;
365
+ const explanation = new Set((offeredToBoopSection(readSkillProjectTemplate()) ?? '').split('\n').map(line => line.trim()));
366
+ return section.split('\n').some(line => line.trim() !== '' && !explanation.has(line.trim()));
367
+ }
368
+ function offeredToBoopSection(text) {
369
+ const heading = /^##[ \t]+Offered to boop[ \t]*$/m.exec(text);
370
+ if (!heading)
371
+ return null;
372
+ const rest = text.slice(heading.index + heading[0].length);
373
+ const nextHeading = rest.search(/^#{1,2}[ \t]/m);
374
+ return nextHeading === -1 ? rest : rest.slice(0, nextHeading);
375
+ }
376
+ function readSkillProjectTemplate() {
377
+ return fs.readFileSync(path.join((0, config_1.getBoopRoot)(), 'src', 'templates', 'skill-project', 'PROJECT.md'), 'utf-8');
378
+ }
246
379
  /**
247
380
  * Copy capability files from the capabilities/ source tree (test fixtures only).
248
381
  */
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Merge the npm scripts a capability declares into a project's package.json
3
+ * during `boop update`.
4
+ *
5
+ * The case this exists for (boop-ldou): a capability ships a new command as two
6
+ * things — a file under `scaffold/`, and a `scripts` entry in `capability.json`.
7
+ * `boop add` merges those scripts at install time (see `merge-scripts` in
8
+ * lib/execute.ts), but `boop update` only ever copied files, so an upgraded
9
+ * project received `src/scripts/migrate-credentials.ts` and no way to run it by
10
+ * name. Every document, post-install note and status hint that said
11
+ * `npm run credentials:migrate` was right for a freshly scaffolded project and
12
+ * wrong for every upgraded one — precisely the projects a migration tool is for.
13
+ *
14
+ * The merge is additive, like `boop add`'s: a script is written only when the
15
+ * project has no script of that name. A name the project already uses is left
16
+ * exactly as it stands, because a command somebody customised is theirs, and
17
+ * clobbering it would be worse than leaving it alone — the same restraint
18
+ * `rewriteStaleRunnerScripts` shows when a command has changed underneath it.
19
+ * What boop does do is say so, so a kept command that no longer matches what the
20
+ * capability ships is visible rather than silent.
21
+ */
22
+ import { CapabilityScript } from './config';
23
+ export interface ScriptUpdateAction {
24
+ /** The script's key in package.json, e.g. `credentials:migrate`. */
25
+ name: string;
26
+ /** The command the capability declares. */
27
+ command: string;
28
+ /** `add` writes it; `keep` leaves the project's own command in place. */
29
+ action: 'add' | 'keep';
30
+ /** Why this action was chosen. */
31
+ reason: string;
32
+ /** The command the project has today, when there is one. */
33
+ currentCommand?: string;
34
+ }
35
+ /**
36
+ * Pure: given what a capability declares and what the project's package.json
37
+ * holds, decide what happens to each script.
38
+ *
39
+ * A script whose command already matches what the capability ships is not
40
+ * returned at all — there is nothing to do and nothing worth reporting.
41
+ */
42
+ export declare function planScriptUpdates(declared: Record<string, CapabilityScript> | undefined, projectScripts: Record<string, string> | undefined): ScriptUpdateAction[];
43
+ /**
44
+ * Write the `add` actions into the project's package.json, and return the ones
45
+ * actually written.
46
+ *
47
+ * Re-reads the file rather than trusting the plan: an action is applied only
48
+ * while the name is still free, so a script written between planning and here
49
+ * survives. A missing or unreadable package.json is not an error — scaffold
50
+ * files have already been delivered by the time this runs, and failing the whole
51
+ * update over the script merge would be worse than reporting nothing.
52
+ */
53
+ export declare function applyScriptUpdates(targetDir: string, actions: ScriptUpdateAction[]): ScriptUpdateAction[];
54
+ /** The project's package.json scripts, or undefined when there is no readable one. */
55
+ export declare function readProjectScripts(targetDir: string): Record<string, string> | undefined;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ /**
3
+ * Merge the npm scripts a capability declares into a project's package.json
4
+ * during `boop update`.
5
+ *
6
+ * The case this exists for (boop-ldou): a capability ships a new command as two
7
+ * things — a file under `scaffold/`, and a `scripts` entry in `capability.json`.
8
+ * `boop add` merges those scripts at install time (see `merge-scripts` in
9
+ * lib/execute.ts), but `boop update` only ever copied files, so an upgraded
10
+ * project received `src/scripts/migrate-credentials.ts` and no way to run it by
11
+ * name. Every document, post-install note and status hint that said
12
+ * `npm run credentials:migrate` was right for a freshly scaffolded project and
13
+ * wrong for every upgraded one — precisely the projects a migration tool is for.
14
+ *
15
+ * The merge is additive, like `boop add`'s: a script is written only when the
16
+ * project has no script of that name. A name the project already uses is left
17
+ * exactly as it stands, because a command somebody customised is theirs, and
18
+ * clobbering it would be worse than leaving it alone — the same restraint
19
+ * `rewriteStaleRunnerScripts` shows when a command has changed underneath it.
20
+ * What boop does do is say so, so a kept command that no longer matches what the
21
+ * capability ships is visible rather than silent.
22
+ */
23
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ var desc = Object.getOwnPropertyDescriptor(m, k);
26
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
27
+ desc = { enumerable: true, get: function() { return m[k]; } };
28
+ }
29
+ Object.defineProperty(o, k2, desc);
30
+ }) : (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ o[k2] = m[k];
33
+ }));
34
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
35
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
36
+ }) : function(o, v) {
37
+ o["default"] = v;
38
+ });
39
+ var __importStar = (this && this.__importStar) || (function () {
40
+ var ownKeys = function(o) {
41
+ ownKeys = Object.getOwnPropertyNames || function (o) {
42
+ var ar = [];
43
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
44
+ return ar;
45
+ };
46
+ return ownKeys(o);
47
+ };
48
+ return function (mod) {
49
+ if (mod && mod.__esModule) return mod;
50
+ var result = {};
51
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
52
+ __setModuleDefault(result, mod);
53
+ return result;
54
+ };
55
+ })();
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.planScriptUpdates = planScriptUpdates;
58
+ exports.applyScriptUpdates = applyScriptUpdates;
59
+ exports.readProjectScripts = readProjectScripts;
60
+ const fs = __importStar(require("fs"));
61
+ const path = __importStar(require("path"));
62
+ /**
63
+ * Pure: given what a capability declares and what the project's package.json
64
+ * holds, decide what happens to each script.
65
+ *
66
+ * A script whose command already matches what the capability ships is not
67
+ * returned at all — there is nothing to do and nothing worth reporting.
68
+ */
69
+ function planScriptUpdates(declared, projectScripts) {
70
+ if (!declared)
71
+ return [];
72
+ const actions = [];
73
+ for (const [name, script] of Object.entries(declared)) {
74
+ const command = typeof script === 'string' ? script : script?.cmd;
75
+ if (!command)
76
+ continue;
77
+ const currentCommand = projectScripts?.[name];
78
+ if (currentCommand === undefined) {
79
+ actions.push({ name, command, action: 'add', reason: 'New script from capability' });
80
+ continue;
81
+ }
82
+ if (currentCommand === command)
83
+ continue;
84
+ actions.push({
85
+ name,
86
+ command,
87
+ action: 'keep',
88
+ reason: 'Project has its own command for this script',
89
+ currentCommand,
90
+ });
91
+ }
92
+ return actions;
93
+ }
94
+ /**
95
+ * Write the `add` actions into the project's package.json, and return the ones
96
+ * actually written.
97
+ *
98
+ * Re-reads the file rather than trusting the plan: an action is applied only
99
+ * while the name is still free, so a script written between planning and here
100
+ * survives. A missing or unreadable package.json is not an error — scaffold
101
+ * files have already been delivered by the time this runs, and failing the whole
102
+ * update over the script merge would be worse than reporting nothing.
103
+ */
104
+ function applyScriptUpdates(targetDir, actions) {
105
+ const additions = actions.filter(a => a.action === 'add');
106
+ if (additions.length === 0)
107
+ return [];
108
+ const pkgPath = path.join(targetDir, 'package.json');
109
+ if (!fs.existsSync(pkgPath))
110
+ return [];
111
+ let pkg;
112
+ try {
113
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ if (!pkg.scripts)
119
+ pkg.scripts = {};
120
+ const applied = [];
121
+ for (const action of additions) {
122
+ if (action.name in pkg.scripts)
123
+ continue;
124
+ pkg.scripts[action.name] = action.command;
125
+ applied.push(action);
126
+ }
127
+ if (applied.length > 0) {
128
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
129
+ }
130
+ return applied;
131
+ }
132
+ /** The project's package.json scripts, or undefined when there is no readable one. */
133
+ function readProjectScripts(targetDir) {
134
+ const pkgPath = path.join(targetDir, 'package.json');
135
+ if (!fs.existsSync(pkgPath))
136
+ return undefined;
137
+ try {
138
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
139
+ return pkg.scripts;
140
+ }
141
+ catch {
142
+ return undefined;
143
+ }
144
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The mechanical half of splitting a drifted skill: find each skill whose
3
+ * SKILL.md the project edited, recover the text boop installed, list every
4
+ * edit, and write one plan per skill for an agent to decide. Nothing here
5
+ * decides where an edit belongs.
6
+ */
7
+ import { FindOriginalTextOptions } from './original-text';
8
+ import { BoopManifest } from './project';
9
+ /** Where plans are written, relative to the project. Everything in `.boop/` but config.json is gitignored. */
10
+ export declare const SKILL_EDITS_DIR: string;
11
+ /** One continuous change between a base text and the project's copy, as line ranges. */
12
+ export interface Edit {
13
+ /** Lines [baseStart, baseEnd) of the base text that the project replaced */
14
+ baseStart: number;
15
+ baseEnd: number;
16
+ /** Lines [projectStart, projectEnd) of the project's copy that replace them */
17
+ projectStart: number;
18
+ projectEnd: number;
19
+ }
20
+ /** A blank decision, for the agent following the triage skill to fill in. */
21
+ export interface EditDecision {
22
+ /** keep | offer | drop */
23
+ decision: null;
24
+ /** For a kept edit: adds | narrows | overrides */
25
+ kind: null;
26
+ /** For a kept or offered edit: the edit rewritten as a rule that stands alone */
27
+ rule: null;
28
+ /** For a dropped edit: why */
29
+ reason: null;
30
+ }
31
+ export interface SkillEditPlanData {
32
+ skill: string;
33
+ capability: string;
34
+ path: string;
35
+ original: {
36
+ source: 'none';
37
+ } | ({
38
+ source: 'git';
39
+ commit: string;
40
+ }) | ({
41
+ source: 'npm';
42
+ package: string;
43
+ version: string;
44
+ });
45
+ /** True when the original could not be found, so every difference from boop's copy needs a decision */
46
+ comparedWithoutOriginal: boolean;
47
+ /** Whether boop has changed the skill since the project installed it; unknown without the original */
48
+ boopChanged: boolean | null;
49
+ /** As they were when the plan was written, so applying it can refuse a plan made for other text */
50
+ hashes: {
51
+ installed: string;
52
+ project: string;
53
+ boop: string;
54
+ };
55
+ decisions: Record<string, EditDecision>;
56
+ }
57
+ export type SkillEditOutcome = {
58
+ kind: 'written';
59
+ skill: string;
60
+ planPath: string;
61
+ edits: number;
62
+ boopChanged: boolean | null;
63
+ comparedWithoutOriginal: boolean;
64
+ replacedStale: boolean;
65
+ } | {
66
+ kind: 'kept';
67
+ skill: string;
68
+ planPath: string;
69
+ } | {
70
+ kind: 'hand-split';
71
+ skill: string;
72
+ relativePath: string;
73
+ upstreamPath?: string;
74
+ };
75
+ /**
76
+ * Find every skill edited in SKILL.md across the project's capabilities and
77
+ * write a plan for each to `.boop/skill-edits/<skill-name>.md`.
78
+ *
79
+ * A plan already written for the current project copy and boop copy is left
80
+ * alone, because it may hold decisions. One written for other text is backed
81
+ * up and rewritten. A skill boop has no stored hash for gets no plan.
82
+ */
83
+ export declare function writeSkillEditPlans(projectDir: string, manifest: BoopManifest, options?: FindOriginalTextOptions): SkillEditOutcome[];
84
+ /**
85
+ * The edits that turn `base` into `project`: each maximal run of lines that
86
+ * are not part of a longest common subsequence of the two.
87
+ */
88
+ export declare function diffLines(base: string[], project: string[]): Edit[];