fix: make Claude skill migration recoverable

This commit is contained in:
Affaan Mustafa
2026-07-26 04:38:35 -04:00
parent fbd45bbd77
commit 4a0e34fa7c
9 changed files with 366 additions and 45 deletions
+6 -9
View File
@@ -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
+9 -5
View File
@@ -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 {
+6
View File
@@ -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,
+51 -9
View File
@@ -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) {
+23 -3
View File
@@ -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,
};
+9 -17
View File
@@ -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,
};
}
@@ -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;
}
+94
View File
@@ -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-');
+41 -1
View File
@@ -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-');