Skip to content

Commit 8210a82

Browse files
jheyworthclaude
andcommitted
fix(installer): address PR #2324 follow-up nitpicks
Four nitpicks from CodeRabbit's original review that were missed in the first triage pass: - Hand-edited pointers now survive the production install flow. cleanupCommandPointers spares pointers for canonicalIds that are still in the new manifest when called from the install/update flow (signal: options.previousSkillIds is set). Uninstall and partial-IDE removal flows still wipe pointers as before. The previous behavior wiped every pointer in removalSet before installCommandPointers could run, so its skip-if-exists guard never fired and hand edits were lost on every reinstall — contradicting the docstring's preservation claim. - RESERVED_OPENCODE_COMMANDS is now gated on this.name === 'opencode' so future adapters opting into commands_target_dir don't silently inherit OpenCode's reserved-name set. - printSummary now surfaces results.commands so users see how many pointers were created/refreshed/skipped per install, plus a warning for any per-file write failures. - Dropped a dead `typeof entry !== 'string'` check; fs.readdir without withFileTypes always yields strings. Tests: extends Suite 8 with a hand-edit-preservation regression that calls setup with previousSkillIds (the production shape) and asserts a sentinel byte sequence in the pointer body survives. 310 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e82ef30 commit 8210a82

2 files changed

Lines changed: 81 additions & 5 deletions

File tree

test/test-installation-components.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,21 @@ async function runTests() {
339339
const refreshed = await fs.readFile(commandFile, 'utf8');
340340
assert(refreshed.includes('UPDATED description'), 'Generator-shaped pointer is refreshed when manifest description changes');
341341

342+
// Hand-edit preservation across the production install flow. The
343+
// installer passes previousSkillIds — without the cleanup-side spare,
344+
// hand edits would be wiped here.
345+
const SENTINEL = 'HAND_EDITED_BY_USER_SHOULD_SURVIVE';
346+
const handEditedBody = `---\ndescription: my custom description\n---\n\n${SENTINEL}\n`;
347+
await fs.writeFile(commandFile, handEditedBody);
348+
const result4 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
349+
silent: true,
350+
selectedModules: ['bmm'],
351+
previousSkillIds: new Set(['bmad-master']),
352+
});
353+
assert(result4.success === true, 'Fourth OpenCode install succeeds with hand-edited pointer present');
354+
const afterReinstall = await fs.readFile(commandFile, 'utf8');
355+
assert(afterReinstall.includes(SENTINEL), 'Hand-edited pointer survives a routine reinstall (cleanup spares active-manifest IDs)');
356+
342357
await fs.remove(tempProjectDir);
343358
await fs.remove(path.dirname(installedBmadDir));
344359
} catch (error) {

tools/installer/ide/_config-driven.js

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,10 @@ class ConfigDrivenIdeSetup {
274274
continue;
275275
}
276276

277-
if (RESERVED_OPENCODE_COMMANDS.has(canonicalId)) {
277+
// Reserved-name guard is OpenCode-specific. Other adapters that opt
278+
// into commands_target_dir later should declare their own reserved
279+
// set rather than inheriting OpenCode's.
280+
if (this.name === 'opencode' && RESERVED_OPENCODE_COMMANDS.has(canonicalId)) {
278281
result.skippedCollision++;
279282
continue;
280283
}
@@ -412,6 +415,18 @@ class ConfigDrivenIdeSetup {
412415
if (count > 0) {
413416
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
414417
}
418+
const cmd = results.commands;
419+
if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) {
420+
const total = cmd.created + cmd.updated;
421+
const detail = cmd.updated > 0 ? `${cmd.created} new, ${cmd.updated} refreshed` : `${total}`;
422+
await prompts.log.success(`${this.name} commands: ${detail}${this.installerConfig.commands_target_dir}`);
423+
if (cmd.skippedCollision > 0) {
424+
await prompts.log.message(` (${cmd.skippedCollision} skipped — name collides with reserved slash command)`);
425+
}
426+
if (cmd.writeFailures > 0) {
427+
await prompts.log.warn(` (${cmd.writeFailures} pointer writes failed — see warnings above)`);
428+
}
429+
}
415430
}
416431

417432
/**
@@ -458,9 +473,20 @@ class ConfigDrivenIdeSetup {
458473
// Runs regardless of skipTarget — command pointers live in a per-IDE
459474
// directory and are not deduped across peers, so a peer-owned shared
460475
// skills directory does not protect this IDE's command pointers from
461-
// cleanup.
476+
// cleanup. The "currently active" set is passed so install-flow cleanup
477+
// (where removalSet contains skills that will be re-added moments later)
478+
// doesn't trample hand-edited pointers; install-flow cleanup will only
479+
// delete pointers for skills that are not in the new manifest.
462480
if (this.installerConfig?.commands_target_dir) {
463-
await this.cleanupCommandPointers(projectDir, this.installerConfig.commands_target_dir, options, removalSet);
481+
// In the install/update flow (signal: previousSkillIds was passed),
482+
// spare pointers whose canonicalId is still in the manifest so hand
483+
// edits survive a routine reinstall. In the uninstall flow (no
484+
// previousSkillIds — full uninstall or per-IDE removal via
485+
// cleanupByList), don't spare anything; the IDE itself is going away,
486+
// so its pointers should go with it.
487+
const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0;
488+
const activeSkillIds = isInstallFlow ? await this._readActiveSkillIds(resolvedBmadDir) : new Set();
489+
await this.cleanupCommandPointers(projectDir, this.installerConfig.commands_target_dir, options, removalSet, activeSkillIds);
464490
}
465491

466492
// Skip target_dir cleanup when a peer platform owns this directory
@@ -571,8 +597,14 @@ class ConfigDrivenIdeSetup {
571597
* @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands)
572598
* @param {Object} options
573599
* @param {Set<string>} removalSet - canonicalIds whose pointer files to remove
600+
* @param {Set<string>} [activeSkillIds] - canonicalIds present in the
601+
* current manifest. Pointers for IDs in this set are spared so an
602+
* install-flow cleanup (where removalSet === previousSkillIds and the
603+
* same skills are about to be re-installed) doesn't wipe hand-edited
604+
* pointer files. Pass an empty set or omit to delete every match in
605+
* removalSet (uninstall flow).
574606
*/
575-
async cleanupCommandPointers(projectDir, commandsTargetDir, options = {}, removalSet = new Set()) {
607+
async cleanupCommandPointers(projectDir, commandsTargetDir, options = {}, removalSet = new Set(), activeSkillIds = new Set()) {
576608
if (!removalSet || removalSet.size === 0) return;
577609

578610
const commandsPath = path.join(projectDir, commandsTargetDir);
@@ -586,9 +618,13 @@ class ConfigDrivenIdeSetup {
586618
}
587619

588620
for (const entry of entries) {
589-
if (typeof entry !== 'string' || !entry.endsWith('.md')) continue;
621+
if (!entry.endsWith('.md')) continue;
590622
const canonicalId = entry.slice(0, -3);
591623
if (!removalSet.has(canonicalId)) continue;
624+
// Spare pointers for skills that are still in the manifest; the
625+
// install pass will refresh them in place if their content has gone
626+
// stale, while preserving hand edits.
627+
if (activeSkillIds.has(canonicalId)) continue;
592628
try {
593629
await fs.remove(path.join(commandsPath, entry));
594630
} catch {
@@ -607,6 +643,31 @@ class ConfigDrivenIdeSetup {
607643
}
608644
}
609645

646+
/**
647+
* Read the canonicalIds currently present in the skill-manifest.csv.
648+
* Used by cleanup to distinguish "re-install of an existing skill"
649+
* (preserve pointer) from "skill truly being removed" (delete pointer).
650+
* @param {string|null} bmadDir
651+
* @returns {Promise<Set<string>>}
652+
*/
653+
async _readActiveSkillIds(bmadDir) {
654+
const ids = new Set();
655+
if (!bmadDir) return ids;
656+
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
657+
if (!(await fs.pathExists(csvPath))) return ids;
658+
try {
659+
const content = await fs.readFile(csvPath, 'utf8');
660+
const records = csv.parse(content, { columns: true, skip_empty_lines: true });
661+
for (const record of records) {
662+
if (record.canonicalId) ids.add(record.canonicalId);
663+
}
664+
} catch {
665+
// Manifest unreadable — return an empty set so cleanup falls back to
666+
// the conservative "delete what removalSet says" behavior.
667+
}
668+
return ids;
669+
}
670+
610671
/**
611672
* Cleanup a specific target directory.
612673
* When removalSet is provided, only removes entries in that set.

0 commit comments

Comments
 (0)