From 4a0e34fa7cf70a1a381ed40d6d0bdb76e6fe23e4 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 26 Jul 2026 04:38:35 -0400 Subject: [PATCH] fix: make Claude skill migration recoverable --- docs/es/README.md | 15 +- scripts/install-apply.js | 14 +- scripts/lib/install-executor.js | 6 + scripts/lib/install-lifecycle.js | 60 ++++++-- scripts/lib/install/apply.js | 26 +++- scripts/lib/install/claude-skill-migration.js | 26 ++-- .../install-claude-skill-migration.test.js | 128 +++++++++++++++++- tests/lib/install-lifecycle.test.js | 94 +++++++++++++ tests/scripts/install-apply.test.js | 42 +++++- 9 files changed, 366 insertions(+), 45 deletions(-) diff --git a/docs/es/README.md b/docs/es/README.md index 7af96eaa1..a6a30dbb0 100644 --- a/docs/es/README.md +++ b/docs/es/README.md @@ -757,16 +757,13 @@ cp -r rules/golang ~/.claude/rules/ecc/ cp -r rules/php ~/.claude/rules/ecc/ cp -r rules/arkts ~/.claude/rules/ecc/ -# Copiar skills primero (superficie principal de flujo de trabajo) -# Recomendado (nuevos usuarios): solo skills generales/básicas -mkdir -p ~/.claude/skills -cp -r .agents/skills/* ~/.claude/skills/ -cp -r skills/search-first ~/.claude/skills/ +# Instalar skills con el instalador consciente de migraciones. +# Conserva skills del usuario, informa conflictos y evita sobrescribirlos. +node scripts/install-apply.js --target claude --modules workflow-quality -# Opcional: añadir skills específicas de framework solo cuando las necesites -# for s in django-patterns django-tdd laravel-patterns springboot-patterns quarkus-patterns; do -# cp -r skills/$s ~/.claude/skills/ -# done +# Opcional: instalar skills concretas solo cuando las necesites. +node scripts/install-apply.js --target claude --skills search-first +# node scripts/install-apply.js --target claude --skills django-patterns,django-tdd # Opcional: mantener compatibilidad con entradas slash durante la migración mkdir -p ~/.claude/commands diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 184d9f87d..8f1b41cca 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -103,7 +103,7 @@ function printHumanPlan(plan, dryRun) { } } console.log(`${dryRun ? 'Operations' : 'Applied operations'}: ${plan.operations.length}`); - if (!dryRun && Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) { + if (Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) { console.log(`Skipped operations: ${plan.skippedOperations.length}`); } @@ -119,7 +119,7 @@ function printHumanPlan(plan, dryRun) { console.log(`- ${operation.sourceRelativePath} -> ${operation.destinationPath}`); } - if (!dryRun && Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) { + if (Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) { console.log('\nSkipped file operations:'); for (const operation of plan.skippedOperations) { console.log(`- ${operation.sourceRelativePath} -> ${operation.destinationPath}`); @@ -145,7 +145,10 @@ function main() { findDefaultInstallConfigPath, loadInstallConfig, } = require('./lib/install/config'); - const { applyInstallPlan } = require('./lib/install-executor'); + const { + applyInstallPlan, + previewInstallPlan, + } = require('./lib/install-executor'); const { createInstallPlanFromRequest } = require('./lib/install/runtime'); const defaultConfigPath = options.configPath || options.languages.length > 0 ? null @@ -157,13 +160,14 @@ function main() { ...options, config, }); - const plan = createInstallPlanFromRequest(request, { + const rawPlan = createInstallPlanFromRequest(request, { projectRoot: process.cwd(), homeDir: process.env.HOME || os.homedir(), claudeRulesDir: process.env.CLAUDE_RULES_DIR || null, }); if (options.dryRun) { + const plan = previewInstallPlan(rawPlan); if (options.json) { console.log(JSON.stringify({ dryRun: true, plan }, null, 2)); } else { @@ -172,7 +176,7 @@ function main() { return; } - const result = applyInstallPlan(plan); + const result = applyInstallPlan(rawPlan); if (options.json) { console.log(JSON.stringify({ dryRun: false, result }, null, 2)); } else { diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index 85257c698..57100cb31 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -123,6 +123,11 @@ function applyInstallPlan(plan) { return applyPlan(plan); } +function previewInstallPlan(plan) { + const { previewInstallPlan: previewPlan } = require('./install/apply'); + return previewPlan(plan); +} + function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) { return { kind: 'copy-file', @@ -802,6 +807,7 @@ module.exports = { SUPPORTED_INSTALL_TARGETS, LEGACY_INSTALL_TARGETS, applyInstallPlan, + previewInstallPlan, createLegacyCompatInstallPlan, createManifestInstallPlan, createLegacyInstallPlan, diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 792ba9935..64f112a38 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -7,6 +7,10 @@ const { resolveInstallPlan, loadInstallManifests } = require('./install-manifest const { readInstallState, writeInstallState } = require('./install-state'); const { assertWithinTrustedRoot } = require('./path-safety'); const { createManifestInstallPlan } = require('./install-executor'); +const { + prepareClaudeSkillMigration, + removeLegacyClaudeSkillFiles, +} = require('./install/claude-skill-migration'); const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry'); const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist'); const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js'); @@ -951,6 +955,22 @@ function createRepairPlanFromRecord(record, context, options = {}) { }; } +function prepareRepairMigration(plan) { + const migration = prepareClaudeSkillMigration(plan); + return { + migration, + plan: { + ...plan, + operations: migration.appliedOperations, + statePreview: migration.finalState, + warnings: [ + ...(Array.isArray(plan.warnings) ? plan.warnings : []), + ...migration.warnings, + ], + }, + }; +} + function repairInstalledStates(options = {}) { const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT; const manifests = loadInstallManifests({ repoRoot }); @@ -988,9 +1008,10 @@ function repairInstalledStates(options = {}) { const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT); if (needsOpencodeBuild && options.dryRun) { - const desiredPlan = createRepairPlanFromRecord(record, context, { + const rawPlan = createRepairPlanFromRecord(record, context, { exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], }); + const { plan: desiredPlan } = prepareRepairMigration(rawPlan); const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations); const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]; @@ -1002,6 +1023,7 @@ function repairInstalledStates(options = {}) { repairedPaths: [], plannedRepairs, stateRefreshed: false, + warnings: desiredPlan.warnings, error: null }; } @@ -1021,7 +1043,11 @@ function repairInstalledStates(options = {}) { } } - const desiredPlan = createRepairPlanFromRecord(record, context); + const rawPlan = createRepairPlanFromRecord(record, context); + const { + migration, + plan: desiredPlan, + } = prepareRepairMigration(rawPlan); const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations); if (operationHealth.missingSource.length > 0) { @@ -1031,14 +1057,20 @@ function repairInstalledStates(options = {}) { installStatePath: record.installStatePath, repairedPaths: [], plannedRepairs: [], + warnings: desiredPlan.warnings, error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}` }; } const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; - const plannedRepairs = needsOpencodeBuild - ? [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)] - : repairOperations.map(operation => operation.destinationPath); + const legacyMigrationPaths = migration.legacyOperationsToRemove.map( + operation => operation.destinationPath + ); + const plannedRepairs = [...new Set([ + ...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []), + ...repairOperations.map(operation => operation.destinationPath), + ...legacyMigrationPaths, + ])]; if (options.dryRun) { return { @@ -1048,26 +1080,36 @@ function repairInstalledStates(options = {}) { repairedPaths: [], plannedRepairs, stateRefreshed: plannedRepairs.length === 0, + warnings: desiredPlan.warnings, error: null }; } + const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; + if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) { + writeInstallState(desiredPlan.installStatePath, migration.bridgeState); + } + if (repairOperations.length > 0) { for (const operation of repairOperations) { executeRepairOperation(context.repoRoot, operation, record.targetRoot); } - writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview); - } else { - writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview); } + if (hasLegacyMigration) { + removeLegacyClaudeSkillFiles(migration, desiredPlan.targetRoot); + } + writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview); return { adapter: record.adapter, - status: (repairOperations.length > 0 || needsOpencodeBuild) ? 'repaired' : 'ok', + status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration) + ? 'repaired' + : 'ok', installStatePath: record.installStatePath, repairedPaths: plannedRepairs, plannedRepairs: [], stateRefreshed: true, + warnings: desiredPlan.warnings, error: null }; } catch (error) { diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 5e927cbda..25521b626 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -144,6 +144,22 @@ function buildResolvedClaudeHooks(plan) { }; } +function previewInstallPlan(plan) { + const migration = prepareClaudeSkillMigration(plan); + return { + ...plan, + statePreview: migration.finalState, + plannedOperations: [...plan.operations], + operations: migration.appliedOperations, + skippedOperations: migration.skippedOperations, + warnings: [ + ...(Array.isArray(plan.warnings) ? plan.warnings : []), + ...migration.warnings, + ], + applied: false, + }; +} + function applyInstallPlan(plan, dependencies = {}) { const persistInstallState = dependencies.writeInstallState || writeInstallState; const migration = prepareClaudeSkillMigration(plan); @@ -157,15 +173,18 @@ function applyInstallPlan(plan, dependencies = {}) { const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; if (migration.requiresBridgeState) { - // Own planned flat skill files before the first copy. A later failure is - // retryable and uninstall can clean any partial flat writes. During legacy - // migration the bridge also retains every operation from the prior state. + // Own every operation that may be written during a flat-skill migration + // before the first copy. A later failure is retryable and uninstall can + // clean the entire partial install, including non-skill files. During + // legacy migration the bridge also retains the prior managed operations. persistInstallState(plan.installStatePath, migration.bridgeState); } for (const operation of appliedPlan.operations) { assertSafeClaudeSkillOperation(appliedPlan, operation); fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); + // The first check validates the existing chain; this second check validates + // every directory created by mkdirSync before any file is written. assertSafeClaudeSkillOperation(appliedPlan, operation); if (operation.kind === 'merge-json') { @@ -245,4 +264,5 @@ function applyInstallPlan(plan, dependencies = {}) { module.exports = { applyInstallPlan, + previewInstallPlan, }; diff --git a/scripts/lib/install/claude-skill-migration.js b/scripts/lib/install/claude-skill-migration.js index d12035680..0282385c7 100644 --- a/scripts/lib/install/claude-skill-migration.js +++ b/scripts/lib/install/claude-skill-migration.js @@ -61,18 +61,20 @@ function assertSafeSkillPath(targetPath, targetRoot, action) { let currentPath = resolvedRoot; for (const segment of relativePath.split(path.sep)) { currentPath = path.join(currentPath, segment); + let stats; try { - if (fs.lstatSync(currentPath).isSymbolicLink()) { - throw new Error( - `Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.` - ); - } + stats = fs.lstatSync(currentPath); } catch (error) { if (error && error.code === 'ENOENT') { break; } throw error; } + if (stats.isSymbolicLink()) { + throw new Error( + `Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.` + ); + } } if (pathExists(targetRoot)) { @@ -325,19 +327,9 @@ function prepareClaudeSkillMigration(plan) { )), ...retainedLegacyOperations, ]; - const appliedSkillDestinations = new Set( - [...currentGroups.values()] - .flat() - .map(({ operation }) => operation) - .filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath))) - .map(operation => comparablePath(operation.destinationPath)) - ); - const plannedFlatSkillOperations = plan.statePreview.operations.filter(operation => ( - appliedSkillDestinations.has(comparablePath(operation.destinationPath)) - )); const bridgeOperations = [ ...((previousState && previousState.operations) || []), - ...plannedFlatSkillOperations, + ...appliedOperations, ]; return { @@ -348,7 +340,7 @@ function prepareClaudeSkillMigration(plan) { bridgeState: buildState(plan.statePreview, bridgeOperations), finalState: buildState(plan.statePreview, finalOperations), legacyOperationsToRemove, - requiresBridgeState: plannedFlatSkillOperations.length > 0, + requiresBridgeState: appliedOperations.length > 0, }; } diff --git a/tests/lib/install-claude-skill-migration.test.js b/tests/lib/install-claude-skill-migration.test.js index e5ed2eb00..9d45db8f2 100644 --- a/tests/lib/install-claude-skill-migration.test.js +++ b/tests/lib/install-claude-skill-migration.test.js @@ -510,6 +510,129 @@ function runTests() { } })) passed++; else failed++; + if (test('tracks non-skill files written before a partial flat-skill install fails', () => { + const fixture = createFixture(); + try { + const ruleSourceRelativePath = path.join('rules', 'common', 'coding.md'); + const ruleSourcePath = path.join(fixture.sourceRoot, ruleSourceRelativePath); + const ruleDestinationPath = path.join( + fixture.targetRoot, + 'rules', + 'ecc', + 'common', + 'coding.md' + ); + fs.mkdirSync(path.dirname(ruleSourcePath), { recursive: true }); + fs.writeFileSync(ruleSourcePath, '# Managed rule\n'); + + const ruleOperation = createOperation( + 'workflow-quality', + fixture.sourceRoot, + ruleSourceRelativePath, + ruleDestinationPath + ); + const missingOperation = createOperation( + 'workflow-quality', + fixture.sourceRoot, + path.join('commands', 'missing.md'), + path.join(fixture.targetRoot, 'commands', 'missing.md') + ); + const operations = [ + fixture.operations[0], + ruleOperation, + missingOperation, + ]; + const partialPlan = { + ...fixture.plan, + operations, + statePreview: { + ...fixture.plan.statePreview, + operations: operations.map(operation => ({ ...operation })), + }, + }; + + assert.throws(() => applyInstallPlan(partialPlan), /ENOENT/); + assert.ok(fs.existsSync(ruleDestinationPath)); + + const bridgeState = readInstallState(fixture.installStatePath); + assert.ok(bridgeState.operations.some(operation => ( + operation.destinationPath === ruleDestinationPath + ))); + + const uninstall = runUninstall(fixture); + assert.strictEqual(uninstall.summary.errorCount, 0); + assert.ok(!fs.existsSync(ruleDestinationPath)); + } finally { + cleanup(fixture.tempDir); + } + })) passed++; else failed++; + + if (test('tracks partial non-skill writes when every flat skill is user-owned', () => { + const fixture = createFixture(); + try { + const userSkillPath = fixture.operations[0].destinationPath; + fs.mkdirSync(path.dirname(userSkillPath), { recursive: true }); + fs.writeFileSync(userSkillPath, '# User skill\n'); + + const ruleSourceRelativePath = path.join('rules', 'common', 'coding.md'); + const ruleSourcePath = path.join(fixture.sourceRoot, ruleSourceRelativePath); + const ruleDestinationPath = path.join( + fixture.targetRoot, + 'rules', + 'ecc', + 'common', + 'coding.md' + ); + fs.mkdirSync(path.dirname(ruleSourcePath), { recursive: true }); + fs.writeFileSync(ruleSourcePath, '# Managed rule\n'); + + const ruleOperation = createOperation( + 'workflow-quality', + fixture.sourceRoot, + ruleSourceRelativePath, + ruleDestinationPath + ); + const missingOperation = createOperation( + 'workflow-quality', + fixture.sourceRoot, + path.join('commands', 'missing.md'), + path.join(fixture.targetRoot, 'commands', 'missing.md') + ); + const operations = [ + ...fixture.operations, + ruleOperation, + missingOperation, + ]; + const partialPlan = { + ...fixture.plan, + operations, + statePreview: { + ...fixture.plan.statePreview, + operations: operations.map(operation => ({ ...operation })), + }, + }; + + assert.throws(() => applyInstallPlan(partialPlan), /ENOENT/); + assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User skill\n'); + assert.ok(fs.existsSync(ruleDestinationPath)); + + const bridgeState = readInstallState(fixture.installStatePath); + assert.ok(!bridgeState.operations.some(operation => ( + operation.destinationPath === userSkillPath + ))); + assert.ok(bridgeState.operations.some(operation => ( + operation.destinationPath === ruleDestinationPath + ))); + + const uninstall = runUninstall(fixture); + assert.strictEqual(uninstall.summary.errorCount, 0); + assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User skill\n'); + assert.ok(!fs.existsSync(ruleDestinationPath)); + } finally { + cleanup(fixture.tempDir); + } + })) passed++; else failed++; + if (test('keeps legacy files tracked when the bridge state write fails', () => { const fixture = createFixture(); try { @@ -567,7 +690,7 @@ function runTests() { ); assert.ok(fixture.operations.every(operation => fs.existsSync(operation.destinationPath))); assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath))); - } finally { + const bridgeState = readInstallState(fixture.installStatePath); assert.ok(fixture.operations.every(flatOperation => ( bridgeState.operations.some(operation => ( @@ -581,12 +704,14 @@ function runTests() { const uninstall = runUninstall(fixture); assert.strictEqual(uninstall.summary.errorCount, 0); assert.ok(fixture.operations.every(operation => !fs.existsSync(operation.destinationPath))); + } finally { cleanup(fixture.tempDir); } })) passed++; else failed++; if (test('rejects a flat skill symlink that escapes the Claude install root', () => { if (process.platform === 'win32') { + console.log(' ↷ skipped on Windows: symlink privileges vary'); return; } @@ -611,6 +736,7 @@ function runTests() { if (test('rejects a dangling destination symlink before copying a Claude skill file', () => { if (process.platform === 'win32') { + console.log(' ↷ skipped on Windows: symlink privileges vary'); return; } diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index c037c0604..bcdc9a359 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -634,6 +634,100 @@ function runTests() { } })) passed++; else failed++; + if (test('Claude repair and dry-run preserve user-owned flat skills during legacy migration', () => { + const homeDir = createTempDir('install-lifecycle-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json'); + const flatSkillPath = path.join(targetRoot, 'skills', 'tdd-workflow', 'SKILL.md'); + const legacySkillPath = path.join( + targetRoot, + 'skills', + 'ecc', + 'tdd-workflow', + 'SKILL.md' + ); + fs.mkdirSync(path.dirname(flatSkillPath), { recursive: true }); + fs.mkdirSync(path.dirname(legacySkillPath), { recursive: true }); + fs.writeFileSync(flatSkillPath, '# User-owned flat skill\n'); + fs.writeFileSync(legacySkillPath, '# Previously managed nested skill\n'); + + writeState(installStatePath, { + adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, + targetRoot, + installStatePath, + request: { + profile: null, + modules: ['workflow-quality'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['platform-configs', 'workflow-quality'], + skippedModules: [], + }, + operations: [{ + kind: 'copy-file', + moduleId: 'workflow-quality', + sourcePath: path.join(REPO_ROOT, 'skills', 'tdd-workflow', 'SKILL.md'), + sourceRelativePath: path.join('skills', 'tdd-workflow', 'SKILL.md'), + destinationPath: legacySkillPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + }], + source: { + repoVersion: CURRENT_PACKAGE_VERSION, + repoCommit: 'abc123', + manifestVersion: CURRENT_MANIFEST_VERSION, + }, + }); + + const dryRun = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + dryRun: true, + }); + assert.ok(!dryRun.results[0].plannedRepairs.includes(flatSkillPath)); + assert.ok(dryRun.results[0].warnings.some(warning => warning.includes('user-owned'))); + assert.strictEqual(fs.readFileSync(flatSkillPath, 'utf8'), '# User-owned flat skill\n'); + assert.strictEqual( + fs.readFileSync(legacySkillPath, 'utf8'), + '# Previously managed nested skill\n' + ); + + const repaired = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + assert.notStrictEqual(repaired.results[0].status, 'error'); + assert.ok(repaired.results[0].warnings.some(warning => warning.includes('user-owned'))); + assert.strictEqual(fs.readFileSync(flatSkillPath, 'utf8'), '# User-owned flat skill\n'); + assert.strictEqual( + fs.readFileSync(legacySkillPath, 'utf8'), + '# Previously managed nested skill\n' + ); + const repairedState = JSON.parse(fs.readFileSync(installStatePath, 'utf8')); + assert.ok(repairedState.operations.some(operation => ( + operation.destinationPath === legacySkillPath + ))); + assert.ok(!repairedState.operations.some(operation => ( + operation.destinationPath === flatSkillPath + ))); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('repair copies missing managed files from recorded source paths', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 9221c1173..6cfb049ec 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -468,7 +468,7 @@ function runTests() { assert.strictEqual(fs.readFileSync(userRulePath, 'utf8'), '# User custom rule\n'); assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n'); assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md'))); - assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'verification-loop', 'SKILL.md'))); const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json')); assert.ok(!state.operations.some(operation => ( operation.destinationPath.startsWith(path.join(claudeRoot, 'skills', 'tdd-workflow')) @@ -523,6 +523,46 @@ function runTests() { } })) passed++; else failed++; + if (test('dry-run reports the same user-owned Claude skill conflicts as apply', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const userSkillRoot = path.join( + homeDir, + '.claude', + 'skills', + 'tdd-workflow' + ); + const userSkillPath = path.join(userSkillRoot, 'SKILL.md'); + fs.mkdirSync(userSkillRoot, { recursive: true }); + fs.writeFileSync(userSkillPath, '# User custom skill\n'); + + const result = run( + ['--skills', 'tdd-workflow', '--dry-run', '--json'], + { cwd: projectDir, homeDir } + ); + assert.strictEqual(result.code, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + assert.strictEqual(payload.dryRun, true); + assert.ok(payload.plan.plannedOperations.length > 0); + assert.ok(payload.plan.skippedOperations.length > 0); + assert.ok(payload.plan.warnings.some(warning => warning.includes('user-owned'))); + assert.ok(payload.plan.skippedOperations.every(operation => ( + operation.destinationPath.startsWith(userSkillRoot) + ))); + assert.ok(!payload.plan.operations.some(operation => ( + operation.destinationPath.startsWith(userSkillRoot) + ))); + assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n'); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json'))); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('installs antigravity manifest profiles while skipping only unsupported modules', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-');