@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
@@ -57,12 +57,14 @@ exports.buildPrompt = buildPrompt;
57
57
  exports.shouldRotateSession = shouldRotateSession;
58
58
  exports.sessionDidWork = sessionDidWork;
59
59
  exports.splitStreamLines = splitStreamLines;
60
+ exports.sessionEnv = sessionEnv;
60
61
  exports.runLoop = runLoop;
61
62
  const child_process_1 = require("child_process");
62
63
  const fs = __importStar(require("fs"));
63
64
  const path = __importStar(require("path"));
64
65
  const crypto_1 = require("crypto");
65
66
  const string_decoder_1 = require("string_decoder");
67
+ const init_1 = require("./init");
66
68
  /**
67
69
  * Output cap for every `bd … --json` read the loop makes.
68
70
  *
@@ -131,13 +133,22 @@ function readReadyQueue(execSyncFn) {
131
133
  /**
132
134
  * Hard requirement: beads must run in Dolt server mode so concurrent worktrees
133
135
  * share one issue DB over TCP. Reads `.beads/metadata.json` and throws an
134
- * actionable error on embedded/missing — call before any `bd` work.
136
+ * actionable error on embedded/missing — call before any `bd` work. A project
137
+ * with no `.beads/` at all (one started with `boop add`) is told the `bd init`
138
+ * command `boop init` would have run. The messages name no skill file, because
139
+ * an installed project may not have the one boop's own repository does.
135
140
  */
136
141
  function assertDoltServerMode(projectDir, deps) {
137
142
  const existsSyncFn = deps?.existsSyncFn || fs.existsSync;
138
143
  const readFileFn = deps?.readFileFn || ((p, enc) => fs.readFileSync(p, enc));
139
- const metaPath = path.join(projectDir, '.beads', 'metadata.json');
140
- const fix = 'Migrate beads to Dolt server mode before running the loop; see skills/boop-dolt-recovery/SKILL.md';
144
+ const beadsDir = path.join(projectDir, '.beads');
145
+ const metaPath = path.join(beadsDir, 'metadata.json');
146
+ if (!existsSyncFn(beadsDir)) {
147
+ const initCommand = `bd ${(0, init_1.bdInitArgs)(path.basename(path.resolve(projectDir))).join(' ')}`;
148
+ throw new Error(`beads is not set up in ${projectDir}: there is no .beads folder. ` +
149
+ `The loop tracks its work in beads, running in Dolt server mode. To set it up, run this from the project folder:\n ${initCommand}`);
150
+ }
151
+ const fix = 'Switch this project\'s beads to Dolt server mode before running the loop.';
141
152
  if (!existsSyncFn(metaPath)) {
142
153
  throw new Error(`beads metadata not found at ${metaPath}. The loop requires Dolt server mode for concurrent worktrees. ${fix}`);
143
154
  }
@@ -500,8 +511,10 @@ async function planWork(opts) {
500
511
  const prompt = buildPlanningPrompt(workContent, flowContent, opts.prefix);
501
512
  const sessionId = (0, crypto_1.randomUUID)();
502
513
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
503
- const logFile = path.join(opts.logDir, `_planning_${opts.prefix}_${timestamp}.log`);
504
- const readableLogFile = path.join(opts.logDir, `_planning_${opts.prefix}_${timestamp}.readable.log`);
514
+ // A spec-driven run usually has no prefix, so fall back to the spec's base name.
515
+ const runName = opts.prefix || path.basename(opts.workPath, path.extname(opts.workPath));
516
+ const logFile = path.join(opts.logDir, `_planning_${runName}_${timestamp}.log`);
517
+ const readableLogFile = path.join(opts.logDir, `_planning_${runName}_${timestamp}.readable.log`);
505
518
  // Write readable log header
506
519
  fs.writeFileSync(readableLogFile, `=== Planning Session ===\nSession: ${sessionId}\nStarted: ${new Date().toISOString()}\n\n`);
507
520
  console.log(`\x1b[32m[bd-loop]\x1b[0m 📋 Starting planning phase...`);
@@ -915,7 +928,7 @@ function readInstalledSkills(projectDir) {
915
928
  /**
916
929
  * The installed skills an issue description names, by bare name or by path, in
917
930
  * the order each is first mentioned. A name only matches whole, so
918
- * boop-testing-testing does not match inside boop-testing-testing-express-web.
931
+ * boop-testing-testing does not match inside boop-testing-testing-integration.
919
932
  */
920
933
  function namedSkills(description, installed) {
921
934
  const found = [];
@@ -1075,6 +1088,24 @@ function splitStreamLines(carry, chunk) {
1075
1088
  const rest = pieces.pop() ?? '';
1076
1089
  return { lines: pieces.filter(Boolean), rest };
1077
1090
  }
1091
+ // Propagate the chosen model beyond this session via the environment.
1092
+ // `--model` (in args) sets this session; ANTHROPIC_MODEL additionally
1093
+ // (a) reaches a nested `boop loop` — child processes inherit the env — so it
1094
+ // doesn't reset to the default, and (b) is honored by any sub-agent whose
1095
+ // definition opts into `model: inherit`. We deliberately do NOT set
1096
+ // CLAUDE_CODE_SUBAGENT_MODEL: sub-agents keep their own declared model, so a
1097
+ // deliberately-cheap agent (e.g. Explore on Haiku) stays cheap. The invoking
1098
+ // agent can still override a sub-agent's model per-invocation when it needs
1099
+ // to. See resolveModel().
1100
+ //
1101
+ // CLAUDE_CODE_ENTRYPOINT marks the session as machine-driven, so its transcript
1102
+ // is not mistaken for one the operator typed into. CLAUDE_CODE_DISABLE_AUTO_MEMORY
1103
+ // keeps the operator's auto-memory index, written for their interactive session,
1104
+ // out of a coding session (honoured by Claude Code 2.1.296).
1105
+ function sessionEnv(model, env = process.env) {
1106
+ const machineDriven = { ...env, CLAUDE_CODE_ENTRYPOINT: 'boop-loop', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' };
1107
+ return model ? { ...machineDriven, ANTHROPIC_MODEL: model } : machineDriven;
1108
+ }
1078
1109
  function runClaude(prompt, opts, logFile, readableLogFile) {
1079
1110
  return new Promise((resolve) => {
1080
1111
  const args = [
@@ -1105,19 +1136,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
1105
1136
  const readableStream = readableLogFile
1106
1137
  ? fs.createWriteStream(readableLogFile, { flags: 'a' })
1107
1138
  : null;
1108
- // Propagate the chosen model beyond this session via the environment.
1109
- // `--model` (in args) sets this session; ANTHROPIC_MODEL additionally
1110
- // (a) reaches a nested `boop loop` — child processes inherit the env — so it
1111
- // doesn't reset to the default, and (b) is honored by any sub-agent whose
1112
- // definition opts into `model: inherit`. We deliberately do NOT set
1113
- // CLAUDE_CODE_SUBAGENT_MODEL: sub-agents keep their own declared model, so a
1114
- // deliberately-cheap agent (e.g. Explore on Haiku) stays cheap. The invoking
1115
- // agent can still override a sub-agent's model per-invocation when it needs
1116
- // to. See resolveModel().
1117
- const childEnv = opts.model
1118
- ? { ...process.env, ANTHROPIC_MODEL: opts.model }
1119
- : process.env;
1120
- const child = (0, child_process_1.spawn)(claudeBin, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv });
1139
+ const child = (0, child_process_1.spawn)(claudeBin, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'], env: sessionEnv(opts.model) });
1121
1140
  let limitHit = false;
1122
1141
  // Track context-window pressure from outside the agent (spec: the loop reads
1123
1142
  // the stream it already parses; the agent is never told). Peak occupancy over
@@ -51,7 +51,7 @@ function runMigrateSkillEdits(deps = {}) {
51
51
  const projectDir = process.cwd();
52
52
  const manifest = (0, project_1.readProjectManifest)(projectDir);
53
53
  if (!manifest) {
54
- console.error('\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run `boop init` first.');
54
+ console.error('\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run `boop init` in an empty folder, or `boop add <capability>` in an existing project, first.');
55
55
  process.exitCode = 1;
56
56
  return;
57
57
  }
@@ -67,7 +67,7 @@ async function runApplySkillEdits(deps = terminalDeps()) {
67
67
  const projectDir = process.cwd();
68
68
  const manifest = (0, project_1.readProjectManifest)(projectDir);
69
69
  if (!manifest) {
70
- console.error('\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run `boop init` first.');
70
+ console.error('\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run `boop init` in an empty folder, or `boop add <capability>` in an existing project, first.');
71
71
  process.exitCode = 1;
72
72
  return;
73
73
  }
@@ -1,4 +1,5 @@
1
1
  import { BoopManifest } from '../lib/project';
2
+ import { MoveState } from '../lib/moves';
2
3
  export interface CapabilityStatusEnvVar {
3
4
  required?: string;
4
5
  set: boolean;
@@ -13,6 +14,15 @@ export interface CapabilityStatus {
13
14
  reachable?: boolean;
14
15
  /** Skills whose SKILL.md this project has edited, which no boop update can improve until the edits move out */
15
16
  skillEdits?: SkillEditsStatus;
17
+ /** Paths leaving this capability for another, which the move pass of `boop update` has not settled */
18
+ moves?: PendingMove[];
19
+ }
20
+ export interface PendingMove {
21
+ path: string;
22
+ toCapability: string;
23
+ toPath: string;
24
+ state: MoveState;
25
+ instruction: string;
16
26
  }
17
27
  export interface SkillEditsStatus {
18
28
  edited: number;
@@ -46,6 +46,7 @@ const capability_1 = require("../lib/capability");
46
46
  const interpolate_1 = require("../lib/interpolate");
47
47
  const db_status_1 = require("../lib/db-status");
48
48
  const update_1 = require("../lib/update");
49
+ const moves_1 = require("../lib/moves");
49
50
  function formatStatus(manifest) {
50
51
  const caps = manifest.capabilities.length > 0
51
52
  ? manifest.capabilities.join(', ')
@@ -104,7 +105,7 @@ function getStatusData(manifest, dir) {
104
105
  const isSet = setKeys.has(varName) || hasLiteralDefault;
105
106
  envStatus[varName] = { required: decl.required, set: isSet };
106
107
  if (!isSet) {
107
- const isRequired = decl.required && decl.required !== 'false';
108
+ const isRequired = decl.required === 'boot' || decl.required === 'runtime';
108
109
  if (isRequired) {
109
110
  allRequiredSet = false;
110
111
  // Look for a setupCheck hint for this env var
@@ -164,6 +165,21 @@ function getStatusData(manifest, dir) {
164
165
  capabilities[capName].skillEdits = { ...counts, instruction: update_1.SKILL_EDITS_INSTRUCTION };
165
166
  }
166
167
  }
168
+ // Every move the move pass finds, listed under the capability the files are leaving
169
+ for (const move of (0, moves_1.planMoves)(dir, manifest, manifest.capabilities).moves) {
170
+ const entry = capabilities[move.fromCapability];
171
+ if (!entry)
172
+ continue;
173
+ const instruction = move.instruction
174
+ ?? (move.state === 'split-will-add' ? `\`boop update\` adds ${move.toCapability}.` : 'Run `boop update`.');
175
+ entry.moves = [...(entry.moves ?? []), {
176
+ path: move.from,
177
+ toCapability: move.toCapability,
178
+ toPath: move.to,
179
+ state: move.state,
180
+ instruction,
181
+ }];
182
+ }
167
183
  return { name: manifest.name, profile: manifest.profile, capabilities };
168
184
  }
169
185
  // Re-export for backward compatibility in tests
@@ -261,6 +277,15 @@ function formatCapabilityLine(capName, cap) {
261
277
  lines.push(` \x1b[33m◐\x1b[0m ${edited} skill${edited === 1 ? '' : 's'} edited in SKILL.md, ${changesWaiting} with boop changes waiting`);
262
278
  lines.push(` → ${instruction}`);
263
279
  }
280
+ for (const move of cap.moves ?? []) {
281
+ const from = move.path.replace(/\/$/, '');
282
+ const as = move.toPath.endsWith('/') ? ` as ${path.basename(move.toPath)}` : '';
283
+ const label = move.state === 'split-will-add'
284
+ ? `${move.toCapability} split from ${capName}: ${from} moves into it`
285
+ : `${from} moved to ${move.toCapability}${as}, waiting`;
286
+ lines.push(` \x1b[33m◐\x1b[0m ${label}`);
287
+ lines.push(` → ${move.instruction}`);
288
+ }
264
289
  return lines;
265
290
  }
266
291
  /**
@@ -301,7 +326,7 @@ async function runStatus(options) {
301
326
  throw err;
302
327
  }
303
328
  if (!manifest) {
304
- console.error('Not a boop project (no .boop/config.json found). Run `boop init` to create one.');
329
+ console.error('Not a boop project (no .boop/config.json found). Run `boop init` in an empty folder, or `boop add <capability>` in an existing project, to create one.');
305
330
  process.exitCode = 1;
306
331
  return;
307
332
  }
@@ -40,14 +40,18 @@ const child_process_1 = require("child_process");
40
40
  const project_1 = require("../lib/project");
41
41
  const capability_1 = require("../lib/capability");
42
42
  const update_1 = require("../lib/update");
43
+ const moves_1 = require("../lib/moves");
44
+ const capability_2 = require("../lib/capability");
45
+ const preflight_1 = require("../lib/preflight");
46
+ const execute_1 = require("../lib/execute");
43
47
  const scaffold_1 = require("../lib/scaffold");
44
48
  const stale_scripts_1 = require("../lib/stale-scripts");
45
49
  function runUpdate(capabilityName, options) {
46
50
  const targetDir = process.cwd();
47
51
  // Must be in a boop project
48
- const manifest = (0, project_1.readProjectManifest)(targetDir);
52
+ let manifest = (0, project_1.readProjectManifest)(targetDir);
49
53
  if (!manifest) {
50
- console.error(`\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run \`boop init\` first.`);
54
+ console.error(`\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run \`boop init\` in an empty folder, or \`boop add <capability>\` in an existing project, first.`);
51
55
  process.exitCode = 1;
52
56
  return;
53
57
  }
@@ -76,10 +80,44 @@ function runUpdate(capabilityName, options) {
76
80
  }
77
81
  }
78
82
  }
79
- // Plan all updates
83
+ // Step 2: the move pass, against the freshly installed packages. A path a
84
+ // capability stopped shipping because another ships it now is settled before
85
+ // either capability is planned (specs/skill-moves.spec.md).
86
+ let movePlan = (0, moves_1.planMoves)(targetDir, manifest, capsToUpdate);
87
+ const capabilitiesAdded = [];
88
+ let moveResults = new Map();
89
+ if (!options.dryRun) {
90
+ // Step 3: add each capability split out of an installed one, then settle the moves again with it installed
91
+ if (movePlan.splitsToAdd.length > 0) {
92
+ const unaddable = {};
93
+ for (const split of movePlan.splitsToAdd) {
94
+ const reason = addSplitCapability(targetDir, split.capability, (0, moves_1.arrivingPaths)(movePlan.moves, split.capability), options);
95
+ if (reason) {
96
+ unaddable[split.capability] = reason;
97
+ }
98
+ else {
99
+ capabilitiesAdded.push(split);
100
+ }
101
+ }
102
+ manifest = (0, project_1.readProjectManifest)(targetDir);
103
+ movePlan = (0, moves_1.planMoves)(targetDir, manifest, [...capsToUpdate, ...capabilitiesAdded.map(a => a.capability)], { unaddable });
104
+ }
105
+ // Step 4: renames, hash transfers and removals, with the manifest written once
106
+ moveResults = (0, moves_1.executeMoves)(targetDir, movePlan.moves);
107
+ manifest = (0, project_1.readProjectManifest)(targetDir);
108
+ }
109
+ // Step 5: plan each capability against the manifest the moves wrote. What a
110
+ // move did not carry out (a hold, a deprecation) belongs to the leaving
111
+ // capability's plan; in a dry run, every move does, and the receiving
112
+ // capability leaves the arriving paths to it.
80
113
  const plans = [];
81
- for (const cap of capsToUpdate) {
82
- const plan = (0, update_1.planCapabilityUpdate)(targetDir, cap, manifest);
114
+ for (const cap of [...capsToUpdate, ...capabilitiesAdded.map(a => a.capability)]) {
115
+ const pending = movePlan.moves.filter(m => m.fromCapability === cap
116
+ && (options.dryRun || m.outcome === 'hold' || m.outcome === 'deprecate'));
117
+ const plan = (0, update_1.planCapabilityUpdate)(targetDir, cap, manifest, {
118
+ moves: pending.flatMap(moves_1.moveActions),
119
+ arriving: options.dryRun ? (0, moves_1.arrivingPaths)(movePlan.moves, cap) : [],
120
+ });
83
121
  if (plan) {
84
122
  plans.push(plan);
85
123
  }
@@ -101,6 +139,9 @@ function runUpdate(capabilityName, options) {
101
139
  console.log(JSON.stringify({ capabilities: plans, staleScripts: stale }, null, 2));
102
140
  return;
103
141
  }
142
+ for (const split of movePlan.splitsToAdd) {
143
+ console.log(`\nwould add capability ${split.capability} (split from ${split.splitFrom})`);
144
+ }
104
145
  for (const plan of plans) {
105
146
  console.log(`\n${plan.capability}:`);
106
147
  // Recording a hash for a file that already holds what boop ships changes nothing the project can see
@@ -115,8 +156,9 @@ function runUpdate(capabilityName, options) {
115
156
  const icon = action.action === 'overwrite' ? '\x1b[32m↑\x1b[0m'
116
157
  : action.action === 'skip' ? '\x1b[33m◐\x1b[0m'
117
158
  : action.action === 'add' ? '\x1b[32m+\x1b[0m'
118
- : action.action === 'migrate' ? '\x1b[36m↗\x1b[0m'
119
- : '\x1b[90m⚠\x1b[0m';
159
+ : action.action === 'migrate' || action.action === 'move' ? '\x1b[36m↗\x1b[0m'
160
+ : action.action === 'remove' ? '\x1b[31m✕\x1b[0m'
161
+ : '\x1b[90m⚠\x1b[0m';
120
162
  const label = action.action === 'skip' && (action.drift || action.instruction) ? skipLabel(action) : action.reason;
121
163
  console.log(` ${icon} ${action.relativePath} — ${label}`);
122
164
  if (action.instruction)
@@ -136,28 +178,31 @@ function runUpdate(capabilityName, options) {
136
178
  for (const plan of plans) {
137
179
  if (plan.upToDate) {
138
180
  results.push({
139
- capability: plan.capability,
140
- overwritten: [],
141
- skipped: [],
142
- added: [],
143
- deprecated: [],
144
- forcedOverwrite: [],
145
- skippedDeleted: [],
146
- pruned: [],
147
- migrated: [],
148
- scriptsAdded: [],
181
+ ...emptyResult(plan.capability),
149
182
  scriptsKept: plan.scriptActions.filter(a => a.action === 'keep'),
150
183
  unchangedCount: plan.unchangedCount,
151
- instructions: [],
152
184
  });
153
185
  continue;
154
186
  }
155
187
  const result = (0, update_1.executeUpdatePlan)(plan, { force: options.force, restore: options.restore, prune: options.prune });
156
188
  results.push(result);
157
189
  }
190
+ // Each executed move is reported under the capability the files left
191
+ for (const moveResult of moveResults.values()) {
192
+ let result = results.find(r => r.capability === moveResult.capability);
193
+ if (!result) {
194
+ result = emptyResult(moveResult.capability);
195
+ results.push(result);
196
+ }
197
+ result.moved.push(...moveResult.moved);
198
+ result.removed.push(...moveResult.removed);
199
+ result.instructions.push(...moveResult.instructions);
200
+ }
158
201
  // Refresh .claude/skills/ symlinks for any new skill folders added by this update,
159
- // and put back any PROJECT.md the project has deleted
202
+ // put back any PROJECT.md the project has deleted, and bring each capability's
203
+ // stored env settings in line with what its package declares now
160
204
  if (!options.dryRun) {
205
+ (0, update_1.refreshCapabilityEnv)(targetDir, plans.map(p => p.capability));
161
206
  (0, scaffold_1.linkSkillsToClaude)(targetDir);
162
207
  (0, scaffold_1.createSkillProjectFiles)(targetDir);
163
208
  }
@@ -173,17 +218,25 @@ function runUpdate(capabilityName, options) {
173
218
  if (options.json) {
174
219
  console.log(JSON.stringify({
175
220
  capabilities: results,
221
+ capabilitiesAdded,
176
222
  staleScripts: remainingStale,
177
223
  fixedScripts,
178
224
  }, null, 2));
179
225
  return;
180
226
  }
181
227
  console.log(`\n${'─'.repeat(40)}`);
182
- results.forEach((result, i) => {
228
+ for (const added of capabilitiesAdded) {
229
+ console.log(`added capability ${added.capability} (split from ${added.splitFrom})`);
230
+ }
231
+ results.forEach((result) => {
183
232
  const summary = (0, update_1.formatUpdateSummary)(result);
184
233
  console.log(`${result.capability}: ${summary}`);
185
- const actionFor = (f) => plans[i].actions.find(a => a.relativePath === f);
234
+ const plan = plans.find(p => p.capability === result.capability);
235
+ const actionFor = (f) => plan?.actions.find(a => a.relativePath === f);
186
236
  const instructionFor = (f) => result.instructions.find(entry => entry.relativePath === f)?.instruction;
237
+ for (const line of moveLines(result, movePlan)) {
238
+ console.log(line);
239
+ }
187
240
  // Show details for non-trivial updates
188
241
  for (const f of result.migrated || []) {
189
242
  console.log(` \x1b[36m↗\x1b[0m ${f} (migrated to folder)`);
@@ -217,9 +270,13 @@ function runUpdate(capabilityName, options) {
217
270
  console.log(` \x1b[32m+\x1b[0m ${f} (new)`);
218
271
  }
219
272
  for (const f of result.deprecated) {
273
+ const movedTo = actionFor(f)?.toCapability;
220
274
  if (result.pruned?.includes(f)) {
221
275
  console.log(` \x1b[31m✕\x1b[0m ${f} (pruned, backup saved)`);
222
276
  }
277
+ else if (movedTo) {
278
+ console.log(` \x1b[33m⚠\x1b[0m ${f} (deprecated — now ships in ${movedTo}, which this project does not install; use --prune to delete)`);
279
+ }
223
280
  else {
224
281
  console.log(` \x1b[33m⚠\x1b[0m ${f} (deprecated — removed from package, use --prune to delete)`);
225
282
  }
@@ -235,6 +292,81 @@ function runUpdate(capabilityName, options) {
235
292
  console.log(`\n${(0, stale_scripts_1.formatStaleScriptWarning)(remainingStale)}`);
236
293
  }
237
294
  }
295
+ /**
296
+ * The report's lines for the moves the update carried out from one capability:
297
+ * each folder or file moved, with where it went, and each skill folder removed
298
+ * because the capability that ships it now is not installed here.
299
+ */
300
+ function moveLines(result, movePlan) {
301
+ const lines = [];
302
+ const instructionFor = (f) => result.instructions.find(entry => entry.relativePath === f)?.instruction;
303
+ for (const moved of result.moved) {
304
+ const planned = movePlan.moves.find(m => m.fromCapability === result.capability && m.from === moved.from);
305
+ const carried = planned?.projectFileCarried ? '; PROJECT.md carried across' : '';
306
+ lines.push(` \x1b[36m↗\x1b[0m ${moved.from} → ${moved.to} (${moved.toCapability}${carried})`);
307
+ const instruction = instructionFor(moved.from);
308
+ if (instruction)
309
+ lines.push(instructionLine(instruction));
310
+ }
311
+ for (const removed of result.removed) {
312
+ const planned = movePlan.moves.find(m => m.fromCapability === result.capability && m.from === removed);
313
+ const toCapability = planned?.toCapability;
314
+ const name = path.basename(planned?.to ?? removed);
315
+ lines.push(` \x1b[31m✕\x1b[0m ${removed} (removed: now ships in ${toCapability} as ${name}, which this project does not install; \`boop add ${toCapability}\` brings it back)`);
316
+ }
317
+ return lines;
318
+ }
319
+ /**
320
+ * Install a capability split out of an installed one, and add it as `boop add`
321
+ * would, except that the paths arriving by move are not copied: the move
322
+ * decides what happens to them. Returns why it could not be added, or null.
323
+ */
324
+ function addSplitCapability(targetDir, capability, arriving, options) {
325
+ if (!(0, capability_1.getPackageDir)(capability, [targetDir])) {
326
+ if (options.skipNpm) {
327
+ return `its package is not installed, and --skip-npm kept boop from installing it`;
328
+ }
329
+ const packageName = (0, capability_2.getCatalogEntry)(capability)?.package ?? `@boopkit/cap-${capability}`;
330
+ const spec = options.tag ? `${packageName}@${options.tag}` : packageName;
331
+ try {
332
+ (0, child_process_1.execSync)(`npm install ${spec}`, { cwd: targetDir, stdio: options.json ? 'pipe' : 'inherit' });
333
+ }
334
+ catch {
335
+ return `npm install ${spec} failed`;
336
+ }
337
+ }
338
+ try {
339
+ const { manifest } = (0, preflight_1.planAdd)(targetDir, capability, { excludePaths: arriving });
340
+ (0, execute_1.executeManifest)(manifest, { install: !options.skipNpm });
341
+ return null;
342
+ }
343
+ catch (err) {
344
+ if (err instanceof preflight_1.PreflightError) {
345
+ return err.checks.filter(c => !c.passed).map(c => c.message).join('; ');
346
+ }
347
+ throw err;
348
+ }
349
+ }
350
+ function emptyResult(capability) {
351
+ return {
352
+ capability,
353
+ overwritten: [],
354
+ skipped: [],
355
+ added: [],
356
+ deprecated: [],
357
+ forcedOverwrite: [],
358
+ skippedDeleted: [],
359
+ pruned: [],
360
+ migrated: [],
361
+ scriptsAdded: [],
362
+ scriptsKept: [],
363
+ unchangedCount: 0,
364
+ instructions: [],
365
+ moved: [],
366
+ removed: [],
367
+ movesWaiting: [],
368
+ };
369
+ }
238
370
  /**
239
371
  * How the report shows the npm scripts a capability declares: an added one reads
240
372
  * like a new file, and a kept one shows the command the capability ships, so a
@@ -265,6 +397,10 @@ function isEditDoingItsJob(action) {
265
397
  }
266
398
  /** What the report says happened to a file the update skipped */
267
399
  function skipLabel(action) {
400
+ if (action?.movedTo) {
401
+ const as = action.movedTo.endsWith('/') ? ` as ${path.basename(action.movedTo)}` : '';
402
+ return `moved to ${action.toCapability}${as}, waiting`;
403
+ }
268
404
  if (action?.drift === 'project-only')
269
405
  return 'edited here, skipped';
270
406
  if (action?.drift === 'both-changed') {
@@ -105,8 +105,11 @@ function getCapabilityFromPackage(name, searchPaths) {
105
105
  const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
106
106
  if (!pkgJson.boopkit?.capability)
107
107
  return null;
108
+ // A capability ships as the package it sits in, so its package.json name is
109
+ // the package when capability.json does not say. Five skill capabilities
110
+ // never said, and `boop add` then never made them a project dependency.
108
111
  const capJson = JSON.parse(fs.readFileSync(capJsonPath, 'utf-8'));
109
- return capJson;
112
+ return { ...capJson, package: capJson.package ?? pkgJson.name };
110
113
  }
111
114
  /**
112
115
  * Scan node_modules/@boopkit/cap-* to find a package whose capability.json
@@ -62,7 +62,7 @@ function executeManifest(manifest, options = {}) {
62
62
  // Back up each mutable target file once before executing any tasks
63
63
  const backedUp = new Set();
64
64
  for (const task of manifest.tasks) {
65
- if (task.op === 'copy-files' || task.op === 'run-command')
65
+ if (task.op === 'copy-files' || task.op === 'run-command' || task.op === 'install-package')
66
66
  continue;
67
67
  if (task.target && fs.existsSync(task.target) && !backedUp.has(task.target)) {
68
68
  (0, safe_io_1.createBackup)(task.target);
@@ -99,8 +99,25 @@ function executeManifest(manifest, options = {}) {
99
99
  function executeTask(task, manifest) {
100
100
  const { targetDir, capability: capabilityName } = manifest;
101
101
  switch (task.op) {
102
+ case 'create-package-json': {
103
+ const data = task.data;
104
+ fs.writeFileSync(task.target, (0, scaffold_1.renderBasePackageJson)(data.projectName), 'utf-8');
105
+ break;
106
+ }
107
+ case 'create-manifest': {
108
+ const data = task.data;
109
+ (0, project_1.writeProjectManifest)(targetDir, data.manifest);
110
+ break;
111
+ }
112
+ case 'install-package': {
113
+ const data = task.data;
114
+ console.error(`Running ${data.command}...`);
115
+ (0, child_process_1.execSync)(data.command, { cwd: targetDir, stdio: 'pipe' });
116
+ break;
117
+ }
102
118
  case 'copy-files': {
103
- (0, scaffold_1.copyCapabilityFiles)(targetDir, capabilityName);
119
+ const data = task.data;
120
+ (0, scaffold_1.copyCapabilityFiles)(targetDir, capabilityName, undefined, data.exclude);
104
121
  (0, scaffold_1.linkSkillsToClaude)(targetDir);
105
122
  (0, scaffold_1.createSkillProjectFiles)(targetDir);
106
123
  break;
@@ -169,7 +186,17 @@ function executeTask(task, manifest) {
169
186
  case 'run-command': {
170
187
  const data = task.data;
171
188
  console.log(`\nRunning ${data.command}...`);
172
- (0, child_process_1.execSync)(data.command, { cwd: targetDir, stdio: 'inherit' });
189
+ // stderr is captured rather than inherited so that a failure's warning
190
+ // carries the command's own error text; on success it is passed through.
191
+ const result = (0, child_process_1.spawnSync)(data.command, { cwd: targetDir, shell: true, stdio: ['inherit', 'inherit', 'pipe'] });
192
+ if (result.error)
193
+ throw result.error;
194
+ const stderr = result.stderr.toString();
195
+ if (result.status !== 0) {
196
+ const exit = result.signal ? `signal ${result.signal}` : `exit code ${result.status}`;
197
+ throw new Error(`Command failed (${exit}): ${data.command}\n${stderr}`);
198
+ }
199
+ process.stderr.write(stderr);
173
200
  break;
174
201
  }
175
202
  }
@@ -52,6 +52,11 @@ export declare function describePlacementOwner(placement: Placement): string;
52
52
  * has no need of npm module resolution (and must not require a build).
53
53
  */
54
54
  export declare function capabilityPlacementsFromDir(pkgDir: string): Placement[];
55
+ /**
56
+ * Whether a project-relative path is one of `paths`, or lies inside one that
57
+ * names a folder (ends in `/`).
58
+ */
59
+ export declare function isUnderAny(relPath: string, paths: string[]): boolean;
55
60
  /**
56
61
  * Every file a capability ships and where it lands in a project.
57
62
  *
@@ -53,6 +53,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
53
53
  exports.PlacementCollisionError = void 0;
54
54
  exports.describePlacementOwner = describePlacementOwner;
55
55
  exports.capabilityPlacementsFromDir = capabilityPlacementsFromDir;
56
+ exports.isUnderAny = isUnderAny;
56
57
  exports.capabilityPlacements = capabilityPlacements;
57
58
  exports.profilePlacements = profilePlacements;
58
59
  exports.skillDirectoryPlacements = skillDirectoryPlacements;
@@ -160,6 +161,13 @@ function capabilityPlacementsFromDir(pkgDir) {
160
161
  }
161
162
  return placements;
162
163
  }
164
+ /**
165
+ * Whether a project-relative path is one of `paths`, or lies inside one that
166
+ * names a folder (ends in `/`).
167
+ */
168
+ function isUnderAny(relPath, paths) {
169
+ return paths.some(p => p.endsWith('/') ? relPath.startsWith(p) : relPath === p);
170
+ }
163
171
  function isCapabilityPackage(pkgDir) {
164
172
  const pkgJsonPath = path.join(pkgDir, 'package.json');
165
173
  if (!fs.existsSync(pkgJsonPath))
@@ -7,7 +7,7 @@
7
7
  * Phase 2 (execute) walks the manifest, performing each operation and
8
8
  * updating task status.
9
9
  */
10
- export type ManifestOp = 'copy-files' | 'merge-deps' | 'merge-scripts' | 'link-package' | 'inject-hook' | 'inject-css' | 'set-env' | 'update-manifest' | 'run-command';
10
+ export type ManifestOp = 'create-package-json' | 'create-manifest' | 'install-package' | 'copy-files' | 'merge-deps' | 'merge-scripts' | 'link-package' | 'inject-hook' | 'inject-css' | 'set-env' | 'update-manifest' | 'run-command';
11
11
  export type TaskStatus = 'pending' | 'done' | 'failed' | 'skipped';
12
12
  export interface ManifestTask {
13
13
  op: ManifestOp;