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..141ec07 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, 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 { + cleanManagedSkills(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..ac28465 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 { cleanManagedSkills, DEV_SKILL_PREFIX } from './lib/utils.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = resolve(__dirname, '..'); @@ -19,7 +19,7 @@ const globalClaudeSkillsDir = join(homeDir, '.claude', 'skills'); function cleanupDevSkills() { try { - rmSync(globalClaudeSkillsDir, { recursive: true, force: true }); + cleanManagedSkills(globalClaudeSkillsDir, DEV_SKILL_PREFIX); console.log('Cleaned up dev skills'); } catch {} } diff --git a/scripts/lib/utils.test.ts b/scripts/lib/utils.test.ts new file mode 100644 index 0000000..dc32f60 --- /dev/null +++ b/scripts/lib/utils.test.ts @@ -0,0 +1,59 @@ +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 { cleanManagedSkills, DEV_SKILL_PREFIX } from './utils.js'; + +describe('cleanManagedSkills', () => { + 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 the directories it manages', () => { + seed(); + + cleanManagedSkills(dir, DEV_SKILL_PREFIX); + + 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(); + + cleanManagedSkills(dir, DEV_SKILL_PREFIX); + + expect(existsSync(dir)).toBe(true); + }); + + it('creates the directory when it does not exist yet', () => { + const missing = join(dir, 'nested', 'skills'); + + cleanManagedSkills(missing, DEV_SKILL_PREFIX); + + expect(existsSync(missing)).toBe(true); + }); + + it('does not treat an empty prefix as "everything"', () => { + seed(); + + cleanManagedSkills(dir, ''); + + expect(readdirSync(dir)).toHaveLength(4); + }); +}); diff --git a/scripts/lib/utils.ts b/scripts/lib/utils.ts index fdfd988..d0962f6 100644 --- a/scripts/lib/utils.ts +++ b/scripts/lib/utils.ts @@ -68,3 +68,18 @@ export function cleanDir(dir: string): void { } mkdirSync(dir, { recursive: true }); } + +export const DEV_SKILL_PREFIX = 'diffity-dev'; + +// 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 }); + } + } +}