@boopkit/cli 0.8.0 → 0.8.2

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 +8 -6
  2. package/dist/capability-catalog.json +10 -3
  3. package/dist/capability-versions.json +13 -12
  4. package/dist/cli.js +0 -0
  5. package/dist/commands/add.js +35 -7
  6. package/dist/commands/adopt.js +1 -1
  7. package/dist/commands/init.d.ts +5 -0
  8. package/dist/commands/init.js +12 -5
  9. package/dist/commands/loop-merge.d.ts +11 -0
  10. package/dist/commands/loop-merge.js +135 -14
  11. package/dist/commands/loop.d.ts +5 -1
  12. package/dist/commands/loop.js +38 -19
  13. package/dist/commands/migrate-skill-edits.js +2 -2
  14. package/dist/commands/status.d.ts +10 -0
  15. package/dist/commands/status.js +27 -2
  16. package/dist/commands/update.js +157 -21
  17. package/dist/lib/capability.js +4 -1
  18. package/dist/lib/execute.js +30 -3
  19. package/dist/lib/install-layout.d.ts +5 -0
  20. package/dist/lib/install-layout.js +8 -0
  21. package/dist/lib/manifest.d.ts +1 -1
  22. package/dist/lib/moves.d.ts +88 -0
  23. package/dist/lib/moves.js +381 -0
  24. package/dist/lib/original-text.d.ts +2 -0
  25. package/dist/lib/original-text.js +15 -7
  26. package/dist/lib/preflight.d.ts +14 -1
  27. package/dist/lib/preflight.js +53 -17
  28. package/dist/lib/project.d.ts +17 -0
  29. package/dist/lib/project.js +26 -0
  30. package/dist/lib/scaffold.d.ts +12 -1
  31. package/dist/lib/scaffold.js +43 -4
  32. package/dist/lib/stale-scripts.js +54 -14
  33. package/dist/lib/update.d.ts +36 -2
  34. package/dist/lib/update.js +82 -4
  35. package/docs/init.md +22 -0
  36. package/docs/loop.md +13 -3
  37. package/docs/skills.md +23 -15
  38. package/package.json +1 -1
  39. package/profiles/base/profile.md +13 -10
  40. package/profiles/development/profile.md +10 -8
  41. package/profiles/web-app/profile.md +6 -4
  42. package/profiles/web-app/reference/message-queue-patterns.ref.md +8 -13
  43. package/src/templates/kernel/tests/app.test.ts.hbs +17 -0
@@ -61,21 +61,46 @@ exports.PreflightError = PreflightError;
61
61
  *
62
62
  * Throws PreflightError if any check fails.
63
63
  */
64
- function planAdd(targetDir, capabilityName) {
64
+ function planAdd(targetDir, capabilityName, options = {}) {
65
65
  const checks = [];
66
66
  const tasks = [];
67
67
  const warnings = [];
68
+ const creates = [];
68
69
  const boopRoot = (0, config_1.getBoopRoot)();
70
+ const searchPaths = options.searchPaths ?? [targetDir];
71
+ const pkgJsonPath = path.join(targetDir, 'package.json');
72
+ const hasPackageJson = fs.existsSync(pkgJsonPath);
69
73
  // --- Pre-flight checks ---
70
- // 1. Validate project
71
- const projectManifest = (0, project_1.readProjectManifest)(targetDir);
72
- if (!projectManifest) {
73
- checks.push({ name: 'project-exists', passed: false, message: 'Not a boop project (no .boop/config.json)' });
74
- throw new PreflightError('Pre-flight failed', checks);
74
+ // 1. Validate project. A project boop did not scaffold has no config yet;
75
+ // the add creates one, so this is the first capability it records.
76
+ const existingManifest = (0, project_1.readProjectManifest)(targetDir);
77
+ const projectManifest = existingManifest ?? (0, project_1.manifestForUnscaffoldedProject)(targetDir);
78
+ if (existingManifest) {
79
+ checks.push({ name: 'project-exists', passed: true });
80
+ }
81
+ else {
82
+ checks.push({ name: 'project-exists', passed: true, message: 'No .boop/config.json — it will be created' });
83
+ if (!hasPackageJson) {
84
+ creates.push('package.json');
85
+ tasks.push({
86
+ op: 'create-package-json',
87
+ target: pkgJsonPath,
88
+ detail: 'Create a minimal package.json, which records capability packages',
89
+ status: 'pending',
90
+ data: { projectName: projectManifest.name },
91
+ });
92
+ }
93
+ creates.push('.boop/config.json');
94
+ tasks.push({
95
+ op: 'create-manifest',
96
+ target: path.join(targetDir, '.boop', 'config.json'),
97
+ detail: `Create .boop/config.json for ${projectManifest.name} (profile: ${projectManifest.profile})`,
98
+ status: 'pending',
99
+ data: { manifest: projectManifest },
100
+ });
75
101
  }
76
- checks.push({ name: 'project-exists', passed: true });
77
102
  // 2. Resolve capability (search project's node_modules first, then CLI root)
78
- const cap = (0, capability_1.getCapability)(capabilityName, [targetDir]);
103
+ const cap = (0, capability_1.getCapability)(capabilityName, searchPaths);
79
104
  if (!cap) {
80
105
  checks.push({ name: 'capability-exists', passed: false, message: `Capability "${capabilityName}" not found` });
81
106
  throw new PreflightError('Pre-flight failed', checks);
@@ -101,11 +126,13 @@ function planAdd(targetDir, capabilityName) {
101
126
  checks.push({ name: 'requires-satisfied', passed: true });
102
127
  }
103
128
  // 5. Enumerate files that would be copied — check for conflicts.
104
- const pkgDir = (0, capability_1.getPackageDir)(capabilityName, [targetDir]);
129
+ const pkgDir = (0, capability_1.getPackageDir)(capabilityName, searchPaths);
105
130
  const capDir = pkgDir ?? path.join(boopRoot, 'capabilities', capabilityName);
106
131
  // The placement list is the same one `copyCapabilityFiles` copies from, so
107
132
  // this cannot disagree with what `boop add` actually writes.
108
- const filesToCopy = (0, install_layout_1.capabilityPlacements)(capabilityName, [targetDir]).map(p => p.dest);
133
+ const excludePaths = options.excludePaths ?? [];
134
+ const filesToCopy = (0, install_layout_1.capabilityPlacements)(capabilityName, searchPaths).map(p => p.dest)
135
+ .filter(dest => !(0, install_layout_1.isUnderAny)(dest, excludePaths));
109
136
  const fileConflicts = filesToCopy.filter(dest => fs.existsSync(path.join(targetDir, dest)));
110
137
  // File conflicts are warnings (skip behavior), not failures
111
138
  if (fileConflicts.length > 0) {
@@ -165,9 +192,8 @@ function planAdd(targetDir, capabilityName) {
165
192
  }
166
193
  // 6. Check for script conflicts
167
194
  if (cap.scripts) {
168
- const pkgPath = path.join(targetDir, 'package.json');
169
- if (fs.existsSync(pkgPath)) {
170
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
195
+ if (hasPackageJson) {
196
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
171
197
  const existingScripts = pkg.scripts || {};
172
198
  const conflicts = [];
173
199
  for (const scriptName of Object.keys(cap.scripts)) {
@@ -193,6 +219,17 @@ function planAdd(targetDir, capabilityName) {
193
219
  throw new PreflightError(`Pre-flight failed: ${failedChecks.map(c => c.message).join('; ')}`, checks);
194
220
  }
195
221
  // --- Build task manifest ---
222
+ // Task: install the capability package. It follows the files a bare project
223
+ // lacks, so npm records it in boop's package.json rather than making its own.
224
+ if (options.installCommand) {
225
+ tasks.push({
226
+ op: 'install-package',
227
+ target: targetDir,
228
+ detail: options.installCommand,
229
+ status: 'pending',
230
+ data: { command: options.installCommand },
231
+ });
232
+ }
196
233
  // Task: copy files
197
234
  if (filesToCopy.length > 0) {
198
235
  tasks.push({
@@ -201,7 +238,7 @@ function planAdd(targetDir, capabilityName) {
201
238
  target: targetDir,
202
239
  detail: `Copy ${filesToCopy.length} file(s) from ${capabilityName}`,
203
240
  status: 'pending',
204
- data: { files: filesToCopy, conflicts: fileConflicts },
241
+ data: { files: filesToCopy, conflicts: fileConflicts, exclude: excludePaths },
205
242
  });
206
243
  }
207
244
  // Task: merge dependencies
@@ -320,8 +357,7 @@ function planAdd(targetDir, capabilityName) {
320
357
  });
321
358
  // Task: rebuild CSS if we injected styles and the project has tailwind:build
322
359
  const hasInjectCss = tasks.some(t => t.op === 'inject-css');
323
- if (hasInjectCss) {
324
- const pkgJsonPath = path.join(targetDir, 'package.json');
360
+ if (hasInjectCss && hasPackageJson) {
325
361
  const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
326
362
  if (pkgJson.scripts?.['tailwind:build']) {
327
363
  tasks.push({
@@ -340,5 +376,5 @@ function planAdd(targetDir, capabilityName) {
340
376
  preflight: { passed: true, checks },
341
377
  tasks,
342
378
  };
343
- return { manifest, cap, projectManifest, filesToCopy, warnings };
379
+ return { manifest, cap, projectManifest, filesToCopy, warnings, creates };
344
380
  }
@@ -8,6 +8,15 @@ export interface CapabilityMeta {
8
8
  installedAt: string;
9
9
  version?: string;
10
10
  fileHashes: Record<string, string>;
11
+ /**
12
+ * For a file that arrived by a move from another capability: where boop
13
+ * installed it before, so the original text can still be found. Dropped once
14
+ * this capability writes the file's hash itself.
15
+ */
16
+ movedFrom?: Record<string, {
17
+ capability: string;
18
+ path: string;
19
+ }>;
11
20
  }
12
21
  export interface BoopManifest {
13
22
  name: string;
@@ -30,6 +39,14 @@ export declare function readProjectManifest(dir: string): BoopManifest | null;
30
39
  * Creates the .boop/ directory if it does not exist.
31
40
  */
32
41
  export declare function writeProjectManifest(dir: string, manifest: BoopManifest): void;
42
+ /**
43
+ * The manifest `boop add` creates for a project boop did not scaffold, such as
44
+ * an app made by another tool. It is named like `boop init` names a project,
45
+ * unless the project's own package.json already gives it a name, and records
46
+ * the `development` profile, which is what a repository that brings in boop's
47
+ * development capabilities without its scaffold uses.
48
+ */
49
+ export declare function manifestForUnscaffoldedProject(dir: string): BoopManifest;
33
50
  /**
34
51
  * Check if a directory contains a boop project (.boop/config.json exists).
35
52
  */
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readProjectManifest = readProjectManifest;
37
37
  exports.writeProjectManifest = writeProjectManifest;
38
+ exports.manifestForUnscaffoldedProject = manifestForUnscaffoldedProject;
38
39
  exports.isBoopProject = isBoopProject;
39
40
  const fs = __importStar(require("fs"));
40
41
  const path = __importStar(require("path"));
@@ -63,6 +64,31 @@ function writeProjectManifest(dir, manifest) {
63
64
  const manifestPath = path.join(boopDir, 'config.json');
64
65
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
65
66
  }
67
+ /**
68
+ * The manifest `boop add` creates for a project boop did not scaffold, such as
69
+ * an app made by another tool. It is named like `boop init` names a project,
70
+ * unless the project's own package.json already gives it a name, and records
71
+ * the `development` profile, which is what a repository that brings in boop's
72
+ * development capabilities without its scaffold uses.
73
+ */
74
+ function manifestForUnscaffoldedProject(dir) {
75
+ return {
76
+ name: packageJsonName(dir) ?? path.basename(dir),
77
+ profile: 'development',
78
+ boopVersion: '0.1.0',
79
+ capabilities: [],
80
+ createdAt: new Date().toISOString(),
81
+ };
82
+ }
83
+ function packageJsonName(dir) {
84
+ try {
85
+ const { name } = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
86
+ return typeof name === 'string' && name.length > 0 ? name : null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
66
92
  /**
67
93
  * Check if a directory contains a boop project (.boop/config.json exists).
68
94
  */
@@ -31,6 +31,12 @@ export declare function renderTemplate(templateContent: string, variables: Templ
31
31
  */
32
32
  export declare function getTemplatesDir(profileName: string): string;
33
33
  export declare function profileHasTemplates(profileName: string): boolean;
34
+ /**
35
+ * The minimal package.json the `base` profile ships, rendered for a project.
36
+ * boop records every capability as an npm package there, so a project without
37
+ * one (a native iOS app, say) gets this one before its first capability.
38
+ */
39
+ export declare function renderBasePackageJson(projectName: string): string;
34
40
  /**
35
41
  * Copy and render every file in a templates directory (the kernel templates
36
42
  * by default) into a target directory. Creates directories as needed. Does
@@ -51,7 +57,7 @@ export declare function hashContent(content: Buffer): string;
51
57
  * Tries package resolution first (scaffold/ and skills/ dirs), falls back to source tree.
52
58
  * Returns list of copied file paths relative to targetDir, and file hashes.
53
59
  */
54
- export declare function copyCapabilityFiles(targetDir: string, capabilityName: string, searchPaths?: string[]): string[];
60
+ export declare function copyCapabilityFiles(targetDir: string, capabilityName: string, searchPaths?: string[], excludePaths?: string[]): string[];
55
61
  /**
56
62
  * Symlink each skill folder under `<targetDir>/skills/` into
57
63
  * `<targetDir>/.claude/skills/` so Claude Code's built-in Skill tool
@@ -66,6 +72,11 @@ export declare function copyCapabilityFiles(targetDir: string, capabilityName: s
66
72
  * `boop init` link all of it; the boop repository mirrors the whole library
67
73
  * there and needs to offer only the part its manifest selects.
68
74
  *
75
+ * A link left pointing into `skills/` at a folder that no longer stands there
76
+ * (the project deleted the skill, or a capability stopped shipping it) is
77
+ * removed, so the links follow the folders. Only symlinks into `skills/` are
78
+ * pruned; a link the project made to anywhere else is never touched.
79
+ *
69
80
  * Idempotent. Returns the relative paths of symlinks created or already present.
70
81
  */
71
82
  export declare function linkSkillsToClaude(targetDir: string, only?: Iterable<string>): string[];
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.renderTemplate = renderTemplate;
40
40
  exports.getTemplatesDir = getTemplatesDir;
41
41
  exports.profileHasTemplates = profileHasTemplates;
42
+ exports.renderBasePackageJson = renderBasePackageJson;
42
43
  exports.scaffoldKernel = scaffoldKernel;
43
44
  exports.hashFile = hashFile;
44
45
  exports.hashContent = hashContent;
@@ -97,6 +98,15 @@ function profileHasTemplates(profileName) {
97
98
  function getProfileTemplatesDir(profileName) {
98
99
  return path.join((0, config_1.getBoopRoot)(), 'profiles', profileName, 'templates');
99
100
  }
101
+ /**
102
+ * The minimal package.json the `base` profile ships, rendered for a project.
103
+ * boop records every capability as an npm package there, so a project without
104
+ * one (a native iOS app, say) gets this one before its first capability.
105
+ */
106
+ function renderBasePackageJson(projectName) {
107
+ const template = fs.readFileSync(path.join(getProfileTemplatesDir('base'), 'package.json.hbs'), 'utf-8');
108
+ return renderTemplate(template, { projectName });
109
+ }
100
110
  /**
101
111
  * Copy and render every file in a templates directory (the kernel templates
102
112
  * by default) into a target directory. Creates directories as needed. Does
@@ -156,7 +166,7 @@ function hashContent(content) {
156
166
  * Tries package resolution first (scaffold/ and skills/ dirs), falls back to source tree.
157
167
  * Returns list of copied file paths relative to targetDir, and file hashes.
158
168
  */
159
- function copyCapabilityFiles(targetDir, capabilityName, searchPaths) {
169
+ function copyCapabilityFiles(targetDir, capabilityName, searchPaths, excludePaths = []) {
160
170
  // Default to the project itself, so this resolves the capability to the same
161
171
  // package `planAdd` did. Without it a capability that lives only under the
162
172
  // project resolved here to nothing, and `boop add` reported every file copied
@@ -168,7 +178,7 @@ function copyCapabilityFiles(targetDir, capabilityName, searchPaths) {
168
178
  const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
169
179
  const boopkit = pkgJson.boopkit;
170
180
  if (boopkit?.capability) {
171
- return copyFromPackage(targetDir, capabilityName, pkgDir);
181
+ return copyFromPackage(targetDir, capabilityName, pkgDir, excludePaths);
172
182
  }
173
183
  }
174
184
  // Fallback to source tree
@@ -200,8 +210,9 @@ function copyPlacements(targetDir, placements) {
200
210
  /**
201
211
  * Copy capability files from a package directory.
202
212
  */
203
- function copyFromPackage(targetDir, capabilityName, pkgDir) {
204
- const { copied, fileHashes } = copyPlacements(targetDir, (0, install_layout_1.capabilityPlacementsFromDir)(pkgDir));
213
+ function copyFromPackage(targetDir, capabilityName, pkgDir, excludePaths) {
214
+ const placements = (0, install_layout_1.capabilityPlacementsFromDir)(pkgDir).filter(p => !(0, install_layout_1.isUnderAny)(p.dest, excludePaths));
215
+ const { copied, fileHashes } = copyPlacements(targetDir, placements);
205
216
  // Store file hashes and version in .boop/config.json
206
217
  if (Object.keys(fileHashes).length > 0) {
207
218
  const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
@@ -223,10 +234,16 @@ function copyFromPackage(targetDir, capabilityName, pkgDir) {
223
234
  * `boop init` link all of it; the boop repository mirrors the whole library
224
235
  * there and needs to offer only the part its manifest selects.
225
236
  *
237
+ * A link left pointing into `skills/` at a folder that no longer stands there
238
+ * (the project deleted the skill, or a capability stopped shipping it) is
239
+ * removed, so the links follow the folders. Only symlinks into `skills/` are
240
+ * pruned; a link the project made to anywhere else is never touched.
241
+ *
226
242
  * Idempotent. Returns the relative paths of symlinks created or already present.
227
243
  */
228
244
  function linkSkillsToClaude(targetDir, only) {
229
245
  const skillsDir = path.join(targetDir, 'skills');
246
+ pruneDanglingSkillLinks(targetDir);
230
247
  if (!fs.existsSync(skillsDir))
231
248
  return [];
232
249
  const selected = only === undefined ? null : new Set(only);
@@ -255,6 +272,28 @@ function linkSkillsToClaude(targetDir, only) {
255
272
  }
256
273
  return linked;
257
274
  }
275
+ /**
276
+ * Remove each symlink in `<targetDir>/.claude/skills/` that points into
277
+ * `<targetDir>/skills/` at an entry that no longer exists.
278
+ */
279
+ function pruneDanglingSkillLinks(targetDir) {
280
+ const skillsDir = path.join(targetDir, 'skills');
281
+ const claudeSkillsDir = path.join(targetDir, '.claude', 'skills');
282
+ if (!fs.existsSync(claudeSkillsDir))
283
+ return;
284
+ for (const entry of fs.readdirSync(claudeSkillsDir, { withFileTypes: true })) {
285
+ if (!entry.isSymbolicLink())
286
+ continue;
287
+ const linkPath = path.join(claudeSkillsDir, entry.name);
288
+ const resolved = path.resolve(claudeSkillsDir, fs.readlinkSync(linkPath));
289
+ if (!resolved.startsWith(skillsDir + path.sep))
290
+ continue;
291
+ // lstat, so a skills/ entry that is itself a symlink counts as present.
292
+ if (fs.lstatSync(resolved, { throwIfNoEntry: false }))
293
+ continue;
294
+ fs.unlinkSync(linkPath);
295
+ }
296
+ }
258
297
  /**
259
298
  * The names of the skill folders under `<targetDir>/skills/` that boop installed.
260
299
  *
@@ -65,19 +65,56 @@ const fs = __importStar(require("fs"));
65
65
  const path = __importStar(require("path"));
66
66
  /** Jest's in-band flag and its single-letter alias. */
67
67
  const IN_BAND_FLAGS = new Set(['--runInBand', '--runInBand=true', '-i']);
68
+ /** Shell operators that end one command and start the next. */
69
+ const COMMAND_SEPARATORS = new Set(['&&', '||', ';', '|']);
68
70
  /**
69
- * Does this command invoke Jest? Matched on the token's basename so that `jest`,
70
- * `npx jest` and `node_modules/.bin/jest` all count, while a command that merely
71
- * contains the same characters somewhere does not.
71
+ * Words that run the command after them, so the word that follows (once their
72
+ * own flags are skipped) is still in command position: `npx jest`, `exec jest`,
73
+ * `node --expose-gc node_modules/.bin/jest`.
72
74
  */
73
- function invokesJest(tokens) {
74
- return tokens.some((token) => {
75
- if (token.startsWith('-'))
76
- return false;
77
- const base = path.basename(token);
78
- return base === 'jest' || base === 'jest.js';
75
+ const LAUNCHERS = new Set(['npx', 'exec', 'node', 'cross-env']);
76
+ /**
77
+ * Keys that cannot be run as scripts by convention. Projects keep explanatory
78
+ * prose there, and prose about Jest naturally mentions `jest` and `--runInBand`.
79
+ */
80
+ function isCommentKey(name) {
81
+ return name.startsWith('_comment') || name.startsWith('//');
82
+ }
83
+ function isJest(token) {
84
+ const base = path.basename(token);
85
+ return base === 'jest' || base === 'jest.js';
86
+ }
87
+ /**
88
+ * Group a command's words by the chained command each belongs to. A separator
89
+ * glued to the end of a word (`tsc;`) still ends that command.
90
+ */
91
+ function groupCommands(tokens) {
92
+ let current = 0;
93
+ return tokens.map((token) => {
94
+ if (COMMAND_SEPARATORS.has(token))
95
+ return current++;
96
+ const index = current;
97
+ if (token.endsWith(';'))
98
+ current++;
99
+ return index;
79
100
  });
80
101
  }
102
+ /**
103
+ * Does this one command run Jest? Only the word in command position counts —
104
+ * after any `KEY=value` assignments, launchers and the launchers' own flags —
105
+ * so `jest`, `npx jest` and `node_modules/.bin/jest` match, while `echo jest`
106
+ * and a command that merely contains the same characters do not.
107
+ */
108
+ function runsJest(tokens) {
109
+ for (const token of tokens) {
110
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
111
+ continue;
112
+ if (LAUNCHERS.has(token) || token.startsWith('-'))
113
+ continue;
114
+ return isJest(token);
115
+ }
116
+ return false;
117
+ }
81
118
  /**
82
119
  * Pure: given a package.json `scripts` object, return the scripts whose command
83
120
  * forces in-band Jest execution and therefore defeats the shipped config.
@@ -87,18 +124,21 @@ function findStaleRunnerScripts(scripts) {
87
124
  return [];
88
125
  const findings = [];
89
126
  for (const [name, command] of Object.entries(scripts)) {
90
- if (typeof command !== 'string')
127
+ if (typeof command !== 'string' || isCommentKey(name))
91
128
  continue;
92
129
  const tokens = command.split(/\s+/).filter(Boolean);
93
- if (!invokesJest(tokens))
94
- continue;
95
- const flags = tokens.filter((t) => IN_BAND_FLAGS.has(t));
130
+ const group = groupCommands(tokens);
131
+ const jestGroups = new Set([...new Set(group)].filter((g) => runsJest(tokens
132
+ .filter((t, i) => group[i] === g && !COMMAND_SEPARATORS.has(t))
133
+ .map((t) => t.replace(/;$/, '')))));
134
+ const offending = (t, i) => jestGroups.has(group[i]) && IN_BAND_FLAGS.has(t);
135
+ const flags = tokens.filter(offending);
96
136
  if (flags.length === 0)
97
137
  continue;
98
138
  findings.push({
99
139
  name,
100
140
  command,
101
- rewritten: tokens.filter((t) => !IN_BAND_FLAGS.has(t)).join(' '),
141
+ rewritten: tokens.filter((t, i) => !offending(t, i)).join(' '),
102
142
  flags,
103
143
  });
104
144
  }
@@ -17,7 +17,7 @@ export interface FileUpdateAction {
17
17
  /** Relative path from project root */
18
18
  relativePath: string;
19
19
  /** What will happen to this file */
20
- action: 'overwrite' | 'skip' | 'add' | 'deprecate' | 'migrate' | 'record-hash';
20
+ action: 'overwrite' | 'skip' | 'add' | 'deprecate' | 'migrate' | 'record-hash' | 'move' | 'remove';
21
21
  /** Why this action was chosen */
22
22
  reason: string;
23
23
  /** Stored hash from config */
@@ -38,6 +38,12 @@ export interface FileUpdateAction {
38
38
  instruction?: string;
39
39
  /** boop's current copy of the file, when the instruction points at it */
40
40
  upstreamPath?: string;
41
+ /** For a path another capability ships now: where it ships */
42
+ movedTo?: string;
43
+ /** For a path another capability ships now: that capability */
44
+ toCapability?: string;
45
+ /** For the move of a skill folder: whether its PROJECT.md held more than the template */
46
+ projectFileCarried?: boolean;
41
47
  }
42
48
  export interface FileInstruction {
43
49
  relativePath: string;
@@ -82,6 +88,16 @@ export interface UpdateResult {
82
88
  unchangedCount: number;
83
89
  /** What to do about the files this update skipped or replaced, where the report says anything */
84
90
  instructions: FileInstruction[];
91
+ /** Paths moved to another capability, by the move pass */
92
+ moved: {
93
+ from: string;
94
+ to: string;
95
+ toCapability: string;
96
+ }[];
97
+ /** Skill folders deleted because another capability ships them now and this project does not install it */
98
+ removed: string[];
99
+ /** Moved paths left where they are until the instruction for them is followed */
100
+ movesWaiting: string[];
85
101
  }
86
102
  /** The skill that splits a drifted SKILL.md — keep in step with the folder name boop-rm2l.8 gives it */
87
103
  export declare const TRIAGE_SKILL = "boop-skills-edit-triage";
@@ -91,11 +107,21 @@ export declare const SKILL_EDITS_INSTRUCTION = "Move each skill's edits into the
91
107
  * Compare a file's current hash against a stored hash.
92
108
  */
93
109
  export declare function compareFileHash(filePath: string, storedHash: string): FileState;
110
+ export interface PlanOptions {
111
+ /**
112
+ * The move pass's actions for paths leaving this capability. Each replaces
113
+ * whatever this planner would plan for the paths it covers; a skip whose
114
+ * `relativePath` is a folder covers every stored path inside it.
115
+ */
116
+ moves?: FileUpdateAction[];
117
+ /** Paths arriving in this capability by a move not yet carried out, which the move lists instead */
118
+ arriving?: string[];
119
+ }
94
120
  /**
95
121
  * Plan an update for a single capability. Returns null if the capability
96
122
  * is not installed or has no package.
97
123
  */
98
- export declare function planCapabilityUpdate(targetDir: string, capabilityName: string, projectManifest: BoopManifest): UpdatePlan | null;
124
+ export declare function planCapabilityUpdate(targetDir: string, capabilityName: string, projectManifest: BoopManifest, options?: PlanOptions): UpdatePlan | null;
99
125
  /**
100
126
  * Execute an update plan, writing files and updating config.
101
127
  * If force is true, overwrites even locally modified files (with backup).
@@ -105,6 +131,14 @@ export declare function executeUpdatePlan(plan: UpdatePlan, options?: {
105
131
  restore?: boolean;
106
132
  prune?: boolean;
107
133
  }): UpdateResult;
134
+ /**
135
+ * Rewrite each capability's stored env record (capabilityConfig[cap].env) from
136
+ * the env its installed package declares. A variable the package still declares
137
+ * keeps its `set` flag; one it no longer declares is dropped, and a capability
138
+ * that declares none loses its record. `boop status` prefers this record over
139
+ * the package, so a stale one would list a moved variable under both capabilities.
140
+ */
141
+ export declare function refreshCapabilityEnv(targetDir: string, capabilities: string[]): void;
108
142
  /**
109
143
  * Count the skills a capability's project copy has edited in SKILL.md, and how
110
144
  * many of those boop has changed since — from the same comparison `boop update`