Fix: deduplicate symlinked provider dirs during update and check

When .claude/skills is a symlink to .agents/skills, updating both
providers wrote to the same directory twice -- the last write
(.agents) always won, making .claude content identical to .agents.
The up-to-date check also always failed because it compared
provider-specific bundle content against the wrong provider's files.

Fix: use realpathSync to detect shared directories and process each
unique real path only once with its matching bundle provider. Respects
the user's symlink setup for non-impeccable skills.

Tested: first run updates 18 skills, second run reports "up to date".

CLI bumped to v2.1.6.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-10 11:43:09 -07:00
co-authored by Claude Opus 4.6
parent 7670d1325a
commit a530f08df6
2 changed files with 47 additions and 24 deletions
+46 -23
View File
@@ -8,7 +8,7 @@
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, symlinkSync, readlinkSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream } from 'node:fs';
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, symlinkSync, readlinkSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { createInterface } from 'node:readline';
import { fileURLToPath } from 'node:url';
@@ -116,31 +116,52 @@ function normalizeForHash(content) {
.replace(/^version:\s*.+$/m, 'version: NORMALIZED');
}
/**
* Deduplicate providers by resolved path. When .claude/skills is a
* symlink to ../.agents/skills, both resolve to the same directory.
* Returns an array of { provider, localSkillsDir } with one entry
* per unique real path. The first provider that maps to a real path
* wins (so the bundle uses that provider's build).
*/
function deduplicateProviders(root, providers) {
const seen = new Map(); // realPath -> { provider, localSkillsDir }
for (const provider of providers) {
const skillsDir = join(root, provider, 'skills');
if (!existsSync(skillsDir)) continue;
const real = realpathSync(skillsDir);
if (!seen.has(real)) {
seen.set(real, { provider, localSkillsDir: skillsDir });
}
}
return [...seen.values()];
}
/**
* Compare local skills against a downloaded bundle.
* Only checks skills that exist in the bundle (ignores user's custom
* skills that aren't part of impeccable). Uses a single provider from
* the bundle as canonical and normalizes provider-specific paths.
* skills that aren't part of impeccable). Deduplicates providers that
* share the same real path (symlinks). Normalizes provider-specific
* paths and version fields before comparing.
* Returns true if every bundle skill matches the local copy.
*/
function isUpToDate(root, providers, bundleDir) {
// Use the first installed provider for comparison
const provider = providers[0];
const bundleSkillsDir = join(bundleDir, provider, 'skills');
const localSkillsDir = join(root, provider, 'skills');
if (!existsSync(bundleSkillsDir)) return false;
const unique = deduplicateProviders(root, providers);
if (unique.length === 0) return false;
for (const name of readdirSync(bundleSkillsDir)) {
const bundleMd = join(bundleSkillsDir, name, 'SKILL.md');
const localMd = join(localSkillsDir, name, 'SKILL.md');
if (!existsSync(bundleMd)) continue;
for (const { provider, localSkillsDir } of unique) {
const bundleSkillsDir = join(bundleDir, provider, 'skills');
if (!existsSync(bundleSkillsDir)) continue;
// Missing locally = needs update
if (!existsSync(localMd)) return false;
for (const name of readdirSync(bundleSkillsDir)) {
const bundleMd = join(bundleSkillsDir, name, 'SKILL.md');
const localMd = join(localSkillsDir, name, 'SKILL.md');
if (!existsSync(bundleMd)) continue;
if (!existsSync(localMd)) return false;
const bundleHash = createHash('sha256').update(normalizeForHash(readFileSync(bundleMd, 'utf-8'))).digest('hex');
const localHash = createHash('sha256').update(normalizeForHash(readFileSync(localMd, 'utf-8'))).digest('hex');
if (bundleHash !== localHash) return false;
const bundleHash = createHash('sha256').update(normalizeForHash(readFileSync(bundleMd, 'utf-8'))).digest('hex');
const localHash = createHash('sha256').update(normalizeForHash(readFileSync(localMd, 'utf-8'))).digest('hex');
if (bundleHash !== localHash) return false;
}
}
return true;
}
@@ -526,7 +547,7 @@ async function update(flags = []) {
}
// Compare local vs remote -- skip if already up to date
if (!yes && isUpToDate(root, providers, tmpDir)) {
if (isUpToDate(root, providers, tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
const v = getSkillsVersion(root);
console.log(`Skills are up to date${v ? ` (v${v})` : ''}. Nothing to do.`);
@@ -546,18 +567,20 @@ async function update(flags = []) {
try {
// Copy from the already-downloaded bundle to each provider folder
// Copy from the bundle to each unique provider folder.
// Deduplicate so symlinked dirs (e.g. .claude/skills -> .agents/skills)
// are only written once with the correct provider's content.
const unique = deduplicateProviders(root, providers);
let updated = 0;
for (const provider of providers) {
for (const { provider, localSkillsDir } of unique) {
const srcDir = join(tmpDir, provider, 'skills');
const destDir = join(root, provider, 'skills');
if (!existsSync(srcDir)) continue;
const skills = readdirSync(srcDir, { withFileTypes: true });
for (const skill of skills) {
if (!skill.isDirectory()) continue;
const src = join(srcDir, skill.name);
const dest = join(destDir, skill.name);
const dest = join(localSkillsDir, skill.name);
if (existsSync(dest)) rmSync(dest, { recursive: true });
copyDirSync(src, dest);
updated++;
@@ -582,7 +605,7 @@ async function update(flags = []) {
}
const v = getSkillsVersion(root);
console.log(`Updated ${updated} skills across ${providers.length} provider(s)${v ? ` to v${v}` : ''}.`);
console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
console.log('Done!\n');
} catch (e) {
console.error(`Update failed: ${e.message}`);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impeccable",
"version": "2.1.5",
"version": "2.1.6",
"author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [