@boopkit/cli 0.6.8 → 0.6.10

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.
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+ /**
3
+ * The single answer to "where does a file land when it is installed".
4
+ *
5
+ * `boop add` copies a capability's files into a project and `boop init` copies a
6
+ * profile's reference pages; before this module the mapping from source file to
7
+ * destination path was written out twice — once read-only inside `planAdd`, once
8
+ * while copying inside `copyFromPackage` — so the two could disagree about what a
9
+ * project receives and nothing would notice. Everything here is a pure read: it
10
+ * looks at a package or profile directory and returns `Placement[]`. It never
11
+ * writes, never needs a `targetDir`, and never touches `.boop/`.
12
+ *
13
+ * Three consumers depend on it: `copyFromPackage` (src/lib/scaffold.ts),
14
+ * `planAdd` (src/lib/preflight.ts) and `tools/generate-installed-layout.ts`,
15
+ * which materialises the installed layout inside the boop repository itself.
16
+ * Adding a fourth answer to this question is the defect this module exists to
17
+ * prevent.
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.PlacementCollisionError = void 0;
54
+ exports.describePlacementOwner = describePlacementOwner;
55
+ exports.capabilityPlacementsFromDir = capabilityPlacementsFromDir;
56
+ exports.capabilityPlacements = capabilityPlacements;
57
+ exports.profilePlacements = profilePlacements;
58
+ exports.skillDirectoryPlacements = skillDirectoryPlacements;
59
+ exports.findPlacementCollisions = findPlacementCollisions;
60
+ exports.assertNoPlacementCollisions = assertNoPlacementCollisions;
61
+ const fs = __importStar(require("fs"));
62
+ const path = __importStar(require("path"));
63
+ const config_1 = require("./config");
64
+ const capability_1 = require("./capability");
65
+ class PlacementCollisionError extends Error {
66
+ constructor(collisions) {
67
+ super(`Placement collision:\n${collisions.map(describeCollision).join('\n')}`);
68
+ this.name = 'PlacementCollisionError';
69
+ this.collisions = collisions;
70
+ }
71
+ }
72
+ exports.PlacementCollisionError = PlacementCollisionError;
73
+ /** How a collision names each side: the owner, and for hand-authored files, that nothing ships it. */
74
+ function describePlacementOwner(placement) {
75
+ return placement.ownerKind === 'hand-authored'
76
+ ? `${placement.owner} (hand-authored, no owner)`
77
+ : placement.owner;
78
+ }
79
+ function describeCollision(collision) {
80
+ const owners = collision.placements.map(describePlacementOwner).join(' and ');
81
+ return ` ${collision.dest} — claimed by ${owners}`;
82
+ }
83
+ function toPosix(relPath) {
84
+ return relPath.split(path.sep).join('/');
85
+ }
86
+ /** Every file under `dir`, absolute, depth-first, sorted for a stable placement order. */
87
+ function walkFiles(dir) {
88
+ const found = [];
89
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
90
+ const full = path.join(dir, entry.name);
91
+ if (entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(full).isDirectory())) {
92
+ found.push(...walkFiles(full));
93
+ }
94
+ else {
95
+ found.push(full);
96
+ }
97
+ }
98
+ return found;
99
+ }
100
+ /**
101
+ * `<skillsDir>/<skill-name>/**` → `skills/<skill-name>/**`.
102
+ * Only directories are skills; a loose file beside them ships nothing.
103
+ */
104
+ function skillPlacements(skillsDir, owner, ownerKind) {
105
+ if (!fs.existsSync(skillsDir))
106
+ return [];
107
+ const placements = [];
108
+ for (const entry of fs.readdirSync(skillsDir).sort()) {
109
+ const skillDir = path.join(skillsDir, entry);
110
+ if (!fs.statSync(skillDir).isDirectory())
111
+ continue;
112
+ for (const source of walkFiles(skillDir)) {
113
+ placements.push({
114
+ owner,
115
+ ownerKind,
116
+ source,
117
+ dest: `skills/${entry}/${toPosix(path.relative(skillDir, source))}`,
118
+ kind: 'skill',
119
+ });
120
+ }
121
+ }
122
+ return placements;
123
+ }
124
+ /**
125
+ * `<scaffoldDir>/**` → `<destPrefix>**`, preserving the tree.
126
+ * The scaffold directory mirrors the target project's own layout.
127
+ */
128
+ function scaffoldPlacements(scaffoldDir, owner, ownerKind, destPrefix = '') {
129
+ if (!fs.existsSync(scaffoldDir))
130
+ return [];
131
+ return walkFiles(scaffoldDir).map(source => ({
132
+ owner,
133
+ ownerKind,
134
+ source,
135
+ dest: destPrefix + toPosix(path.relative(scaffoldDir, source)),
136
+ kind: 'scaffold',
137
+ }));
138
+ }
139
+ /**
140
+ * Placements for an already-resolved capability package directory.
141
+ *
142
+ * `capabilityPlacements` delegates here once it has resolved a name to a package;
143
+ * the generator calls it directly because it walks `packages/cap-*` on disk and
144
+ * has no need of npm module resolution (and must not require a build).
145
+ */
146
+ function capabilityPlacementsFromDir(pkgDir) {
147
+ const pkgJsonPath = path.join(pkgDir, 'package.json');
148
+ if (!fs.existsSync(pkgJsonPath))
149
+ return [];
150
+ const boopkit = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).boopkit;
151
+ if (!boopkit)
152
+ return [];
153
+ const owner = path.basename(pkgDir);
154
+ const placements = [];
155
+ if (boopkit.skillsDir) {
156
+ placements.push(...skillPlacements(path.join(pkgDir, boopkit.skillsDir), owner, 'capability'));
157
+ }
158
+ if (boopkit.scaffoldDir) {
159
+ placements.push(...scaffoldPlacements(path.join(pkgDir, boopkit.scaffoldDir), owner, 'capability'));
160
+ }
161
+ return placements;
162
+ }
163
+ function isCapabilityPackage(pkgDir) {
164
+ const pkgJsonPath = path.join(pkgDir, 'package.json');
165
+ if (!fs.existsSync(pkgJsonPath))
166
+ return false;
167
+ return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).boopkit?.capability === true;
168
+ }
169
+ /**
170
+ * Every file a capability ships and where it lands in a project.
171
+ *
172
+ * Resolves the capability the way `boop add` does — the installed package first,
173
+ * then the `capabilities/` source tree that only test fixtures still use.
174
+ */
175
+ function capabilityPlacements(capabilityName, searchPaths) {
176
+ const pkgDir = (0, capability_1.getPackageDir)(capabilityName, searchPaths);
177
+ if (pkgDir && isCapabilityPackage(pkgDir)) {
178
+ return capabilityPlacementsFromDir(pkgDir);
179
+ }
180
+ const capDir = path.join((0, config_1.getBoopRoot)(), 'capabilities', capabilityName);
181
+ if (!fs.existsSync(capDir))
182
+ return [];
183
+ const owner = `cap-${capabilityName}`;
184
+ return [
185
+ ...skillPlacements(path.join(capDir, 'skills'), owner, 'capability'),
186
+ ...scaffoldPlacements(path.join(capDir, 'files'), owner, 'capability'),
187
+ ];
188
+ }
189
+ /**
190
+ * Every file a profile ships and where it lands, mirroring `boop init`
191
+ * (src/commands/init.ts) — today that is the profile's reference pages.
192
+ */
193
+ function profilePlacements(profileName, repoRoot) {
194
+ const profileDir = path.join(repoRoot ?? (0, config_1.getBoopRoot)(), 'profiles', profileName);
195
+ return scaffoldPlacements(path.join(profileDir, 'reference'), profileName, 'profile', 'reference/');
196
+ }
197
+ /**
198
+ * The directory-level view of a skill's placements: one entry per skill folder.
199
+ *
200
+ * The mirror materialises a skill as a single link to its folder rather than a
201
+ * link per file, and this derives that view from the same placement list rather
202
+ * than re-deriving `skills/<name>` from a package layout a second time.
203
+ */
204
+ function skillDirectoryPlacements(placements) {
205
+ // Keyed by folder *and* source, so two capabilities shipping the same skill
206
+ // name stay two placements and the collision check can still see them.
207
+ const folders = new Map();
208
+ for (const placement of placements) {
209
+ if (placement.kind !== 'skill')
210
+ continue;
211
+ const [, skillName] = placement.dest.split('/');
212
+ const dest = `skills/${skillName}`;
213
+ // The file's path within its skill folder is the same suffix in source and dest,
214
+ // so trimming it from the source yields the folder the link must point at.
215
+ const withinSkill = placement.dest.slice(dest.length + 1);
216
+ const source = placement.source.slice(0, placement.source.length - withinSkill.length - 1);
217
+ folders.set(`${dest}\u0000${source}`, { ...placement, source, dest });
218
+ }
219
+ return [...folders.values()];
220
+ }
221
+ /** Every destination claimed by two or more different source files. */
222
+ function findPlacementCollisions(placements) {
223
+ const byDest = new Map();
224
+ for (const placement of placements) {
225
+ const claimants = byDest.get(placement.dest) ?? [];
226
+ claimants.push(placement);
227
+ byDest.set(placement.dest, claimants);
228
+ }
229
+ const collisions = [];
230
+ for (const [dest, claimants] of byDest) {
231
+ const sources = new Set(claimants.map(p => p.source));
232
+ if (sources.size > 1)
233
+ collisions.push({ dest, placements: claimants });
234
+ }
235
+ return collisions.sort((a, b) => a.dest.localeCompare(b.dest));
236
+ }
237
+ /**
238
+ * Throw if any destination is claimed twice.
239
+ *
240
+ * The install path would not throw — `safeCopyDir` applies `onConflict: 'skip'`
241
+ * per file, so a second claimant silently half-merges into the first. That is
242
+ * fine to warn about while installing one capability into a project and fatal
243
+ * when writing a mirror of the whole library, which is why this is a separate
244
+ * assertion rather than something `planAdd` inherits.
245
+ */
246
+ function assertNoPlacementCollisions(placements) {
247
+ const collisions = findPlacementCollisions(placements);
248
+ if (collisions.length > 0)
249
+ throw new PlacementCollisionError(collisions);
250
+ }
@@ -45,6 +45,7 @@ const fs = __importStar(require("fs"));
45
45
  const path = __importStar(require("path"));
46
46
  const config_1 = require("./config");
47
47
  const capability_1 = require("./capability");
48
+ const install_layout_1 = require("./install-layout");
48
49
  const project_1 = require("./project");
49
50
  class PreflightError extends Error {
50
51
  constructor(message, checks) {
@@ -99,42 +100,13 @@ function planAdd(targetDir, capabilityName) {
99
100
  }
100
101
  checks.push({ name: 'requires-satisfied', passed: true });
101
102
  }
102
- // 5. Enumerate files that would be copied — check for conflicts
103
- // Resolve from package (scaffold/ and skills/) or source tree (files/ and skills/)
103
+ // 5. Enumerate files that would be copied — check for conflicts.
104
104
  const pkgDir = (0, capability_1.getPackageDir)(capabilityName, [targetDir]);
105
- const pkgJsonPath = pkgDir ? path.join(pkgDir, 'package.json') : null;
106
- const pkgJson = pkgJsonPath && fs.existsSync(pkgJsonPath)
107
- ? JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'))
108
- : null;
109
- const boopkit = pkgJson?.boopkit;
110
- const capDir = boopkit
111
- ? pkgDir
112
- : path.join(boopRoot, 'capabilities', capabilityName);
113
- const filesToCopy = [];
114
- const fileConflicts = [];
115
- // Skills files
116
- const skillsDir = boopkit?.skillsDir
117
- ? path.join(capDir, boopkit.skillsDir)
118
- : path.join(capDir, 'skills');
119
- if (fs.existsSync(skillsDir)) {
120
- for (const entry of fs.readdirSync(skillsDir)) {
121
- const destPath = path.join(targetDir, 'skills', entry);
122
- const relPath = path.join('skills', entry);
123
- filesToCopy.push(relPath);
124
- if (fs.existsSync(destPath)) {
125
- fileConflicts.push(relPath);
126
- }
127
- }
128
- }
129
- // Capability files from scaffold/ (package) or files/ (source tree)
130
- if (cap.files) {
131
- const filesDir = boopkit?.scaffoldDir
132
- ? path.join(capDir, boopkit.scaffoldDir)
133
- : path.join(capDir, 'files');
134
- if (fs.existsSync(filesDir)) {
135
- enumerateDir(filesDir, targetDir, filesToCopy, fileConflicts);
136
- }
137
- }
105
+ const capDir = pkgDir ?? path.join(boopRoot, 'capabilities', capabilityName);
106
+ // The placement list is the same one `copyCapabilityFiles` copies from, so
107
+ // this cannot disagree with what `boop add` actually writes.
108
+ const filesToCopy = (0, install_layout_1.capabilityPlacements)(capabilityName, [targetDir]).map(p => p.dest);
109
+ const fileConflicts = filesToCopy.filter(dest => fs.existsSync(path.join(targetDir, dest)));
138
110
  // File conflicts are warnings (skip behavior), not failures
139
111
  if (fileConflicts.length > 0) {
140
112
  checks.push({
@@ -370,25 +342,3 @@ function planAdd(targetDir, capabilityName) {
370
342
  };
371
343
  return { manifest, cap, projectManifest, filesToCopy, warnings };
372
344
  }
373
- /**
374
- * Recursively enumerate files in srcDir and check for conflicts in destDir.
375
- * Pure read — does not write.
376
- */
377
- function enumerateDir(srcDir, destBase, filesToCopy, fileConflicts, baseDir) {
378
- const root = baseDir ?? srcDir;
379
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
380
- for (const entry of entries) {
381
- const srcPath = path.join(srcDir, entry.name);
382
- if (entry.isDirectory()) {
383
- enumerateDir(srcPath, destBase, filesToCopy, fileConflicts, root);
384
- }
385
- else {
386
- const rel = path.relative(root, srcPath);
387
- const destPath = path.join(destBase, rel);
388
- filesToCopy.push(rel);
389
- if (fs.existsSync(destPath)) {
390
- fileConflicts.push(rel);
391
- }
392
- }
393
- }
394
- }
@@ -45,9 +45,14 @@ export declare function copyCapabilityFiles(targetDir: string, capabilityName: s
45
45
  * `.claude/skills/<other>` entries (Claude Code plugins, user-authored skills)
46
46
  * are preserved.
47
47
  *
48
+ * Pass `only` to link a subset of what stands in `skills/`. A project's own
49
+ * `skills/` holds exactly what its capabilities installed, so `boop add` and
50
+ * `boop init` link all of it; the boop repository mirrors the whole library
51
+ * there and needs to offer only the part its manifest selects.
52
+ *
48
53
  * Idempotent. Returns the relative paths of symlinks created or already present.
49
54
  */
50
- export declare function linkSkillsToClaude(targetDir: string): string[];
55
+ export declare function linkSkillsToClaude(targetDir: string, only?: Iterable<string>): string[];
51
56
  /**
52
57
  * Merge dependencies into an existing package.json.
53
58
  * Does not overwrite existing dependency versions.
@@ -64,6 +69,12 @@ export declare function linkSkillsToClaude(targetDir: string): string[];
64
69
  * 4. '*' (last resort)
65
70
  */
66
71
  export declare function getBoopkitVersion(packageName: string): string;
72
+ /**
73
+ * The warning shown when a dependency lands in a project's package.json with no
74
+ * version range. Written to be actionable by an agent reading it in a
75
+ * transcript with no other context.
76
+ */
77
+ export declare function formatUnpinnedDependencyWarning(names: string[]): string;
67
78
  export declare function mergePackageJson(targetDir: string, dependencies?: string[], devDependencies?: string[], packages?: Record<string, string>, options?: {
68
79
  skipBackup?: boolean;
69
80
  }): void;
@@ -42,6 +42,7 @@ exports.hashFile = hashFile;
42
42
  exports.copyCapabilityFiles = copyCapabilityFiles;
43
43
  exports.linkSkillsToClaude = linkSkillsToClaude;
44
44
  exports.getBoopkitVersion = getBoopkitVersion;
45
+ exports.formatUnpinnedDependencyWarning = formatUnpinnedDependencyWarning;
45
46
  exports.mergePackageJson = mergePackageJson;
46
47
  exports.injectCssImports = injectCssImports;
47
48
  exports.collectHooks = collectHooks;
@@ -52,6 +53,7 @@ const handlebars_1 = __importDefault(require("handlebars"));
52
53
  const config_1 = require("./config");
53
54
  const capability_1 = require("./capability");
54
55
  const safe_io_1 = require("./safe-io");
56
+ const install_layout_1 = require("./install-layout");
55
57
  /**
56
58
  * Render a single Handlebars template string with variables.
57
59
  *
@@ -133,65 +135,40 @@ function copyCapabilityFiles(targetDir, capabilityName, searchPaths) {
133
135
  const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
134
136
  const boopkit = pkgJson.boopkit;
135
137
  if (boopkit?.capability) {
136
- return copyFromPackage(targetDir, capabilityName, pkgDir, boopkit);
138
+ return copyFromPackage(targetDir, capabilityName, pkgDir);
137
139
  }
138
140
  }
139
141
  // Fallback to source tree
140
142
  return copyFromSourceTree(targetDir, capabilityName);
141
143
  }
142
- function walkDir(dir) {
143
- const results = [];
144
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
145
- const fullPath = path.join(dir, entry.name);
146
- if (entry.isDirectory()) {
147
- results.push(...walkDir(fullPath));
148
- }
149
- else {
150
- results.push(fullPath);
151
- }
152
- }
153
- return results;
154
- }
155
144
  /**
156
- * Copy capability files from a package directory.
145
+ * Copy a capability's placements into the project, skipping anything already there.
146
+ *
147
+ * Returns the relative paths this capability owns in the project, and their
148
+ * hashes. A skill file is reported even when it was skipped — the skill folder
149
+ * is the capability's regardless of who wrote the bytes — while a skipped
150
+ * scaffold file is not, because it belongs to whatever put it there first.
157
151
  */
158
- function copyFromPackage(targetDir, capabilityName, pkgDir, boopkit) {
152
+ function copyPlacements(targetDir, placements) {
159
153
  const copied = [];
160
154
  const fileHashes = {};
161
- // Copy skills from package
162
- if (boopkit.skillsDir) {
163
- const skillsDir = path.join(pkgDir, boopkit.skillsDir);
164
- if (fs.existsSync(skillsDir)) {
165
- const destSkillsDir = path.join(targetDir, 'skills');
166
- (0, safe_io_1.safeCopyDir)(skillsDir, destSkillsDir, { onConflict: 'skip' });
167
- const entries = fs.readdirSync(skillsDir);
168
- for (const entry of entries) {
169
- const srcEntryPath = path.join(skillsDir, entry);
170
- if (fs.statSync(srcEntryPath).isDirectory()) {
171
- const files = walkDir(path.join(destSkillsDir, entry));
172
- for (const filePath of files) {
173
- const rel = path.join('skills', entry, path.relative(path.join(destSkillsDir, entry), filePath));
174
- copied.push(rel);
175
- fileHashes[rel] = hashFile(filePath);
176
- }
177
- }
178
- }
179
- }
180
- }
181
- // Copy scaffold files from package
182
- if (boopkit.scaffoldDir) {
183
- const scaffoldDir = path.join(pkgDir, boopkit.scaffoldDir);
184
- if (fs.existsSync(scaffoldDir)) {
185
- const scaffolded = scaffoldCapabilityFiles(targetDir, scaffoldDir);
186
- copied.push(...scaffolded);
187
- for (const rel of scaffolded) {
188
- const destPath = path.join(targetDir, rel);
189
- if (fs.existsSync(destPath)) {
190
- fileHashes[rel] = hashFile(destPath);
191
- }
192
- }
155
+ for (const placement of placements) {
156
+ const destPath = path.join(targetDir, placement.dest);
157
+ const result = (0, safe_io_1.safeCopyFile)(placement.source, destPath, { onConflict: 'skip' });
158
+ if (!result.copied && placement.kind !== 'skill')
159
+ continue;
160
+ copied.push(placement.dest);
161
+ if (fs.existsSync(destPath)) {
162
+ fileHashes[placement.dest] = hashFile(destPath);
193
163
  }
194
164
  }
165
+ return { copied, fileHashes };
166
+ }
167
+ /**
168
+ * Copy capability files from a package directory.
169
+ */
170
+ function copyFromPackage(targetDir, capabilityName, pkgDir) {
171
+ const { copied, fileHashes } = copyPlacements(targetDir, (0, install_layout_1.capabilityPlacementsFromDir)(pkgDir));
195
172
  // Store file hashes and version in .boop/config.json
196
173
  if (Object.keys(fileHashes).length > 0) {
197
174
  const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
@@ -208,17 +185,27 @@ function copyFromPackage(targetDir, capabilityName, pkgDir, boopkit) {
208
185
  * `.claude/skills/<other>` entries (Claude Code plugins, user-authored skills)
209
186
  * are preserved.
210
187
  *
188
+ * Pass `only` to link a subset of what stands in `skills/`. A project's own
189
+ * `skills/` holds exactly what its capabilities installed, so `boop add` and
190
+ * `boop init` link all of it; the boop repository mirrors the whole library
191
+ * there and needs to offer only the part its manifest selects.
192
+ *
211
193
  * Idempotent. Returns the relative paths of symlinks created or already present.
212
194
  */
213
- function linkSkillsToClaude(targetDir) {
195
+ function linkSkillsToClaude(targetDir, only) {
214
196
  const skillsDir = path.join(targetDir, 'skills');
215
197
  if (!fs.existsSync(skillsDir))
216
198
  return [];
199
+ const selected = only === undefined ? null : new Set(only);
217
200
  const claudeSkillsDir = path.join(targetDir, '.claude', 'skills');
218
201
  fs.mkdirSync(claudeSkillsDir, { recursive: true });
219
202
  const linked = [];
220
203
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
221
- if (!entry.isDirectory())
204
+ // isDirectory() uses lstat semantics, so a symlinked skill folder reads as
205
+ // neither — and the installed-layout mirror makes every mirrored skill one.
206
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
207
+ continue;
208
+ if (selected && !selected.has(entry.name))
222
209
  continue;
223
210
  const linkPath = path.join(claudeSkillsDir, entry.name);
224
211
  const target = path.join('..', '..', 'skills', entry.name);
@@ -239,37 +226,7 @@ function linkSkillsToClaude(targetDir) {
239
226
  * Copy capability files from the capabilities/ source tree (test fixtures only).
240
227
  */
241
228
  function copyFromSourceTree(targetDir, capabilityName) {
242
- const capDir = path.join((0, config_1.getBoopRoot)(), 'capabilities', capabilityName);
243
- if (!fs.existsSync(capDir)) {
244
- return [];
245
- }
246
- const copied = [];
247
- // Copy skills directory if present
248
- const skillsDir = path.join(capDir, 'skills');
249
- if (fs.existsSync(skillsDir)) {
250
- const destSkillsDir = path.join(targetDir, 'skills');
251
- (0, safe_io_1.safeCopyDir)(skillsDir, destSkillsDir, { onConflict: 'skip' });
252
- const entries = fs.readdirSync(skillsDir);
253
- for (const entry of entries) {
254
- const srcEntryPath = path.join(skillsDir, entry);
255
- if (fs.statSync(srcEntryPath).isDirectory()) {
256
- const files = walkDir(path.join(destSkillsDir, entry));
257
- for (const filePath of files) {
258
- copied.push(path.join('skills', entry, path.relative(path.join(destSkillsDir, entry), filePath)));
259
- }
260
- }
261
- }
262
- }
263
- // Scaffold files declared in the capability manifest
264
- const cap = (0, capability_1.getCapability)(capabilityName);
265
- if (cap?.files) {
266
- const filesDir = path.join(capDir, 'files');
267
- if (fs.existsSync(filesDir)) {
268
- const scaffolded = scaffoldCapabilityFiles(targetDir, filesDir);
269
- copied.push(...scaffolded);
270
- }
271
- }
272
- return copied;
229
+ return copyPlacements(targetDir, (0, install_layout_1.capabilityPlacements)(capabilityName)).copied;
273
230
  }
274
231
  /**
275
232
  * Update .boop/config.json with file hashes for a capability.
@@ -298,34 +255,6 @@ function updateCapabilityHashes(targetDir, capabilityName, fileHashes, version)
298
255
  config.capabilityMeta[capabilityName] = meta;
299
256
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
300
257
  }
301
- /**
302
- * Copy all files from a capability's files/ directory into the target project.
303
- * Preserves directory structure. Does not overwrite existing files.
304
- */
305
- function scaffoldCapabilityFiles(targetDir, filesDir) {
306
- const copied = [];
307
- function processDir(srcDir, destDir) {
308
- if (!fs.existsSync(destDir)) {
309
- fs.mkdirSync(destDir, { recursive: true });
310
- }
311
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
312
- for (const entry of entries) {
313
- const srcPath = path.join(srcDir, entry.name);
314
- const destPath = path.join(destDir, entry.name);
315
- if (entry.isDirectory()) {
316
- processDir(srcPath, destPath);
317
- }
318
- else {
319
- if (!fs.existsSync(destPath)) {
320
- fs.copyFileSync(srcPath, destPath);
321
- copied.push(path.relative(targetDir, destPath));
322
- }
323
- }
324
- }
325
- }
326
- processDir(filesDir, targetDir);
327
- return copied;
328
- }
329
258
  /**
330
259
  * Merge dependencies into an existing package.json.
331
260
  * Does not overwrite existing dependency versions.
@@ -377,6 +306,23 @@ function getBoopkitVersion(packageName) {
377
306
  }
378
307
  return '*';
379
308
  }
309
+ /**
310
+ * Parse a dependency string like "express@^5" or "@prisma/client@^6" into
311
+ * name + version.
312
+ *
313
+ * A bare name still yields `'*'` rather than throwing. That fallback is for
314
+ * capabilities boop did not write — a third-party or locally authored manifest
315
+ * should scaffold rather than abort an install halfway through — but for a
316
+ * capability that ships with boop a bare name is a defect, not a shorthand.
317
+ * `'*'` hands a new project whatever major shipped most recently, which is how
318
+ * a scaffold ended up on TypeScript 6 while boop ran its own suite on 5.9.3.
319
+ *
320
+ * So the fallback stays and gets loud instead: `mergePackageJson` warns about
321
+ * every unpinned range it writes, and
322
+ * `tests/lib/capability-contracts.test.ts` fails when one of boop's own
323
+ * manifests or profiles declares a bare name, which is where the regression
324
+ * actually gets caught.
325
+ */
380
326
  function parseDep(dep) {
381
327
  // Handle scoped packages: @scope/name@version
382
328
  const match = dep.match(/^(@[^@/]+\/[^@]+)@(.+)$/) || dep.match(/^([^@]+)@(.+)$/);
@@ -385,12 +331,42 @@ function parseDep(dep) {
385
331
  }
386
332
  return { name: dep, version: '*' };
387
333
  }
334
+ /**
335
+ * The warning shown when a dependency lands in a project's package.json with no
336
+ * version range. Written to be actionable by an agent reading it in a
337
+ * transcript with no other context.
338
+ */
339
+ function formatUnpinnedDependencyWarning(names) {
340
+ if (names.length === 0)
341
+ return '';
342
+ const lines = [];
343
+ lines.push(`\x1b[33mWARN\x1b[0m ${names.length === 1 ? 'A dependency was' : `${names.length} dependencies were`} added with no version range:`);
344
+ for (const name of names) {
345
+ lines.push(` ${name}: "*"`);
346
+ }
347
+ lines.push('');
348
+ lines.push(' "*" resolves to whatever major shipped most recently, so two projects');
349
+ lines.push(' scaffolded a month apart get different toolchains and neither matches the');
350
+ lines.push(' one boop tests against. Pin the declaration to a caret major — "express@^5"');
351
+ lines.push(' rather than "express" — in the capability.json or profile that declares it,');
352
+ lines.push(' then set the range by hand in this project\'s package.json.');
353
+ return lines.join('\n');
354
+ }
388
355
  function mergePackageJson(targetDir, dependencies, devDependencies, packages, options) {
389
356
  const pkgPath = path.join(targetDir, 'package.json');
390
357
  if (!fs.existsSync(pkgPath)) {
391
358
  return;
392
359
  }
393
360
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
361
+ // Names written with no range. Collected on the way through rather than
362
+ // derived from the declarations, so it also catches getBoopkitVersion falling
363
+ // all the way through to its own '*'.
364
+ const unpinned = [];
365
+ const record = (name, version) => {
366
+ if (version === '*')
367
+ unpinned.push(name);
368
+ return version;
369
+ };
394
370
  if (dependencies) {
395
371
  if (!pkg.dependencies)
396
372
  pkg.dependencies = {};
@@ -398,10 +374,10 @@ function mergePackageJson(targetDir, dependencies, devDependencies, packages, op
398
374
  const { name, version } = parseDep(dep);
399
375
  if (!(name in pkg.dependencies)) {
400
376
  if (name.startsWith('@boopkit/')) {
401
- pkg.dependencies[name] = getBoopkitVersion(name);
377
+ pkg.dependencies[name] = record(name, getBoopkitVersion(name));
402
378
  }
403
379
  else {
404
- pkg.dependencies[name] = version;
380
+ pkg.dependencies[name] = record(name, version);
405
381
  }
406
382
  }
407
383
  }
@@ -412,7 +388,7 @@ function mergePackageJson(targetDir, dependencies, devDependencies, packages, op
412
388
  for (const dep of devDependencies) {
413
389
  const { name, version } = parseDep(dep);
414
390
  if (!(name in pkg.devDependencies)) {
415
- pkg.devDependencies[name] = version;
391
+ pkg.devDependencies[name] = record(name, version);
416
392
  }
417
393
  }
418
394
  }
@@ -421,13 +397,16 @@ function mergePackageJson(targetDir, dependencies, devDependencies, packages, op
421
397
  pkg.dependencies = {};
422
398
  for (const [name, version] of Object.entries(packages)) {
423
399
  if (!(name in pkg.dependencies)) {
424
- pkg.dependencies[name] = version;
400
+ pkg.dependencies[name] = record(name, version);
425
401
  }
426
402
  }
427
403
  }
428
404
  if (!options?.skipBackup)
429
405
  (0, safe_io_1.createBackup)(pkgPath);
430
406
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
407
+ if (unpinned.length > 0) {
408
+ console.warn(formatUnpinnedDependencyWarning(unpinned));
409
+ }
431
410
  }
432
411
  /**
433
412
  * Scan the styles/ directory for CSS partials and inject @import lines