From 2a42265dc2ca33cd9bded889b47f51395d6e7e23 Mon Sep 17 00:00:00 2001 From: James Klein Date: Fri, 17 Jul 2026 21:43:32 +0000 Subject: [PATCH 1/2] fix(skills): don't wipe unrelated skills when syncing dev skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build:skills called cleanDir on ~/.claude/skills, deleting the entire directory before reinstalling diffity's dev skills. That destroys any other skills the user keeps there — their own skills, or skills from other tools — which is especially harmful when ~/.claude/skills is a symlink into a version-controlled dotfiles repo. Replace the blanket cleanDir with cleanManagedSkills, which removes only the directories diffity manages (those named `diffity-dev-*`) and leaves everything else in place. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/build-skills.ts | 6 ++++-- scripts/lib/utils.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/build-skills.ts b/scripts/build-skills.ts index d730bdc..7ee673d 100644 --- a/scripts/build-skills.ts +++ b/scripts/build-skills.ts @@ -1,7 +1,7 @@ import { createHash } from 'crypto'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { readSkills, renderSkill, writeFile, cleanDir } from './lib/utils.js'; +import { readSkills, renderSkill, writeFile, cleanDir, cleanManagedSkills } from './lib/utils.js'; import { claudeCode } from './lib/transformers/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -30,7 +30,9 @@ writeFile( ); console.log(`Skills hash: ${skillsHash}`); -cleanDir(globalClaudeSkillsDir); +// Only remove diffity's own dev skills, not the whole directory — ~/.claude/skills +// is shared with the user's own skills and skills from other tools. +cleanManagedSkills(globalClaudeSkillsDir, 'diffity-dev'); for (const skill of skills) { claudeCode(skill, homeDir, { binary: 'diffity-dev', namePrefix: 'diffity-dev', slashPrefix: '/diffity-dev-', installHint: 'run `npm run dev` from the diffity repo root to link the CLI' }); } diff --git a/scripts/lib/utils.ts b/scripts/lib/utils.ts index fdfd988..24d5095 100644 --- a/scripts/lib/utils.ts +++ b/scripts/lib/utils.ts @@ -68,3 +68,16 @@ export function cleanDir(dir: string): void { } mkdirSync(dir, { recursive: true }); } + +// Remove only the skill directories diffity manages (those named `${prefix}-*`), +// leaving any other skills in the directory untouched. Unlike cleanDir, this is +// safe to run against a shared location like ~/.claude/skills, which may hold +// the user's own skills or skills installed by other tools. +export function cleanManagedSkills(dir: string, prefix: string): void { + mkdirSync(dir, { recursive: true }); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { + rmSync(join(dir, entry.name), { recursive: true, force: true }); + } + } +} From 70f24269123f63dd7f6ee14641bc2f36eba359b1 Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 10:12:53 +0200 Subject: [PATCH 2/2] fix(scripts): remove only diffity-dev skills, never the whole global dir build-skills.ts cleaned ~/.claude/skills with a recursive rmSync before writing the dev skills, and dev.ts did the same on SIGINT/SIGTERM and on dev exit. Both deleted every unrelated Claude Code skill the user had installed there. Both now remove only the immediate diffity-dev-* subdirectories, and the sync can be skipped entirely with DIFFITY_SKIP_DEV_SKILLS=1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- package-lock.json | 3 ++- package.json | 8 +++--- scripts/build-skills.ts | 14 ++++++---- scripts/dev.ts | 6 ++--- scripts/lib/utils.test.ts | 55 +++++++++++++++++++++++++++++++++++++++ scripts/lib/utils.ts | 25 ++++++++++++++++++ 6 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 scripts/lib/utils.test.ts diff --git a/package-lock.json b/package-lock.json index 16891ff..5ccfa4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "devDependencies": { "concurrently": "^9.2.1", "gray-matter": "^4.0.3", - "tsx": "^4.21.0" + "tsx": "^4.21.0", + "vitest": "^4.1.0" } }, "node_modules/@antfu/install-pkg": { diff --git a/package.json b/package.json index d316f28..8325382 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,12 @@ "scripts": { "build": "tsx scripts/build.ts", "build:skills": "tsx scripts/build-skills.ts", - "test": "npm run test -w @diffity/git && npm run test -w @diffity/parser && npm run test -w @diffity/ui", + "test": "npm run test -w @diffity/git && npm run test -w @diffity/parser && npm run test -w @diffity/ui && npm run test:scripts", "link-dev": "tsx scripts/link-dev.ts", "dev": "tsx scripts/dev.ts", "release:patch": "npm run build && tsx scripts/release.ts patch && npm publish -w packages/cli", - "release:minor": "npm run build && tsx scripts/release.ts minor && npm publish -w packages/cli" + "release:minor": "npm run build && tsx scripts/release.ts minor && npm publish -w packages/cli", + "test:scripts": "vitest run scripts" }, "keywords": [ "git", @@ -31,6 +32,7 @@ "devDependencies": { "concurrently": "^9.2.1", "gray-matter": "^4.0.3", - "tsx": "^4.21.0" + "tsx": "^4.21.0", + "vitest": "^4.1.0" } } diff --git a/scripts/build-skills.ts b/scripts/build-skills.ts index d730bdc..48e9137 100644 --- a/scripts/build-skills.ts +++ b/scripts/build-skills.ts @@ -1,7 +1,7 @@ import { createHash } from 'crypto'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { readSkills, renderSkill, writeFile, cleanDir } from './lib/utils.js'; +import { readSkills, renderSkill, writeFile, cleanDir, removePrefixedDirs, DEV_SKILL_PREFIX } from './lib/utils.js'; import { claudeCode } from './lib/transformers/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -30,8 +30,12 @@ writeFile( ); console.log(`Skills hash: ${skillsHash}`); -cleanDir(globalClaudeSkillsDir); -for (const skill of skills) { - claudeCode(skill, homeDir, { binary: 'diffity-dev', namePrefix: 'diffity-dev', slashPrefix: '/diffity-dev-', installHint: 'run `npm run dev` from the diffity repo root to link the CLI' }); +if (process.env.DIFFITY_SKIP_DEV_SKILLS || !homeDir) { + console.log('Skipped dev skills sync'); +} else { + removePrefixedDirs(globalClaudeSkillsDir, DEV_SKILL_PREFIX); + for (const skill of skills) { + claudeCode(skill, homeDir, { binary: DEV_SKILL_PREFIX, namePrefix: DEV_SKILL_PREFIX, slashPrefix: `/${DEV_SKILL_PREFIX}-`, installHint: 'run `npm run dev` from the diffity repo root to link the CLI' }); + } + console.log(`Synced ${skills.length} dev skills to ~/.claude/skills/`); } -console.log(`Synced ${skills.length} dev skills to ~/.claude/skills/`); diff --git a/scripts/dev.ts b/scripts/dev.ts index 94b33d7..c2e8204 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -2,9 +2,9 @@ import { execSync } from 'child_process'; import { dirname, resolve, join } from 'path'; -import { rmSync } from 'fs'; import { fileURLToPath } from 'url'; import concurrently from 'concurrently'; +import { removePrefixedDirs, DEV_SKILL_PREFIX } from './lib/utils.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = resolve(__dirname, '..'); @@ -19,8 +19,8 @@ const globalClaudeSkillsDir = join(homeDir, '.claude', 'skills'); function cleanupDevSkills() { try { - rmSync(globalClaudeSkillsDir, { recursive: true, force: true }); - console.log('Cleaned up dev skills'); + const removed = removePrefixedDirs(globalClaudeSkillsDir, DEV_SKILL_PREFIX); + console.log(`Cleaned up ${removed.length} dev skills`); } catch {} } diff --git a/scripts/lib/utils.test.ts b/scripts/lib/utils.test.ts new file mode 100644 index 0000000..17ac328 --- /dev/null +++ b/scripts/lib/utils.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { removePrefixedDirs, DEV_SKILL_PREFIX } from './utils.js'; + +describe('removePrefixedDirs', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'diffity-skills-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function seed(): void { + mkdirSync(join(dir, `${DEV_SKILL_PREFIX}-review`), { recursive: true }); + writeFileSync(join(dir, `${DEV_SKILL_PREFIX}-review`, 'SKILL.md'), 'dev skill'); + mkdirSync(join(dir, 'slack-canvas', 'scripts'), { recursive: true }); + writeFileSync(join(dir, 'slack-canvas', 'scripts', 'slack-canvas.sh'), '#!/bin/sh'); + mkdirSync(join(dir, 'standup'), { recursive: true }); + writeFileSync(join(dir, 'loose-file.md'), 'not a skill'); + } + + it('removes only directories matching the prefix', () => { + seed(); + + const removed = removePrefixedDirs(dir, DEV_SKILL_PREFIX); + + expect(removed).toEqual([`${DEV_SKILL_PREFIX}-review`]); + expect(readdirSync(dir).sort()).toEqual(['loose-file.md', 'slack-canvas', 'standup']); + expect(existsSync(join(dir, 'slack-canvas', 'scripts', 'slack-canvas.sh'))).toBe(true); + }); + + it('leaves the containing directory in place', () => { + seed(); + + removePrefixedDirs(dir, DEV_SKILL_PREFIX); + + expect(existsSync(dir)).toBe(true); + }); + + it('returns nothing when the directory does not exist', () => { + expect(removePrefixedDirs(join(dir, 'missing'), DEV_SKILL_PREFIX)).toEqual([]); + }); + + it('rejects an empty prefix instead of removing everything', () => { + seed(); + + expect(() => removePrefixedDirs(dir, '')).toThrow(/non-empty prefix/); + expect(readdirSync(dir)).toHaveLength(4); + }); +}); diff --git a/scripts/lib/utils.ts b/scripts/lib/utils.ts index fdfd988..63c308a 100644 --- a/scripts/lib/utils.ts +++ b/scripts/lib/utils.ts @@ -68,3 +68,28 @@ export function cleanDir(dir: string): void { } mkdirSync(dir, { recursive: true }); } + +export const DEV_SKILL_PREFIX = 'diffity-dev'; + +// `dir` holds skills this repo does not own, so only our own prefixed entries may be removed — +// never the directory itself. +export function removePrefixedDirs(dir: string, prefix: string): string[] { + if (!prefix) { + throw new Error('removePrefixedDirs requires a non-empty prefix'); + } + + if (!existsSync(dir)) { + return []; + } + + const removed: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith(prefix)) { + continue; + } + rmSync(join(dir, entry.name), { recursive: true, force: true }); + removed.push(entry.name); + } + + return removed; +}