Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plug/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
.DS_Store
*.log
CLAUDE.md
test-output/
29 changes: 20 additions & 9 deletions plug/src/commands/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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,
},
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}`);
Expand All @@ -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;
}
Expand All @@ -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 };
}

/**
Expand Down
77 changes: 51 additions & 26 deletions plug/src/commands/remove.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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;
Expand Down
68 changes: 21 additions & 47 deletions plug/src/commands/update.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
);
Expand Down
6 changes: 5 additions & 1 deletion plug/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
4 changes: 2 additions & 2 deletions plug/src/utils/fetcher.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion plug/src/utils/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
REGISTRY_FILE,
DEFAULT_BRANCH,
CACHE_TTL_MS,
toRefBranch,
} from '../constants.js';

import { fetchCommunityIndex, normalizeCommunityPackage } from './community-index.js';
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading