fix: flatten Claude skill installs

This commit is contained in:
Affaan Mustafa
2026-07-26 03:27:37 -04:00
parent ad8db87780
commit fbd45bbd77
15 changed files with 1209 additions and 78 deletions
+8
View File
@@ -307,6 +307,14 @@ npx ecc-install --profile minimal --target claude
This profile intentionally excludes `hooks-runtime`.
Claude manual installs place each skill directly under
`~/.claude/skills/<skill-name>/` (or `.claude/skills/<skill-name>/` for
`claude-project`) so Claude Code can discover it. When upgrading an older ECC
manual install, the installer migrates only nested `skills/ecc/` files recorded
in ECC install-state. If a flat skill directory is user-owned, ECC preserves it,
prints a conflict warning, and keeps any older managed copy tracked for a safe
uninstall instead of overwriting user files.
If you want the normal core profile but need hooks off, use:
```bash
+4 -4
View File
@@ -759,13 +759,13 @@ 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/ecc
cp -r .agents/skills/* ~/.claude/skills/ecc/
cp -r skills/search-first ~/.claude/skills/ecc/
mkdir -p ~/.claude/skills
cp -r .agents/skills/* ~/.claude/skills/
cp -r skills/search-first ~/.claude/skills/
# 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/ecc/
# cp -r skills/$s ~/.claude/skills/
# done
# Opcional: mantener compatibilidad con entradas slash durante la migración
+14 -4
View File
@@ -32,8 +32,8 @@ Usage: install.sh [--target <${LEGACY_INSTALL_TARGETS.join('|')}>] [--dry-run] [
install.sh [--dry-run] [--json] --config <path>
Targets:
claude (default) - Install ECC into ~/.claude/ with managed rules/skills under rules/ecc and skills/ecc
claude-project - Install ECC into ./.claude/ (per-project) with managed rules/skills under rules/ecc and skills/ecc
claude (default) - Install ECC into ~/.claude/ with managed rules under rules/ecc and flat skills under skills/
claude-project - Install ECC into ./.claude/ (per-project) with managed rules under rules/ecc and flat skills under skills/
cursor - Install rules, hooks, and bundled Cursor configs to ./.cursor/
antigravity - Install rules, workflows, skills, and agents to ./.agent/
codex - Install shared agents/config into ~/.codex/
@@ -102,7 +102,10 @@ function printHumanPlan(plan, dryRun) {
console.log(`Excluded modules: ${plan.excludedModuleIds.join(', ')}`);
}
}
console.log(`Operations: ${plan.operations.length}`);
console.log(`${dryRun ? 'Operations' : 'Applied operations'}: ${plan.operations.length}`);
if (!dryRun && Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) {
console.log(`Skipped operations: ${plan.skippedOperations.length}`);
}
if (plan.warnings.length > 0) {
console.log('\nWarnings:');
@@ -111,11 +114,18 @@ function printHumanPlan(plan, dryRun) {
}
}
console.log('\nPlanned file operations:');
console.log(`\n${dryRun ? 'Planned' : 'Applied'} file operations:`);
for (const operation of plan.operations) {
console.log(`- ${operation.sourceRelativePath} -> ${operation.destinationPath}`);
}
if (!dryRun && 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}`);
}
}
if (!dryRun) {
console.log(`\nDone. Install-state written to ${plan.installStatePath}`);
}
+1 -2
View File
@@ -27,14 +27,13 @@ function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
}
if (normalizedSourcePath === 'skills') {
return path.join(targetRoot, 'skills', CLAUDE_ECC_NAMESPACE);
return path.join(targetRoot, 'skills');
}
if (normalizedSourcePath.startsWith('skills/')) {
return path.join(
targetRoot,
'skills',
CLAUDE_ECC_NAMESPACE,
normalizedSourcePath.slice('skills/'.length)
);
}
@@ -27,14 +27,13 @@ function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
}
if (normalizedSourcePath === 'skills') {
return path.join(targetRoot, 'skills', CLAUDE_ECC_NAMESPACE);
return path.join(targetRoot, 'skills');
}
if (normalizedSourcePath.startsWith('skills/')) {
return path.join(
targetRoot,
'skills',
CLAUDE_ECC_NAMESPACE,
normalizedSourcePath.slice('skills/'.length)
);
}
+41 -11
View File
@@ -5,7 +5,12 @@ const path = require('path');
const { writeInstallState } = require('../install-state');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { buildInstallIndex, isNamespacedSource, rewriteRelativeLinks } = require('./link-rewrite');
const {
assertSafeClaudeSkillOperation,
prepareClaudeSkillMigration,
removeLegacyClaudeSkillFiles,
} = require('./claude-skill-migration');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
function isMarkdownPath(filePath) {
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
@@ -139,13 +144,29 @@ function buildResolvedClaudeHooks(plan) {
};
}
function applyInstallPlan(plan) {
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan);
function applyInstallPlan(plan, dependencies = {}) {
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const migration = prepareClaudeSkillMigration(plan);
const appliedPlan = {
...plan,
operations: migration.appliedOperations,
};
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(appliedPlan);
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
const linkIndex = buildLinkIndexForPlan(plan);
const linkIndex = buildLinkIndexForPlan(appliedPlan);
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
for (const operation of plan.operations) {
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.
persistInstallState(plan.installStatePath, migration.bridgeState);
}
for (const operation of appliedPlan.operations) {
assertSafeClaudeSkillOperation(appliedPlan, operation);
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
assertSafeClaudeSkillOperation(appliedPlan, operation);
if (operation.kind === 'merge-json') {
const payload = cloneJsonValue(operation.mergePayload);
@@ -174,16 +195,14 @@ function applyInstallPlan(plan) {
continue;
}
// Namespaced markdown (e.g. skills/<id> -> skills/ecc/<id>) needs its
// relative cross-directory links rewritten so they resolve after install
// (issue #2340). Files whose install path is unchanged (no namespace
// injected) and all non-markdown files stay on the byte-for-byte copy path.
// Markdown may reference files whose installed paths move, such as rules
// copied under rules/ecc. Rewrite only links that point at installed targets;
// untouched links and non-markdown files stay on the byte-for-byte path.
if (
linkIndex
&& operation.kind === 'copy-file'
&& operation.sourceRelativePath
&& isMarkdownPath(operation.destinationPath)
&& isNamespacedSource(operation.sourceRelativePath, linkIndex)
) {
const rewritten = rewriteRelativeLinks(
fs.readFileSync(operation.sourcePath, 'utf8'),
@@ -205,10 +224,21 @@ function applyInstallPlan(plan) {
);
}
writeInstallState(plan.installStatePath, plan.statePreview);
if (hasLegacyMigration) {
removeLegacyClaudeSkillFiles(migration, plan.targetRoot);
}
persistInstallState(plan.installStatePath, migration.finalState);
return {
...plan,
statePreview: migration.finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
],
applied: true,
};
}
@@ -0,0 +1,385 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const CLAUDE_TARGETS = new Set(['claude', 'claude-project']);
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
function normalizeSourceRelativePath(sourceRelativePath) {
const slashNormalized = String(sourceRelativePath || '').replace(/\\/g, '/');
const normalized = path.posix.normalize(slashNormalized).replace(/^\.\//, '');
if (
!normalized
|| normalized === '.'
|| normalized === '..'
|| normalized.startsWith('../')
|| path.posix.isAbsolute(normalized)
) {
return null;
}
return normalized;
}
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
function samePath(leftPath, rightPath) {
return comparablePath(leftPath) === comparablePath(rightPath);
}
function assertSafeSkillPath(targetPath, targetRoot, action) {
const resolvedRoot = path.resolve(targetRoot);
const resolvedTarget = path.resolve(targetPath);
const relativePath = path.relative(resolvedRoot, resolvedTarget);
if (
relativePath === ''
|| relativePath.startsWith('..')
|| path.isAbsolute(relativePath)
) {
throw new Error(
`Refusing to ${action} outside the install root: '${targetPath}' is not within '${targetRoot}'.`
);
}
let currentPath = resolvedRoot;
for (const segment of relativePath.split(path.sep)) {
currentPath = path.join(currentPath, segment);
try {
if (fs.lstatSync(currentPath).isSymbolicLink()) {
throw new Error(
`Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.`
);
}
} catch (error) {
if (error && error.code === 'ENOENT') {
break;
}
throw error;
}
}
if (pathExists(targetRoot)) {
assertWithinTrustedRoot(targetPath, targetRoot, action);
}
}
function describeClaudeSkillOperation(targetRoot, operation) {
if (!operation || operation.kind !== 'copy-file') {
return null;
}
const sourceRelativePath = normalizeSourceRelativePath(operation.sourceRelativePath);
if (!sourceRelativePath) {
return null;
}
const sourceParts = sourceRelativePath.split('/');
if (sourceParts[0] !== 'skills' || sourceParts.length < 3 || !sourceParts[1]) {
return null;
}
const skillName = sourceParts[1];
const relativeParts = sourceParts.slice(2);
const flatSkillRoot = path.join(targetRoot, 'skills', skillName);
const legacySkillRoot = path.join(targetRoot, 'skills', 'ecc', skillName);
return {
sourceKey: sourceRelativePath,
skillName,
flatSkillRoot,
flatDestinationPath: path.join(flatSkillRoot, ...relativeParts),
legacySkillRoot,
legacyDestinationPath: path.join(legacySkillRoot, ...relativeParts),
};
}
function assertSafeClaudeSkillOperation(plan, operation) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return;
}
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
return;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'install Claude skill'
);
}
function isManagedOperation(operation) {
return operation && operation.ownership === 'managed';
}
function uniqueOperations(operations) {
const seen = new Set();
return operations.filter(operation => {
const key = [
operation.kind,
normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath,
comparablePath(operation.destinationPath),
].join('\0');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function buildState(statePreview, operations) {
return {
...statePreview,
operations: uniqueOperations(operations).map(operation => ({ ...operation })),
};
}
function groupCurrentSkillOperations(plan) {
const groups = new Map();
for (const operation of plan.operations) {
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
continue;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'install Claude skill'
);
const current = groups.get(descriptor.flatSkillRoot) || [];
current.push({ operation, descriptor });
groups.set(descriptor.flatSkillRoot, current);
}
return groups;
}
function classifyPreviousOperations(plan, previousState) {
const flatByDestination = new Map();
const legacyBySource = new Map();
const legacyBySkillRoot = new Map();
for (const operation of (previousState && previousState.operations) || []) {
if (!isManagedOperation(operation)) {
continue;
}
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor) {
continue;
}
if (samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'inspect managed Claude skill'
);
flatByDestination.set(comparablePath(operation.destinationPath), operation);
continue;
}
if (!samePath(operation.destinationPath, descriptor.legacyDestinationPath)) {
continue;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'migrate managed Claude skill'
);
legacyBySource.set(descriptor.sourceKey, operation);
const current = legacyBySkillRoot.get(descriptor.legacySkillRoot) || [];
current.push({ operation, descriptor });
legacyBySkillRoot.set(descriptor.legacySkillRoot, current);
}
return {
flatByDestination,
legacyBySource,
legacyBySkillRoot,
};
}
function createConflictWarning(skillName, flatSkillRoot, retainsLegacy) {
const legacySuffix = retainsLegacy
? ' The existing ECC-managed nested copy was retained and remains tracked for uninstall.'
: '';
return `Skipped Claude skill '${skillName}' at ${flatSkillRoot}: the flat skill directory is user-owned because it is not recorded in ECC install-state.${legacySuffix}`;
}
function createFileConflictWarning(destinationPath, retainsLegacy) {
const legacySuffix = retainsLegacy
? ' The matching ECC-managed nested file was retained and remains tracked for uninstall.'
: '';
return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`;
}
function prepareClaudeSkillMigration(plan) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return {
enabled: false,
appliedOperations: [...plan.operations],
skippedOperations: [],
warnings: [],
bridgeState: plan.statePreview,
finalState: plan.statePreview,
legacyOperationsToRemove: [],
requiresBridgeState: false,
};
}
const previousState = pathExists(plan.installStatePath)
? readInstallState(plan.installStatePath)
: null;
const currentGroups = groupCurrentSkillOperations(plan);
const previous = classifyPreviousOperations(plan, previousState);
const skippedOperations = [];
const skippedDestinations = new Set();
const warnings = [];
const currentSourceKeys = new Set(
[...currentGroups.values()]
.flat()
.map(({ descriptor }) => descriptor.sourceKey)
);
const retainedLegacyOperations = new Set(
[...previous.legacyBySource.entries()]
.filter(([sourceKey]) => !currentSourceKeys.has(sourceKey))
.map(([_sourceKey, operation]) => operation)
);
for (const [flatSkillRoot, entries] of currentGroups) {
const hasManagedFlatFile = entries.some(({ operation }) => (
previous.flatByDestination.has(comparablePath(operation.destinationPath))
));
const legacyEntries = previous.legacyBySkillRoot.get(
entries[0].descriptor.legacySkillRoot
) || [];
if (pathExists(flatSkillRoot) && !hasManagedFlatFile) {
for (const { operation } of entries) {
skippedOperations.push(operation);
skippedDestinations.add(comparablePath(operation.destinationPath));
}
for (const { operation } of legacyEntries) {
retainedLegacyOperations.add(operation);
}
warnings.push(createConflictWarning(
entries[0].descriptor.skillName,
flatSkillRoot,
legacyEntries.length > 0
));
continue;
}
for (const { operation, descriptor } of entries) {
const destinationKey = comparablePath(operation.destinationPath);
const isPreviouslyManaged = previous.flatByDestination.has(destinationKey);
if (!pathExists(operation.destinationPath) || isPreviouslyManaged) {
continue;
}
skippedOperations.push(operation);
skippedDestinations.add(destinationKey);
const legacyOperation = previous.legacyBySource.get(descriptor.sourceKey);
if (legacyOperation) {
retainedLegacyOperations.add(legacyOperation);
}
warnings.push(createFileConflictWarning(
operation.destinationPath,
Boolean(legacyOperation)
));
}
}
const appliedOperations = plan.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
));
const legacyOperations = [...previous.legacyBySource.values()];
const legacyOperationsToRemove = legacyOperations.filter(operation => (
!retainedLegacyOperations.has(operation)
));
const finalOperations = [
...plan.statePreview.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
)),
...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,
];
return {
enabled: true,
appliedOperations,
skippedOperations,
warnings,
bridgeState: buildState(plan.statePreview, bridgeOperations),
finalState: buildState(plan.statePreview, finalOperations),
legacyOperationsToRemove,
requiresBridgeState: plannedFlatSkillOperations.length > 0,
};
}
function cleanupEmptyLegacyParents(filePath, targetRoot) {
const skillsRoot = path.join(targetRoot, 'skills');
let currentPath = path.dirname(filePath);
while (!samePath(currentPath, skillsRoot)) {
assertSafeSkillPath(currentPath, targetRoot, 'clean Claude skill migration');
if (!pathExists(currentPath) || fs.readdirSync(currentPath).length > 0) {
return;
}
fs.rmdirSync(currentPath);
currentPath = path.dirname(currentPath);
}
}
function removeLegacyClaudeSkillFiles(migration, targetRoot) {
for (const operation of migration.legacyOperationsToRemove) {
assertSafeSkillPath(
operation.destinationPath,
targetRoot,
'migrate managed Claude skill'
);
fs.rmSync(operation.destinationPath, { force: true });
cleanupEmptyLegacyParents(operation.destinationPath, targetRoot);
}
}
module.exports = {
assertSafeClaudeSkillOperation,
prepareClaudeSkillMigration,
removeLegacyClaudeSkillFiles,
};
+7 -9
View File
@@ -22,7 +22,7 @@ function stripTrailingSlash(value) {
// `fileMappings` is a list of { sourceRel, destRel } where both are paths
// relative to the repo root and the install root respectively. The directory
// map is derived by walking shared ancestors of each source/dest pair, which is
// exact for prefix-insertion namespacing (e.g. `skills/x` -> `skills/ecc/x`):
// exact for prefix-insertion namespacing (e.g. `rules/x` -> `rules/ecc/x`):
// the path suffix below the inserted segment is preserved, so ancestor `k`
// of the source maps to the dest with the matching number of trailing
// segments removed.
@@ -95,7 +95,7 @@ function resolveInstalledTarget(target, sourceDir, index) {
}
// True when the plan installs `sourceRel` at a different relative path than the
// source (i.e. a namespace segment was injected, e.g. skills/x -> skills/ecc/x).
// source (i.e. a namespace segment was injected, e.g. rules/x -> rules/ecc/x).
// Callers use this to keep non-namespaced files on the byte-for-byte copy path.
function isNamespacedSource(sourceRel, index) {
const normalizedSource = toPosix(sourceRel);
@@ -103,18 +103,16 @@ function isNamespacedSource(sourceRel, index) {
return Boolean(installedSource) && installedSource !== normalizedSource;
}
// Rewrite relative links in a single namespaced markdown file so they resolve
// to the file's installed location. Returns the content unchanged when the
// file itself was not namespaced or when no link needs adjustment. Pure: no IO.
// Rewrite relative links in a markdown file so they resolve to installed target
// locations. The source file may itself install at the same relative path; links
// can still need changes when their targets move, such as rules -> rules/ecc.
// Pure: no IO.
function rewriteRelativeLinks(content, options) {
const { sourceRel, index } = options || {};
const normalizedSource = toPosix(sourceRel);
const installedSource = index && index.byFile.get(normalizedSource);
// Only rewrite when the file's own install path gained/changed a namespace
// segment. If it lands at the same relative path, every link recomputes to
// itself, so there is nothing to do.
if (!installedSource || installedSource === normalizedSource) {
if (!installedSource) {
return content;
}
@@ -0,0 +1,645 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
const { uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function createOperation(moduleId, sourceRoot, sourceRelativePath, destinationPath) {
return {
kind: 'copy-file',
moduleId,
sourcePath: path.join(sourceRoot, sourceRelativePath),
sourceRelativePath,
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
};
}
function createFixture(options = {}) {
const tempDir = createTempDir('claude-skill-migration-');
const homeDir = path.join(tempDir, 'home');
const projectRoot = path.join(tempDir, 'project');
const sourceRoot = path.join(tempDir, 'source');
const target = options.target || 'claude';
const targetRoot = target === 'claude'
? path.join(homeDir, '.claude')
: path.join(projectRoot, '.claude');
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
const skillFiles = options.skillFiles || {
'SKILL.md': '# Current ECC skill\n',
'references/guide.md': '# Current ECC guide\n',
};
for (const [relativePath, content] of Object.entries(skillFiles)) {
const sourcePath = path.join(sourceRoot, 'skills', 'demo-skill', relativePath);
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
fs.writeFileSync(sourcePath, content);
}
const operations = Object.keys(skillFiles).map(relativePath => createOperation(
'workflow-quality',
sourceRoot,
path.join('skills', 'demo-skill', relativePath),
path.join(targetRoot, 'skills', 'demo-skill', relativePath)
));
const statePreview = {
schemaVersion: 'ecc.install.v1',
installedAt: new Date().toISOString(),
target: {
id: target === 'claude' ? 'claude-home' : 'claude-project',
target,
kind: target === 'claude' ? 'home' : 'project',
root: targetRoot,
installStatePath,
},
request: {
profile: null,
modules: ['workflow-quality'],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: ['workflow-quality'],
skippedModules: [],
},
source: {
repoVersion: null,
repoCommit: null,
manifestVersion: 1,
},
operations: operations.map(operation => ({ ...operation })),
};
return {
tempDir,
homeDir,
projectRoot,
sourceRoot,
target,
targetRoot,
installStatePath,
operations,
plan: {
mode: 'manifest',
target,
adapter: {
id: target === 'claude' ? 'claude-home' : 'claude-project',
target,
kind: target === 'claude' ? 'home' : 'project',
},
targetRoot,
installRoot: targetRoot,
installStatePath,
operations,
statePreview,
warnings: [],
},
};
}
function legacyDestinationPath(targetRoot, operation) {
const sourceParts = operation.sourceRelativePath.split(path.sep);
return path.join(targetRoot, 'skills', 'ecc', ...sourceParts.slice(1));
}
function seedLegacyInstall(fixture, options = {}) {
const legacyOperations = fixture.operations.map((operation, index) => {
const destinationPath = legacyDestinationPath(fixture.targetRoot, operation);
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, `# Legacy managed file ${index}\n`);
return {
...operation,
sourceRelativePath: options.windowsSourcePaths
? operation.sourceRelativePath.split(path.sep).join('\\')
: operation.sourceRelativePath,
destinationPath,
};
});
writeInstallState(fixture.installStatePath, {
...fixture.plan.statePreview,
operations: legacyOperations,
});
return legacyOperations;
}
function runUninstall(fixture) {
return uninstallInstalledStates({
homeDir: fixture.homeDir,
projectRoot: fixture.projectRoot,
targets: [fixture.target],
});
}
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.stack || error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing Claude flat-skill migration ===\n');
let passed = 0;
let failed = 0;
for (const target of ['claude', 'claude-project']) {
if (test(`migrates state-managed nested skills for ${target} without deleting untracked files`, () => {
const fixture = createFixture({ target });
try {
const legacyOperations = seedLegacyInstall(fixture, {
windowsSourcePaths: target === 'claude-project',
});
const untrackedPath = path.join(
fixture.targetRoot,
'skills',
'ecc',
'demo-skill',
'user-notes.md'
);
fs.writeFileSync(untrackedPath, '# User notes\n');
applyInstallPlan(fixture.plan);
for (const operation of fixture.operations) {
assert.strictEqual(
fs.readFileSync(operation.destinationPath, 'utf8'),
fs.readFileSync(operation.sourcePath, 'utf8')
);
}
for (const operation of legacyOperations) {
assert.ok(!fs.existsSync(operation.destinationPath), operation.destinationPath);
}
assert.strictEqual(fs.readFileSync(untrackedPath, 'utf8'), '# User notes\n');
const state = readInstallState(fixture.installStatePath);
assert.ok(state.operations.some(operation => (
operation.destinationPath === fixture.operations[0].destinationPath
)));
assert.ok(!state.operations.some(operation => (
operation.destinationPath.includes(path.join('skills', 'ecc', 'demo-skill'))
)));
const rerun = applyInstallPlan(fixture.plan);
assert.deepStrictEqual(rerun.skippedOperations, []);
assert.strictEqual(fs.readFileSync(untrackedPath, 'utf8'), '# User notes\n');
const uninstall = runUninstall(fixture);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.ok(!fs.existsSync(fixture.operations[0].destinationPath));
assert.strictEqual(fs.readFileSync(untrackedPath, 'utf8'), '# User notes\n');
} finally {
cleanup(fixture.tempDir);
}
})) passed++; else failed++;
}
if (test('selective migration preserves unrelated legacy skills and uninstall ownership', () => {
const fixture = createFixture();
try {
const legacyOperations = seedLegacyInstall(fixture);
const otherSourceRelativePath = path.join('skills', 'other-skill', 'SKILL.md');
const otherSourcePath = path.join(fixture.sourceRoot, otherSourceRelativePath);
const otherLegacyPath = path.join(
fixture.targetRoot,
'skills',
'ecc',
'other-skill',
'SKILL.md'
);
fs.mkdirSync(path.dirname(otherSourcePath), { recursive: true });
fs.mkdirSync(path.dirname(otherLegacyPath), { recursive: true });
fs.writeFileSync(otherSourcePath, '# Other source\n');
fs.writeFileSync(otherLegacyPath, '# Other legacy managed skill\n');
const otherLegacyOperation = createOperation(
'other-module',
fixture.sourceRoot,
otherSourceRelativePath,
otherLegacyPath
);
writeInstallState(fixture.installStatePath, {
...fixture.plan.statePreview,
operations: [...legacyOperations, otherLegacyOperation],
});
applyInstallPlan(fixture.plan);
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
assert.strictEqual(
fs.readFileSync(otherLegacyPath, 'utf8'),
'# Other legacy managed skill\n'
);
const state = readInstallState(fixture.installStatePath);
assert.ok(state.operations.some(operation => (
operation.destinationPath === otherLegacyPath
)));
const uninstall = runUninstall(fixture);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.ok(!fs.existsSync(otherLegacyPath));
} finally {
cleanup(fixture.tempDir);
}
})) passed++; else failed++;
if (test('reruns a completed migration idempotently and remains uninstallable', () => {
const fixture = createFixture();
try {
const legacyOperations = seedLegacyInstall(fixture);
applyInstallPlan(fixture.plan);
const stateAfterMigration = readInstallState(fixture.installStatePath);
const rerun = applyInstallPlan(fixture.plan);
const stateAfterRerun = readInstallState(fixture.installStatePath);
assert.deepStrictEqual(rerun.skippedOperations, []);
assert.ok(!rerun.warnings.some(warning => (
warning.includes('user-owned') || warning.includes('nested copy')
)));
assert.deepStrictEqual(stateAfterRerun, stateAfterMigration);
assert.ok(fixture.operations.every(operation => (
fs.readFileSync(operation.destinationPath, 'utf8')
=== fs.readFileSync(operation.sourcePath, 'utf8')
)));
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
assert.ok(!fs.existsSync(path.join(fixture.targetRoot, 'skills', 'ecc')));
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('preserves a user-owned flat skill and keeps legacy ownership for uninstall', () => {
const fixture = createFixture();
try {
const legacyOperations = seedLegacyInstall(fixture);
const userSkillPath = fixture.operations[0].destinationPath;
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
fs.writeFileSync(userSkillPath, '# User-owned flat skill\n');
const result = applyInstallPlan(fixture.plan);
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User-owned flat skill\n');
assert.ok(legacyOperations.every(operation => fs.existsSync(operation.destinationPath)));
assert.ok(result.warnings.some(warning => (
warning.includes('demo-skill') && warning.includes('user-owned')
)), JSON.stringify(result.warnings));
assert.strictEqual(result.operations.length, 0);
assert.strictEqual(result.skippedOperations.length, fixture.operations.length);
const state = readInstallState(fixture.installStatePath);
assert.ok(legacyOperations.every(legacyOperation => (
state.operations.some(operation => operation.destinationPath === legacyOperation.destinationPath)
)));
assert.ok(!state.operations.some(operation => (
operation.destinationPath === fixture.operations[0].destinationPath
)));
const uninstall = runUninstall(fixture);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User-owned flat skill\n');
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
} finally {
cleanup(fixture.tempDir);
}
})) passed++; else failed++;
if (test('does not claim or merge into a user-owned flat skill on first install', () => {
const fixture = createFixture();
try {
const userSkillPath = fixture.operations[0].destinationPath;
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
fs.writeFileSync(userSkillPath, '# User-owned flat skill\n');
const result = applyInstallPlan(fixture.plan);
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User-owned flat skill\n');
assert.ok(!fs.existsSync(fixture.operations[1].destinationPath));
assert.ok(result.warnings.some(warning => warning.includes('user-owned')));
assert.strictEqual(result.operations.length, 0);
assert.strictEqual(result.skippedOperations.length, fixture.operations.length);
assert.deepStrictEqual(readInstallState(fixture.installStatePath).operations, []);
const uninstall = runUninstall(fixture);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User-owned flat skill\n');
} finally {
cleanup(fixture.tempDir);
}
})) passed++; else failed++;
if (test('updates recorded flat files but preserves conflicting unrecorded files', () => {
const initial = createFixture({
skillFiles: {
'SKILL.md': '# Initial ECC skill\n',
},
});
let expanded;
try {
applyInstallPlan(initial.plan);
expanded = createFixture({
skillFiles: {
'SKILL.md': '# Updated ECC skill\n',
'references/guide.md': '# ECC guide\n',
'references/new.md': '# New managed file\n',
},
});
const expandedOriginalTargetRoot = expanded.targetRoot;
expanded.homeDir = initial.homeDir;
expanded.projectRoot = initial.projectRoot;
expanded.targetRoot = initial.targetRoot;
expanded.installStatePath = initial.installStatePath;
expanded.operations = expanded.operations.map(operation => ({
...operation,
destinationPath: path.join(
initial.targetRoot,
path.relative(expandedOriginalTargetRoot, operation.destinationPath)
),
}));
expanded.plan = {
...expanded.plan,
targetRoot: initial.targetRoot,
installRoot: initial.targetRoot,
installStatePath: initial.installStatePath,
operations: expanded.operations,
statePreview: {
...expanded.plan.statePreview,
target: {
...expanded.plan.statePreview.target,
root: initial.targetRoot,
installStatePath: initial.installStatePath,
},
operations: expanded.operations,
},
};
const userGuidePath = expanded.operations[1].destinationPath;
fs.mkdirSync(path.dirname(userGuidePath), { recursive: true });
fs.writeFileSync(userGuidePath, '# User guide\n');
const result = applyInstallPlan(expanded.plan);
assert.strictEqual(
fs.readFileSync(expanded.operations[0].destinationPath, 'utf8'),
'# Updated ECC skill\n'
);
assert.strictEqual(fs.readFileSync(userGuidePath, 'utf8'), '# User guide\n');
assert.strictEqual(
fs.readFileSync(expanded.operations[2].destinationPath, 'utf8'),
'# New managed file\n'
);
assert.ok(result.warnings.some(warning => warning.includes('guide.md')));
const state = readInstallState(initial.installStatePath);
assert.ok(state.operations.some(operation => (
operation.destinationPath === expanded.operations[0].destinationPath
)));
assert.ok(!state.operations.some(operation => (
operation.destinationPath === userGuidePath
)));
assert.ok(state.operations.some(operation => (
operation.destinationPath === expanded.operations[2].destinationPath
)));
} finally {
cleanup(initial.tempDir);
if (expanded) {
cleanup(expanded.tempDir);
}
}
})) passed++; else failed++;
if (test('tracks a partial migration so retry and uninstall remain safe', () => {
const fixture = createFixture();
try {
const legacyOperations = seedLegacyInstall(fixture);
const missingSourcePlan = {
...fixture.plan,
operations: fixture.operations.map((operation, index) => (
index === 1
? { ...operation, sourcePath: path.join(fixture.sourceRoot, 'missing.md') }
: operation
)),
};
assert.throws(() => applyInstallPlan(missingSourcePlan), /ENOENT/);
assert.ok(legacyOperations.every(operation => fs.existsSync(operation.destinationPath)));
assert.ok(fs.existsSync(fixture.operations[0].destinationPath));
assert.ok(!fs.existsSync(fixture.operations[1].destinationPath));
const bridgeState = readInstallState(fixture.installStatePath);
assert.ok(legacyOperations.every(legacyOperation => (
bridgeState.operations.some(operation => (
operation.destinationPath === legacyOperation.destinationPath
))
)));
assert.ok(fixture.operations.every(flatOperation => (
bridgeState.operations.some(operation => (
operation.destinationPath === flatOperation.destinationPath
))
)));
const retry = applyInstallPlan(fixture.plan);
assert.deepStrictEqual(retry.skippedOperations, []);
assert.ok(fixture.operations.every(operation => fs.existsSync(operation.destinationPath)));
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
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('tracks a partial first install so retry does not misclassify it as user-owned', () => {
const fixture = createFixture();
try {
const missingSourcePlan = {
...fixture.plan,
operations: fixture.operations.map((operation, index) => (
index === 1
? { ...operation, sourcePath: path.join(fixture.sourceRoot, 'missing.md') }
: operation
)),
};
assert.throws(() => applyInstallPlan(missingSourcePlan), /ENOENT/);
assert.ok(fs.existsSync(fixture.operations[0].destinationPath));
assert.ok(!fs.existsSync(fixture.operations[1].destinationPath));
const bridgeState = readInstallState(fixture.installStatePath);
assert.ok(fixture.operations.every(flatOperation => (
bridgeState.operations.some(operation => (
operation.destinationPath === flatOperation.destinationPath
))
)));
const retry = applyInstallPlan(fixture.plan);
assert.deepStrictEqual(retry.skippedOperations, []);
assert.ok(!retry.warnings.some(warning => warning.includes('user-owned')));
assert.ok(fixture.operations.every(operation => fs.existsSync(operation.destinationPath)));
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('keeps legacy files tracked when the bridge state write fails', () => {
const fixture = createFixture();
try {
const legacyOperations = seedLegacyInstall(fixture);
const failingStateWriter = filePath => {
assert.strictEqual(
path.resolve(filePath),
path.resolve(fixture.installStatePath)
);
throw new Error('injected install-state write failure');
};
assert.throws(
() => applyInstallPlan(fixture.plan, { writeInstallState: failingStateWriter }),
/injected install-state write failure/
);
assert.ok(legacyOperations.every(operation => fs.existsSync(operation.destinationPath)));
assert.ok(fixture.operations.every(operation => !fs.existsSync(operation.destinationPath)));
const state = readInstallState(fixture.installStatePath);
assert.ok(state.operations.every(operation => (
operation.destinationPath.includes(path.join('skills', 'ecc', 'demo-skill'))
)));
const retry = applyInstallPlan(fixture.plan);
assert.deepStrictEqual(retry.skippedOperations, []);
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('keeps both layouts represented if the final state write fails', () => {
const fixture = createFixture();
let stateWriteCount = 0;
try {
const legacyOperations = seedLegacyInstall(fixture);
const failFinalStateWrite = (filePath, state) => {
assert.strictEqual(
path.resolve(filePath),
path.resolve(fixture.installStatePath)
);
stateWriteCount += 1;
if (stateWriteCount === 2) {
throw new Error('injected final install-state write failure');
}
return writeInstallState(fixture.installStatePath, state);
};
assert.throws(
() => applyInstallPlan(fixture.plan, { writeInstallState: failFinalStateWrite }),
/injected final install-state write failure/
);
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 => (
operation.destinationPath === flatOperation.destinationPath
))
)));
assert.ok(bridgeState.operations.some(operation => (
operation.destinationPath.includes(path.join('skills', 'ecc', 'demo-skill'))
)));
const uninstall = runUninstall(fixture);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.ok(fixture.operations.every(operation => !fs.existsSync(operation.destinationPath)));
cleanup(fixture.tempDir);
}
})) passed++; else failed++;
if (test('rejects a flat skill symlink that escapes the Claude install root', () => {
if (process.platform === 'win32') {
return;
}
const fixture = createFixture();
try {
const outsideRoot = path.join(fixture.tempDir, 'outside');
fs.mkdirSync(outsideRoot, { recursive: true });
const flatSkillRoot = path.join(fixture.targetRoot, 'skills', 'demo-skill');
fs.mkdirSync(path.dirname(flatSkillRoot), { recursive: true });
fs.symlinkSync(outsideRoot, flatSkillRoot, 'dir');
assert.throws(
() => applyInstallPlan(fixture.plan),
/symlinked Claude skill path/
);
assert.deepStrictEqual(fs.readdirSync(outsideRoot), []);
assert.ok(!fs.existsSync(fixture.installStatePath));
} finally {
cleanup(fixture.tempDir);
}
})) passed++; else failed++;
if (test('rejects a dangling destination symlink before copying a Claude skill file', () => {
if (process.platform === 'win32') {
return;
}
const fixture = createFixture({
skillFiles: {
'SKILL.md': '# Current ECC skill\n',
},
});
try {
const outsideRoot = path.join(fixture.tempDir, 'outside');
const outsideTarget = path.join(outsideRoot, 'not-created.md');
fs.mkdirSync(outsideRoot, { recursive: true });
fs.mkdirSync(path.dirname(fixture.operations[0].destinationPath), { recursive: true });
fs.symlinkSync(outsideTarget, fixture.operations[0].destinationPath, 'file');
assert.strictEqual(fs.existsSync(fixture.operations[0].destinationPath), false);
assert.throws(
() => applyInstallPlan(fixture.plan),
/symlinked Claude skill path/
);
assert.ok(!fs.existsSync(outsideTarget));
assert.ok(!fs.existsSync(fixture.installStatePath));
} finally {
cleanup(fixture.tempDir);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
+2 -2
View File
@@ -362,7 +362,7 @@ function runTests() {
)));
assert.ok(plan.operations.some(operation => (
operation.sourceRelativePath === path.join('skills', 'demo', 'SKILL.md')
&& operation.destinationPath === path.join(homeDir, '.claude', 'skills', 'ecc', 'demo', 'SKILL.md')
&& operation.destinationPath === path.join(homeDir, '.claude', 'skills', 'demo', 'SKILL.md')
)));
assert.deepStrictEqual(plan.warnings, ['fixture warning']);
assert.strictEqual(plan.statePreview.request.profile, 'minimal');
@@ -416,7 +416,7 @@ function runTests() {
assert.strictEqual(applied.applied, true);
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'rules', 'ecc', 'common', 'coding-style.md')));
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'demo', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'demo', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'src', 'app.js')));
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'standalone.txt')));
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'plugin.json')));
+26 -19
View File
@@ -17,13 +17,13 @@ const { createManifestInstallPlan } = require('../../scripts/lib/install-executo
const REPO_ROOT = path.resolve(__dirname, '..', '..');
// A claude-style namespace placement: skills/<id> -> skills/ecc/<id> and
// A claude-style namespace placement: skills/<id> -> skills/<id> and
// rules/<x> -> rules/ecc/<x>. Mirrors what the real adapter emits.
function claudeNamespaceMappings() {
return [
{ sourceRel: 'skills/react-patterns/SKILL.md', destRel: 'skills/ecc/react-patterns/SKILL.md' },
{ sourceRel: 'skills/react-patterns/other.md', destRel: 'skills/ecc/react-patterns/other.md' },
{ sourceRel: 'skills/react-patterns/sub/NOTE.md', destRel: 'skills/ecc/react-patterns/sub/NOTE.md' },
{ sourceRel: 'skills/react-patterns/SKILL.md', destRel: 'skills/react-patterns/SKILL.md' },
{ sourceRel: 'skills/react-patterns/other.md', destRel: 'skills/react-patterns/other.md' },
{ sourceRel: 'skills/react-patterns/sub/NOTE.md', destRel: 'skills/react-patterns/sub/NOTE.md' },
{ sourceRel: 'rules/react/hooks.md', destRel: 'rules/ecc/react/hooks.md' },
{ sourceRel: 'rules/react/testing.md', destRel: 'rules/ecc/react/testing.md' },
{ sourceRel: 'rules/react/coding-style.md', destRel: 'rules/ecc/react/coding-style.md' },
@@ -64,17 +64,17 @@ function runTests() {
for (const skill of ['react-patterns', 'react-performance', 'react-testing']) {
if (test(`rewrites ../../rules file link for ${skill}`, () => {
const idx = buildInstallIndex([
{ sourceRel: `skills/${skill}/SKILL.md`, destRel: `skills/ecc/${skill}/SKILL.md` },
{ sourceRel: `skills/${skill}/SKILL.md`, destRel: `skills/${skill}/SKILL.md` },
{ sourceRel: 'rules/react/hooks.md', destRel: 'rules/ecc/react/hooks.md' },
]);
const before = 'See [rules](../../rules/react/hooks.md) for details.';
const after = rewriteRelativeLinks(before, { sourceRel: `skills/${skill}/SKILL.md`, index: idx });
assert.notStrictEqual(after, before, 'rewrite must change the broken link (not vacuous)');
assert.ok(
after.includes('](../../../rules/ecc/react/hooks.md)'),
after.includes('](../../rules/ecc/react/hooks.md)'),
`expected corrected link, got: ${after}`
);
assert.ok(!after.includes('](../../rules/'), 'broken depth must be gone');
assert.ok(!after.includes('](../../rules/react/'), 'un-namespaced rules link must be gone');
})) passed++; else failed++;
}
@@ -82,7 +82,7 @@ function runTests() {
const before = '- Rules: [rules/react/](../../rules/react/)';
const after = rewriteRelativeLinks(before, { sourceRel: 'skills/react-patterns/SKILL.md', index });
assert.notStrictEqual(after, before);
assert.ok(after.includes('](../../../rules/ecc/react/)'), `got: ${after}`);
assert.ok(after.includes('](../../rules/ecc/react/)'), `got: ${after}`);
})) passed++; else failed++;
if (test('leaves an intra-skill sibling link unchanged', () => {
@@ -111,7 +111,7 @@ function runTests() {
if (test('preserves a #fragment on a rewritten link', () => {
const before = '[hooks](../../rules/react/hooks.md#use-effect)';
const after = rewriteRelativeLinks(before, { sourceRel: 'skills/react-patterns/SKILL.md', index });
assert.ok(after.includes('](../../../rules/ecc/react/hooks.md#use-effect)'), `got: ${after}`);
assert.ok(after.includes('](../../rules/ecc/react/hooks.md#use-effect)'), `got: ${after}`);
})) passed++; else failed++;
if (test('does not rewrite links inside fenced code blocks', () => {
@@ -123,16 +123,16 @@ function runTests() {
].join('\n');
const after = rewriteRelativeLinks(before, { sourceRel: 'skills/react-patterns/SKILL.md', index });
assert.ok(after.includes('[code](../../rules/react/hooks.md)'), 'code-fence link must be untouched');
assert.ok(after.includes('[prose](../../../rules/ecc/react/hooks.md)'), 'prose link must be rewritten');
assert.ok(after.includes('[prose](../../rules/ecc/react/hooks.md)'), 'prose link must be rewritten');
})) passed++; else failed++;
if (test('computes depth from path math for a nested skill file', () => {
// skills/react-patterns/sub/NOTE.md -> skills/ecc/react-patterns/sub/NOTE.md
// skills/react-patterns/sub/NOTE.md -> skills/react-patterns/sub/NOTE.md
// Source link is ../../../rules/react/hooks.md (3 up from sub/).
const before = '[r](../../../rules/react/hooks.md)';
const after = rewriteRelativeLinks(before, { sourceRel: 'skills/react-patterns/sub/NOTE.md', index });
assert.notStrictEqual(after, before, 'nested depth must be recomputed, not hardcoded');
assert.ok(after.includes('](../../../../rules/ecc/react/hooks.md)'), `got: ${after}`);
assert.ok(after.includes('](../../../rules/ecc/react/hooks.md)'), `got: ${after}`);
})) passed++; else failed++;
if (test('is a no-op for a non-namespacing (identity) placement', () => {
@@ -148,12 +148,16 @@ function runTests() {
assert.strictEqual(after, before);
})) passed++; else failed++;
// Guards the apply-layer gate: only namespaced files leave the byte-copy
// path, so non-namespaced markdown is still copied verbatim.
if (test('isNamespacedSource flags only files whose install path changed', () => {
// Guards the low-level namespace detector for callers that need to distinguish
// identity copies from prefix-injected placements.
if (test('isNamespacedSource flags only files whose own install path changed', () => {
assert.strictEqual(
isNamespacedSource('skills/react-patterns/SKILL.md', index), true,
'a namespaced skill file must be flagged'
isNamespacedSource('rules/react/hooks.md', index), true,
'a namespaced rule file must be flagged'
);
assert.strictEqual(
isNamespacedSource('skills/react-patterns/SKILL.md', index), false,
'a flat-installed skill file must not be flagged'
);
const identity = buildInstallIndex(identityMappings());
assert.strictEqual(
@@ -201,13 +205,16 @@ function runTests() {
const content = fs.readFileSync(path.join(REPO_ROOT, sourceRel), 'utf8');
assert.ok(content.includes('](../../rules/'), `${sourceRel} should have a broken link pre-fix`);
const rewritten = rewriteRelativeLinks(content, { sourceRel, index: realIndex });
assert.ok(!rewritten.includes('](../../rules/'), `${sourceRel} still has the broken depth`);
assert.ok(
!rewritten.includes('](../../rules/react/'),
`${sourceRel} still links to un-namespaced rules`
);
// Only links we actually changed are validated here; cross-skill links to
// skills outside this module subset are legitimately left untouched.
const before = extractLinks(content);
const after = extractLinks(rewritten);
const installedSkillDir = path.posix.dirname(`skills/ecc/${skill}/SKILL.md`);
const installedSkillDir = path.posix.dirname(`skills/${skill}/SKILL.md`);
for (let i = 0; i < after.length; i += 1) {
if (after[i] === before[i]) {
continue;
+6 -6
View File
@@ -71,7 +71,7 @@ function runTests() {
assert.strictEqual(statePath, path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
})) passed++; else failed++;
if (test('plans claude rules and skills under ECC-managed subdirectories', () => {
if (test('plans namespaced Claude rules and flat discoverable skills', () => {
const repoRoot = path.join(__dirname, '..', '..');
const homeDir = '/Users/example';
@@ -101,9 +101,9 @@ function runTests() {
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'skills/tdd-workflow'
&& operation.destinationPath === path.join(homeDir, '.claude', 'skills', 'ecc', 'tdd-workflow')
&& operation.destinationPath === path.join(homeDir, '.claude', 'skills', 'tdd-workflow')
)),
'Should install bundled Claude skills under skills/ecc'
'Should install bundled Claude skills under skills'
);
})) passed++; else failed++;
@@ -884,7 +884,7 @@ function runTests() {
assert.ok(byTarget.supports('claude-project'));
})) passed++; else failed++;
if (test('plans claude-project rules and skills under project-scope ECC-managed subdirectories', () => {
if (test('plans project-scoped namespaced Claude rules and flat skills', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
@@ -917,9 +917,9 @@ function runTests() {
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'skills/tdd-workflow'
&& operation.destinationPath === path.join(projectRoot, '.claude', 'skills', 'ecc', 'tdd-workflow')
&& operation.destinationPath === path.join(projectRoot, '.claude', 'skills', 'tdd-workflow')
)),
'Should install bundled skills under project-scope skills/ecc'
'Should install bundled skills under project-scope skills'
);
})) passed++; else failed++;
+1 -1
View File
@@ -205,7 +205,7 @@ function runTests() {
'Should install Japanese README under docs/ja-JP'
);
assert.ok(
!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'configure-ecc', 'SKILL.md')),
!fs.existsSync(path.join(claudeRoot, 'skills', 'configure-ecc', 'SKILL.md')),
'Locale-only install should not install English skills'
);
+6 -6
View File
@@ -658,7 +658,7 @@ function runTests() {
const claudeRoot = path.join(homeDir, '.claude');
// Security skill should be installed (from --with)
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'security-review', 'SKILL.md')),
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'security-review', 'SKILL.md')),
'Should install security-review skill from --with');
// Core profile modules should be installed
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')),
@@ -697,12 +697,12 @@ function runTests() {
const claudeRoot = path.join(homeDir, '.claude');
// Orchestration skills should NOT be installed (from --without)
assert.ok(!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')),
assert.ok(!fs.existsSync(path.join(claudeRoot, 'skills', 'dmux-workflows', 'SKILL.md')),
'Should not install orchestration skills');
// Developer profile base modules should be installed
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')),
'Should install core rules');
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')),
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')),
'Should install workflow skills');
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
@@ -735,7 +735,7 @@ function runTests() {
const claudeRoot = path.join(homeDir, '.claude');
// framework-language skill (from lang:typescript) should be installed
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'coding-standards', 'SKILL.md')),
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')),
'Should install framework-language skills');
// Its dependencies should be installed
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')),
@@ -771,11 +771,11 @@ function runTests() {
const claudeRoot = path.join(homeDir, '.claude');
assert.ok(
fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'continuous-learning-v2', 'SKILL.md')),
fs.existsSync(path.join(claudeRoot, 'skills', 'continuous-learning-v2', 'SKILL.md')),
'Should install continuous-learning-v2'
);
assert.ok(
!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')),
!fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')),
'Should not install unrelated workflow-quality skills'
);
+62 -12
View File
@@ -100,8 +100,8 @@ function runTests() {
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'utils.js')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'coding-standards', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
const statePath = path.join(homeDir, '.claude', 'ecc', 'install-state.json');
@@ -133,23 +133,23 @@ function runTests() {
assert.strictEqual(result.code, 0, result.stderr);
const claudeRoot = path.join(homeDir, '.claude');
const skillPath = path.join(claudeRoot, 'skills', 'ecc', 'react-patterns', 'SKILL.md');
const skillPath = path.join(claudeRoot, 'skills', 'react-patterns', 'SKILL.md');
assert.ok(fs.existsSync(skillPath), 'react-patterns SKILL.md should be installed');
const content = fs.readFileSync(skillPath, 'utf8');
assert.ok(
content.includes('../../../rules/ecc/react/'),
content.includes('../../rules/ecc/react/'),
'source-relative rules link should be rewritten for the ecc/ namespace'
);
assert.ok(
!content.includes('](../../rules/'),
'no un-namespaced ](../../rules/ links should remain'
!content.includes('](../../rules/react/'),
'no un-namespaced ](../../rules/react/ links should remain'
);
// The rewritten link must resolve to a file that actually exists on disk.
const linkTarget = path.join(
path.dirname(skillPath),
'../../../rules/ecc/react/hooks.md'
'../../rules/ecc/react/hooks.md'
);
assert.ok(fs.existsSync(linkTarget), 'rewritten link target should exist');
} finally {
@@ -462,11 +462,61 @@ function runTests() {
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(result.stdout.includes('user-owned'), result.stdout);
assert.ok(result.stdout.includes('Skipped operations:'), result.stdout);
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', 'ecc', 'tdd-workflow', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', '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'))
)));
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('reports applied and skipped user-owned Claude skill operations in JSON', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
try {
const userSkillPath = path.join(
homeDir,
'.claude',
'skills',
'tdd-workflow',
'SKILL.md'
);
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
fs.writeFileSync(userSkillPath, '# User custom skill\n');
const result = run(['--skills', 'tdd-workflow', '--json'], {
cwd: projectDir,
homeDir,
});
assert.strictEqual(result.code, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.strictEqual(payload.dryRun, false);
assert.ok(payload.result.plannedOperations.length > 0);
assert.ok(payload.result.operations.length > 0);
assert.ok(payload.result.skippedOperations.length > 0);
assert.strictEqual(
payload.result.operations.length + payload.result.skippedOperations.length,
payload.result.plannedOperations.length
);
assert.ok(payload.result.skippedOperations.every(operation => (
operation.destinationPath.startsWith(path.dirname(userSkillPath))
)));
assert.ok(!payload.result.operations.some(operation => (
operation.destinationPath.startsWith(path.dirname(userSkillPath))
)));
assert.ok(payload.result.warnings.some(warning => warning.includes('user-owned')));
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n');
} finally {
cleanup(homeDir);
cleanup(projectDir);
@@ -880,8 +930,8 @@ function runTests() {
const result = run(['--config', configPath], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'security-review', 'SKILL.md')));
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md')));
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md')));
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
assert.strictEqual(state.request.profile, 'developer');
@@ -912,8 +962,8 @@ function runTests() {
const result = run([], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'security-review', 'SKILL.md')));
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md')));
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md')));
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
assert.strictEqual(state.request.profile, 'developer');