diff --git a/plug/.gitignore b/plug/.gitignore index 198f91d..187cbab 100644 --- a/plug/.gitignore +++ b/plug/.gitignore @@ -2,3 +2,4 @@ node_modules/ .DS_Store *.log CLAUDE.md +test-output/ diff --git a/plug/src/commands/install.js b/plug/src/commands/install.js index a8ff25a..70aa471 100644 --- a/plug/src/commands/install.js +++ b/plug/src/commands/install.js @@ -199,7 +199,7 @@ export async function runInstall(name, options = {}) { throw err; } - const { type, destPath, version } = installInfo; + const { type, destPath, version, files } = installInfo; const directDeps = isRoot ? rootDirectDeps : (entrySpec.pkg.dependencies ?? []).map(d => (typeof d === 'string' ? d : d.name)); @@ -211,6 +211,7 @@ export async function runInstall(name, options = {}) { vault: entrySpec.vault.name, version, path: destPath, + files, installed_as: isRoot ? 'explicit' : 'dependency', dependencies: directDeps, }, @@ -253,7 +254,7 @@ export async function runInstall(name, options = {}) { * Downloads and writes a single package to disk. Branches on rawBaseUrl for community packages. * Returns { type, destPath, version }. */ -async function installSinglePackage(pkgSpec, isGlobal) { +export async function installSinglePackage(pkgSpec, isGlobal) { const { pkg, vault, rawBaseUrl } = pkgSpec; const pkgName = pkg.name; @@ -298,19 +299,28 @@ async function installSinglePackage(pkgSpec, isGlobal) { let primaryDestPath = ''; let primaryContent = ''; + const downloadedFiles = []; + + const prefix = pkg.path ? (pkg.path.endsWith('/') ? pkg.path : pkg.path + '/') : ''; + const localEntryFile = (prefix && entryFile.startsWith(prefix)) ? entryFile.slice(prefix.length) : entryFile; for (const relativePath of filesToDownload) { + let localPath = relativePath; + if (prefix && relativePath.startsWith(prefix)) { + localPath = relativePath.slice(prefix.length); + } + let content; if (rawBaseUrl) { let url; if (rawBaseUrl.endsWith('/')) { url = relativePath.startsWith('/') ? `${rawBaseUrl}${relativePath.slice(1)}` - : `${rawBaseUrl}${pkg.path}/${relativePath}`; + : (relativePath.startsWith(prefix) ? `${rawBaseUrl}${relativePath}` : `${rawBaseUrl}${pkg.path}/${relativePath}`); } else { url = relativePath.startsWith('/') ? `${rawBaseUrl}${relativePath}` - : `${rawBaseUrl}/${pkg.path}/${relativePath}`; + : (relativePath.startsWith(prefix) ? `${rawBaseUrl}/${relativePath}` : `${rawBaseUrl}/${pkg.path}/${relativePath}`); } url = url.replace(/([^:]\/)\/+/g, '$1'); verbose(`Downloading ${relativePath} from ${url}`); @@ -319,20 +329,21 @@ async function installSinglePackage(pkgSpec, isGlobal) { content = await resp.text(); } else { verbose(`Downloading ${relativePath} from vault ${vault.name}`); - content = await downloadFile(vault, `${pkg.path}/${relativePath}`); + content = await downloadFile(vault, `${pkg.path}/${localPath}`); } // Determine local destination // For skills, we flatten the structure into the skill directory unless we want to preserve subdirs // For now, let's preserve subdirs relative to the package root - const destPath = (type === 'skill' && relativePath === entryFile) + const destPath = (type === 'skill' && localPath === localEntryFile) ? path.join(rootDestDir, 'SKILL.md') - : path.join(rootDestDir, relativePath); + : path.join(rootDestDir, localPath); await ensureDir(path.dirname(destPath)); await fs.writeFile(destPath, content, 'utf8'); + downloadedFiles.push(destPath); - if (relativePath === entryFile) { + if (localPath === localEntryFile) { primaryDestPath = destPath; primaryContent = content; } @@ -341,7 +352,7 @@ async function installSinglePackage(pkgSpec, isGlobal) { const fm = type === 'skill' ? parseFrontmatter(primaryContent) : {}; const hookRequired = !!(fm.hook || fm.hooks); - return { type, destPath: primaryDestPath, version: meta.version, hookRequired }; + return { type, destPath: primaryDestPath, version: meta.version, hookRequired, files: downloadedFiles }; } /** diff --git a/plug/src/commands/remove.js b/plug/src/commands/remove.js index 7ebd007..05f93d2 100644 --- a/plug/src/commands/remove.js +++ b/plug/src/commands/remove.js @@ -1,5 +1,6 @@ import chalk from 'chalk'; import fs from 'fs/promises'; +import path from 'path'; import { select, confirm } from '@inquirer/prompts'; import { getInstalled, trackRemove, prunableOrphans, removeDependentEdge } from '../utils/tracker.js'; import { ctx, verbose } from '../utils/context.js'; @@ -63,20 +64,17 @@ export async function runRemove(name, options = {}) { } if (choice === 'force') { - verbose(`Removing file at ${pkg.path}`); - try { - await fs.unlink(pkg.path); - } catch (err) { - if (err.code === 'EACCES' || err.code === 'EPERM') { - throw Object.assign( - new Error(`Cannot remove '${pkg.path}'. Check permissions.`), - { code: err.code }, - ); - } - if (err.code !== 'ENOENT') { - throw err; + const filesToRemove = pkg.files && pkg.files.length > 0 ? pkg.files : [pkg.path]; + for (const filePath of filesToRemove) { + verbose(`Removing file at ${filePath}`); + try { + await fs.unlink(filePath); + } catch (err) { + if (err.code !== 'ENOENT') { + verbose(`Failed to remove ${filePath}: ${err.message}`); + } } - verbose(`File already gone (ENOENT), removing from tracker`); + await cleanEmptyParents(filePath); } await trackRemove(name, isGlobal); @@ -96,20 +94,18 @@ export async function runRemove(name, options = {}) { } } - verbose(`Removing file at ${pkg.path}`); - try { - await fs.unlink(pkg.path); - } catch (err) { - if (err.code === 'EACCES' || err.code === 'EPERM') { - throw Object.assign( - new Error(`Cannot remove '${pkg.path}'. Check permissions.`), - { code: err.code }, - ); - } - if (err.code !== 'ENOENT') { - throw err; + const filesToRemove = pkg.files && pkg.files.length > 0 ? pkg.files : [pkg.path]; + for (const filePath of filesToRemove) { + verbose(`Removing file at ${filePath}`); + try { + await fs.unlink(filePath); + } catch (err) { + if (err.code !== 'ENOENT') { + verbose(`Failed to remove ${filePath}: ${err.message}`); + } } - verbose(`File already gone (ENOENT), removing from tracker`); + // Always try to clean parents even if unlink failed (e.g. already gone) + await cleanEmptyParents(filePath); } await trackRemove(name, isGlobal); @@ -124,6 +120,35 @@ export async function runRemove(name, options = {}) { await _pruneOrphans(isGlobal); } +/** + * Recursively deletes empty parent directories up to (but not including) .claude subdirs. + * @param {string} filePath + */ +export async function cleanEmptyParents(filePath) { + let currentDir = path.dirname(filePath); + while (true) { + const dirName = path.basename(currentDir); + // Stop if we hit one of the core subdirs + if (['skills', 'commands', 'agents', '.claude', '.plugvault'].includes(dirName)) break; + + try { + const files = await fs.readdir(currentDir); + if (files.length === 0) { + verbose(`Removing empty directory: ${currentDir}`); + await fs.rmdir(currentDir); + + const parent = path.dirname(currentDir); + if (parent === currentDir) break; // Root reached + currentDir = parent; + } else { + break; + } + } catch { + break; + } + } +} + async function _pruneOrphans(isGlobal) { const orphans = await prunableOrphans(isGlobal); if (orphans.length === 0) return; diff --git a/plug/src/commands/update.js b/plug/src/commands/update.js index 43097dd..6721040 100644 --- a/plug/src/commands/update.js +++ b/plug/src/commands/update.js @@ -1,12 +1,11 @@ import chalk from 'chalk'; -import path from 'path'; import fs from 'fs/promises'; import { getInstalled, trackInstall } from '../utils/tracker.js'; import { findPackage } from '../utils/registry.js'; -import { downloadFile } from '../utils/fetcher.js'; -import { getClaudeSkillsDir, getClaudeDirForType, ensureDir } from '../utils/paths.js'; import { createSpinner } from '../utils/ui.js'; import { ctx, verbose } from '../utils/context.js'; +import { installSinglePackage } from './install.js'; +import { cleanEmptyParents } from './remove.js'; export function registerUpdate(program) { program @@ -105,67 +104,42 @@ export async function runUpdate(name, options = {}) { return { name, status: 'up-to-date', version: installedVersion }; } - // Re-download + // Use installSinglePackage to handle multi-file and community downloads const dlSpinner = createSpinner(`Updating ${name} to v${latestVersion}...`); - let meta; - let content; + let installInfo; try { - try { - verbose(`Fetching meta.json for ${name}`); - const metaContent = await downloadFile(vault, `${pkg.path}/meta.json`); - meta = JSON.parse(metaContent); - } catch { - verbose('meta.json unavailable, falling back to registry data'); - meta = { - type: pkg.type || record.type, - entry: `${name}.md`, - version: pkg.version, - }; - } - - const entryFile = meta.entry || `${name}.md`; - verbose(`Downloading ${entryFile}`); - content = await downloadFile(vault, `${pkg.path}/${entryFile}`); + installInfo = await installSinglePackage(result, isGlobal); dlSpinner.stop(); } catch (err) { dlSpinner.stop(); throw err; } - const entryFile = meta.entry || `${name}.md`; - const type = meta.type || pkg.type || record.type || 'command'; - let destPath; - if (type === 'skill') { - const skillSubdir = path.join(getClaudeSkillsDir(isGlobal), name); - await ensureDir(skillSubdir); - destPath = path.join(skillSubdir, 'SKILL.md'); - } else { - const destDir = getClaudeDirForType(type, isGlobal); - await ensureDir(destDir); - destPath = path.join(destDir, entryFile); - } - - verbose(`Writing to ${destPath}`); - try { - await fs.writeFile(destPath, content, 'utf8'); - } catch (err) { - if (err.code === 'EACCES' || err.code === 'EPERM') { - throw Object.assign( - new Error(`Cannot write to ${destPath}. Check permissions.`), - { code: err.code }, - ); + // Clean up old files that are not in the new file list + const oldFiles = record.files || [record.path]; + const newFiles = new Set(installInfo.files); + for (const oldPath of oldFiles) { + if (!newFiles.has(oldPath)) { + verbose(`Cleaning up old file: ${oldPath}`); + try { + await fs.unlink(oldPath); + await cleanEmptyParents(oldPath); + } catch { /* ignore */ } } - throw err; } await trackInstall( name, { - type, + type: installInfo.type, vault: vault.name, version: latestVersion, - path: destPath, + path: installInfo.destPath, + files: installInfo.files, installedAt: record.installedAt, + installed_as: record.installed_as || 'explicit', + dependencies: record.dependencies || [], + dependents: record.dependents || [], }, isGlobal, ); diff --git a/plug/src/constants.js b/plug/src/constants.js index 34a6078..c4de6e5 100644 --- a/plug/src/constants.js +++ b/plug/src/constants.js @@ -22,6 +22,10 @@ export const GITHUB_API_BASE = 'https://api.github.com'; export const REGISTRY_FILE = 'registry.json'; export const DEFAULT_BRANCH = 'main'; +export function toRefBranch(branch) { + return branch.startsWith('refs/') ? branch : `refs/heads/${branch}`; +} + export const OFFICIAL_VAULT = { name: OFFICIAL_VAULT_NAME, owner: OFFICIAL_VAULT_OWNER, @@ -31,4 +35,4 @@ export const OFFICIAL_VAULT = { }; export const COMMUNITY_INDEX_URL = - 'https://raw.githubusercontent.com/dsiddharth2/plugvault/main/community-index.json' + 'https://raw.githubusercontent.com/dsiddharth2/plugvault/refs/heads/main/community-index.json'; diff --git a/plug/src/utils/fetcher.js b/plug/src/utils/fetcher.js index 43f9bcf..da3440b 100644 --- a/plug/src/utils/fetcher.js +++ b/plug/src/utils/fetcher.js @@ -1,5 +1,5 @@ import { getAuthHeaders } from './auth.js'; -import { GITHUB_RAW_BASE, DEFAULT_BRANCH } from '../constants.js'; +import { GITHUB_RAW_BASE, DEFAULT_BRANCH, toRefBranch } from '../constants.js'; /** * Downloads a file from a vault's GitHub repo. @@ -8,7 +8,7 @@ import { GITHUB_RAW_BASE, DEFAULT_BRANCH } from '../constants.js'; * @returns {string} File contents as a string */ export async function downloadFile(vault, filePath) { - const branch = vault.branch || DEFAULT_BRANCH; + const branch = toRefBranch(vault.branch || DEFAULT_BRANCH); const url = `${GITHUB_RAW_BASE}/${vault.owner}/${vault.repo}/${branch}/${filePath}`; const headers = await getAuthHeaders(vault.name); diff --git a/plug/src/utils/registry.js b/plug/src/utils/registry.js index 4e84cf5..874662d 100644 --- a/plug/src/utils/registry.js +++ b/plug/src/utils/registry.js @@ -8,6 +8,7 @@ import { REGISTRY_FILE, DEFAULT_BRANCH, CACHE_TTL_MS, + toRefBranch, } from '../constants.js'; import { fetchCommunityIndex, normalizeCommunityPackage } from './community-index.js'; @@ -71,7 +72,7 @@ export async function fetchRegistry(vault) { const cached = await getCachedRegistry(vault.name); if (cached) return cached; - const branch = vault.branch || DEFAULT_BRANCH; + const branch = toRefBranch(vault.branch || DEFAULT_BRANCH); const url = `${GITHUB_RAW_BASE}/${vault.owner}/${vault.repo}/${branch}/${REGISTRY_FILE}`; const headers = await getAuthHeaders(vault.name); diff --git a/plug/tests/community-install-integration.test.js b/plug/tests/community-install-integration.test.js new file mode 100644 index 0000000..0e5d843 --- /dev/null +++ b/plug/tests/community-install-integration.test.js @@ -0,0 +1,171 @@ +/** + * Integration test: fetches the real community index, then installs every + * package through the same installSinglePackage flow the TUI uses. + * + * Run: + * npx vitest run tests/community-install-integration.test.js + * + * Limit to N packages (fastest smoke test): + * LIMIT=5 npx vitest run tests/community-install-integration.test.js + * + * Filter by name substring: + * FILTER=test-driven npx vitest run tests/community-install-integration.test.js + * + * Output dir (default: ./test-output/community-install): + * OUTPUT_DIR=/tmp/plug-check npx vitest run tests/community-install-integration.test.js + * + * Use a local community-index.json instead of GitHub: + * LOCAL_INDEX=/path/to/community-index.json npx vitest run tests/community-install-integration.test.js + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import { installSinglePackage } from '../src/commands/install.js'; +import { fetchCommunityIndex, normalizeCommunityPackage } from '../src/utils/community-index.js'; +import { ctx } from '../src/utils/context.js'; + +const LOCAL_INDEX = process.env.LOCAL_INDEX || ''; + +const OUTPUT_DIR = process.env.OUTPUT_DIR + || path.resolve(import.meta.dirname, '..', 'test-output', 'community-install'); +const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : Infinity; +const FILTER = process.env.FILTER || ''; + +const reportPath = path.join(OUTPUT_DIR, '_report.json'); +const report = { total: 0, passed: 0, failed: 0, skipped: 0, packages: [] }; + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +// Override paths so everything writes into OUTPUT_DIR +const claudeDir = path.join(OUTPUT_DIR, '.claude'); +const skillsDir = path.join(claudeDir, 'skills'); +const commandsDir = path.join(claudeDir, 'commands'); +const agentsDir = path.join(claudeDir, 'agents'); + +// We need to mock paths and tracker so installSinglePackage writes to our output dir. +// But we want REAL network calls — no mocking fetch. +import { vi } from 'vitest'; + +vi.mock('../src/utils/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getClaudeSkillsDir: () => skillsDir, + getClaudeCommandsDir: () => commandsDir, + getClaudeAgentsDir: () => agentsDir, + getClaudeDirForType: (type) => { + if (type === 'skill') return skillsDir; + if (type === 'agent') return agentsDir; + return commandsDir; + }, + getInstalledFilePath: () => path.join(OUTPUT_DIR, '.plugvault', 'installed.json'), + getGlobalDir: () => OUTPUT_DIR, + getConfigFilePath: () => path.join(OUTPUT_DIR, '.plugvault', 'config.json'), + getCacheDir: () => path.join(OUTPUT_DIR, '.plugvault', 'cache'), + ensureDir: actual.ensureDir, + }; +}); + +vi.mock('../src/utils/tracker.js', () => ({ + trackInstall: async () => {}, + isInstalled: async () => false, + getInstalled: async () => ({ installed: {} }), + addDependents: async () => {}, +})); + +vi.mock('../src/utils/resolver.js', () => ({ + resolve: vi.fn().mockResolvedValue({ toInstall: [], alreadySatisfied: [], cycles: [] }), +})); + +let communityPackages = []; + +describe('community install integration', () => { + beforeAll(async () => { + ctx.set({ yes: true, verbose: false, json: false }); + + await ensureDir(skillsDir); + await ensureDir(commandsDir); + await ensureDir(agentsDir); + await ensureDir(path.join(OUTPUT_DIR, '.plugvault', 'cache')); + + // Fetch community index — local file or remote + let index; + if (LOCAL_INDEX) { + const raw = await fs.readFile(LOCAL_INDEX, 'utf8'); + index = JSON.parse(raw); + } else { + index = await fetchCommunityIndex(); + } + const rawPkgs = index.packages || []; + + communityPackages = rawPkgs + .filter((p) => !FILTER || p.name.includes(FILTER)) + .slice(0, LIMIT) + .map((p) => normalizeCommunityPackage(p)); + + report.total = communityPackages.length; + console.log(`\n Community index: ${rawPkgs.length} total, testing ${communityPackages.length} packages`); + console.log(` Output: ${OUTPUT_DIR}\n`); + }, 30_000); + + afterAll(async () => { + await ensureDir(OUTPUT_DIR); + await fs.writeFile(reportPath, JSON.stringify(report, null, 2), 'utf8'); + console.log(`\n ══════════════════════════════════════════`); + console.log(` Report: ${reportPath}`); + console.log(` Total: ${report.total} Passed: ${report.passed} Failed: ${report.failed} Skipped: ${report.skipped}`); + console.log(` Output dir: ${OUTPUT_DIR}`); + console.log(` ══════════════════════════════════════════\n`); + }); + + it('installs all community packages', async () => { + for (const pkg of communityPackages) { + const pkgSpec = { + pkg, + vault: { name: pkg.vault, url: pkg.vaultUrl }, + rawBaseUrl: pkg.rawBaseUrl ?? null, + }; + + const entry = { + name: pkg.name, + type: pkg.type, + vault: pkg.vault, + fileCount: (pkg.files || []).length, + status: 'pending', + files: [], + error: null, + }; + + try { + const result = await installSinglePackage(pkgSpec, false); + entry.status = 'passed'; + entry.files = result.files || []; + entry.destPath = result.destPath; + entry.version = result.version; + report.passed++; + } catch (err) { + entry.status = 'failed'; + entry.error = err.message; + report.failed++; + } + + report.packages.push(entry); + + // Progress indicator + const done = report.passed + report.failed + report.skipped; + const symbol = entry.status === 'passed' ? '✓' : '✗'; + const extra = entry.files.length > 1 ? ` (${entry.files.length} files)` : ''; + console.log(` ${symbol} [${done}/${report.total}] ${pkg.name}${extra}${entry.error ? ' — ' + entry.error : ''}`); + } + + // Write final report + await fs.writeFile(reportPath, JSON.stringify(report, null, 2), 'utf8'); + + // Assertions + expect(report.failed).toBe(0); + expect(report.passed).toBe(report.total); + }, 600_000); // 10 min timeout for all downloads +}); diff --git a/plug/tests/fetcher.test.js b/plug/tests/fetcher.test.js index 4438404..948acb1 100644 --- a/plug/tests/fetcher.test.js +++ b/plug/tests/fetcher.test.js @@ -54,7 +54,7 @@ describe('fetcher utils', () => { }); await downloadFile(sampleVault, 'registry/file.md'); expect(global.fetch).toHaveBeenCalledWith( - 'https://raw.githubusercontent.com/dsiddharth2/plugvault/main/registry/file.md', + 'https://raw.githubusercontent.com/dsiddharth2/plugvault/refs/heads/main/registry/file.md', expect.any(Object) ); }); diff --git a/plug/tests/install.test.js b/plug/tests/install.test.js index cae98e4..68a7e4e 100644 --- a/plug/tests/install.test.js +++ b/plug/tests/install.test.js @@ -316,6 +316,60 @@ describe('plug install', () => { // ── Phase 2: per-skill subdir layout ───────────────────────────────────────── + it('correctly handles nested files from community vault with rawBaseUrl and strips pkg.path prefix', async () => { + // This simulates community-index returning absolute file paths starting with pkg.path + const communityPkg = { + name: 'google-workspace-cli', + type: 'skill', + version: '1.0.0', + path: 'engineering-team/google-workspace-cli', + description: 'GWS CLI', + rawBaseUrl: 'https://raw.githubusercontent.com/dsiddharth2/claude-skills/refs/heads/main/', + files: [ + 'engineering-team/google-workspace-cli/SKILL.md', + 'engineering-team/google-workspace-cli/assets/logo.png', + 'engineering-team/google-workspace-cli/scripts/setup.sh' + ], + entry: 'engineering-team/google-workspace-cli/SKILL.md' + }; + + findAllPackages.mockResolvedValue([{ pkg: communityPkg, vault: sampleVault }]); + + // Mock fetch for the 3 files plus meta.json + global.fetch = vi.fn((url) => { + if (url.endsWith('meta.json')) return Promise.resolve({ ok: false, status: 404 }); + if (url.endsWith('SKILL.md')) return Promise.resolve({ ok: true, status: 200, text: async () => '# skill content' }); + if (url.endsWith('logo.png')) return Promise.resolve({ ok: true, status: 200, text: async () => 'binary-logo-data' }); + if (url.endsWith('setup.sh')) return Promise.resolve({ ok: true, status: 200, text: async () => 'echo setup' }); + return Promise.resolve({ ok: false, status: 404 }); + }); + + await runInstall('google-workspace-cli', {}); + + // Ensure the 4 fetch calls had the right URLs (1 for meta.json, 3 for files) + expect(global.fetch).toHaveBeenCalledTimes(4); + const expectedBase = 'https://raw.githubusercontent.com/dsiddharth2/claude-skills/refs/heads/main/engineering-team/google-workspace-cli'; + expect(global.fetch.mock.calls[1][0]).toBe(expectedBase + '/SKILL.md'); + expect(global.fetch.mock.calls[2][0]).toBe(expectedBase + '/assets/logo.png'); + expect(global.fetch.mock.calls[3][0]).toBe(expectedBase + '/scripts/setup.sh'); + + // Ensure files were written correctly with prefix stripped + const skillDest = path.join(localSkillsDir, 'google-workspace-cli', 'SKILL.md'); + const logoDest = path.join(localSkillsDir, 'google-workspace-cli', 'assets', 'logo.png'); + const scriptDest = path.join(localSkillsDir, 'google-workspace-cli', 'scripts', 'setup.sh'); + + const skillContent = await fs.readFile(skillDest, 'utf8'); + expect(skillContent).toBe('# skill content'); + + const logoContent = await fs.readFile(logoDest, 'utf8'); + expect(logoContent).toBe('binary-logo-data'); + + const scriptContent = await fs.readFile(scriptDest, 'utf8'); + expect(scriptContent).toBe('echo setup'); + + delete global.fetch; // cleanup + }); + it('fresh install of 3 skills produces 3 separate subdirs each with SKILL.md', async () => { const skills = [ { name: 'api-patterns', content: '# api-patterns' }, diff --git a/plug/tests/remove.test.js b/plug/tests/remove.test.js index 42100a4..825ed18 100644 --- a/plug/tests/remove.test.js +++ b/plug/tests/remove.test.js @@ -398,4 +398,48 @@ describe('plug remove', () => { const data = JSON.parse(await fs.readFile(localInstalledFile, 'utf8')); expect(data.installed['dep-x']).toBeUndefined(); }); + + it('removes all files in the files array and cleans up empty parent directories', async () => { + // We need to ensure the path includes one of the stop-directories like 'skills' + const skillsBase = path.join(tmpDir, '.claude', 'skills'); + await fs.mkdir(skillsBase, { recursive: true }); + + const skillDir = path.join(skillsBase, 'multi-file-skill'); + const skillFile = path.join(skillDir, 'SKILL.md'); + const helperFile = path.join(skillDir, 'utils', 'helper.js'); + + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.mkdir(path.dirname(helperFile), { recursive: true }); + await fs.writeFile(skillFile, '# SKILL', 'utf8'); + await fs.writeFile(helperFile, 'console.log("helper")', 'utf8'); + + await fs.writeFile( + localInstalledFile, + JSON.stringify({ + installed: { + 'multi-file-skill': { + type: 'skill', + vault: 'official', + version: '1.0.0', + path: skillFile, + files: [skillFile, helperFile], + }, + }, + }), + 'utf8', + ); + + await runRemove('multi-file-skill', {}); + + // Both files should be deleted + await expect(fs.access(skillFile)).rejects.toThrow(); + await expect(fs.access(helperFile)).rejects.toThrow(); + + // The subdirectories should be cleaned up because they are empty + await expect(fs.access(path.dirname(helperFile))).rejects.toThrow(); + await expect(fs.access(skillDir)).rejects.toThrow(); + + // Core 'skills' directory should still exist + await expect(fs.access(path.join(tmpDir, '.claude', 'skills'))).resolves.toBeUndefined(); + }); });