claudepluginhub 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ import type { Scope } from './prompts.js';
2
+ import type { ComponentLock } from './lock.js';
3
+ export interface FlatComponent {
4
+ source: string;
5
+ type: 'command' | 'agent' | 'skill' | 'hook' | 'mcp' | 'lsp';
6
+ componentName: string;
7
+ sourcePath: string;
8
+ inlineConfig: unknown | null;
9
+ }
10
+ export type InstallResult = {
11
+ status: 'installed';
12
+ lock: ComponentLock;
13
+ } | {
14
+ status: 'skipped';
15
+ } | {
16
+ status: 'failed';
17
+ };
18
+ export declare function downloadSkill(component: FlatComponent, scope: Scope): Promise<ComponentLock | null>;
19
+ export declare function downloadSingleFile(component: FlatComponent, scope: Scope): Promise<ComponentLock | null>;
20
+ export declare function mergeConfig(component: FlatComponent, scope: Scope): Promise<ComponentLock | null>;
21
+ export declare function installComponent(component: FlatComponent, scope: Scope, checkExisting: boolean): Promise<InstallResult>;
@@ -0,0 +1,264 @@
1
+ import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { fetchDefaultBranch, fetchTree, downloadFile, findTreeSha, findBlobSha, listFilesUnder, } from './github.js';
5
+ import { printError } from './output.js';
6
+ function getBaseDir(scope) {
7
+ if (scope === 'user') {
8
+ return join(homedir(), '.claude');
9
+ }
10
+ return join(process.cwd(), '.claude');
11
+ }
12
+ function getSettingsFile(scope) {
13
+ const base = getBaseDir(scope);
14
+ if (scope === 'local') {
15
+ return join(base, 'settings.local.json');
16
+ }
17
+ return join(base, 'settings.json');
18
+ }
19
+ function ensureDir(filePath) {
20
+ const dir = dirname(filePath);
21
+ if (!existsSync(dir)) {
22
+ mkdirSync(dir, { recursive: true });
23
+ }
24
+ }
25
+ function readJsonFile(path) {
26
+ if (!existsSync(path))
27
+ return {};
28
+ try {
29
+ return JSON.parse(readFileSync(path, 'utf-8'));
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ function writeJsonFile(path, data) {
36
+ ensureDir(path);
37
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
38
+ }
39
+ function isSafePath(destPath, baseDir) {
40
+ const resolved = resolve(destPath);
41
+ const base = resolve(baseDir);
42
+ return resolved.startsWith(base + sep) || resolved === base;
43
+ }
44
+ export async function downloadSkill(component, scope) {
45
+ const { source, sourcePath, componentName } = component;
46
+ // Strip /SKILL.md to get directory path
47
+ const dirPath = sourcePath.replace(/\/SKILL\.md$/, '');
48
+ const branch = await fetchDefaultBranch(source);
49
+ if (!branch)
50
+ return null;
51
+ const tree = await fetchTree(source, branch);
52
+ if (!tree)
53
+ return null;
54
+ const files = listFilesUnder(tree, dirPath);
55
+ if (files.length === 0) {
56
+ printError(`No files found for skill ${componentName} at ${dirPath}`);
57
+ return null;
58
+ }
59
+ const baseDir = getBaseDir(scope);
60
+ const skillsRoot = join(baseDir, 'skills');
61
+ const installDir = join(skillsRoot, componentName);
62
+ if (!isSafePath(installDir, skillsRoot)) {
63
+ printError(`Skipping unsafe skill name: ${componentName}`);
64
+ return null;
65
+ }
66
+ for (const file of files) {
67
+ const relativePath = file.path.slice(dirPath.length + 1); // remove dir prefix + /
68
+ const destPath = join(installDir, relativePath);
69
+ if (!isSafePath(destPath, installDir)) {
70
+ printError(`Skipping unsafe path: ${file.path}`);
71
+ continue;
72
+ }
73
+ const content = await downloadFile(source, branch, file.path);
74
+ if (content === null)
75
+ return null;
76
+ ensureDir(destPath);
77
+ writeFileSync(destPath, content);
78
+ }
79
+ const hash = findTreeSha(tree, dirPath) ?? 'unknown';
80
+ return {
81
+ source,
82
+ type: 'skill',
83
+ componentName,
84
+ sourcePath,
85
+ installedPath: installDir,
86
+ hash,
87
+ configFile: null,
88
+ configKeys: null,
89
+ configFragment: null,
90
+ };
91
+ }
92
+ export async function downloadSingleFile(component, scope) {
93
+ const { source, type, sourcePath, componentName } = component;
94
+ const branch = await fetchDefaultBranch(source);
95
+ if (!branch)
96
+ return null;
97
+ const tree = await fetchTree(source, branch);
98
+ if (!tree)
99
+ return null;
100
+ const baseDir = getBaseDir(scope);
101
+ const subdir = type === 'command' ? 'commands' : 'agents';
102
+ // Preserve nested path: "commands/git/commit.md" -> .claude/commands/git/commit.md
103
+ const prefix = subdir + '/';
104
+ const relPath = sourcePath.startsWith(prefix) ? sourcePath.slice(prefix.length) : sourcePath;
105
+ const destPath = join(baseDir, subdir, relPath);
106
+ if (!isSafePath(destPath, join(baseDir, subdir))) {
107
+ printError(`Skipping unsafe path: ${sourcePath}`);
108
+ return null;
109
+ }
110
+ const content = await downloadFile(source, branch, sourcePath);
111
+ if (content === null)
112
+ return null;
113
+ ensureDir(destPath);
114
+ writeFileSync(destPath, content);
115
+ const hash = findBlobSha(tree, sourcePath) ?? 'unknown';
116
+ return {
117
+ source,
118
+ type,
119
+ componentName,
120
+ sourcePath,
121
+ installedPath: destPath,
122
+ hash,
123
+ configFile: null,
124
+ configKeys: null,
125
+ configFragment: null,
126
+ };
127
+ }
128
+ async function resolveConfig(component) {
129
+ const { inlineConfig, source, sourcePath } = component;
130
+ // Inline config object — use directly
131
+ if (inlineConfig && typeof inlineConfig === 'object') {
132
+ return inlineConfig;
133
+ }
134
+ // String path — download and parse
135
+ if (typeof inlineConfig === 'string' || !inlineConfig) {
136
+ const filePath = typeof inlineConfig === 'string' ? inlineConfig : sourcePath;
137
+ const branch = await fetchDefaultBranch(source);
138
+ if (!branch)
139
+ return null;
140
+ const content = await downloadFile(source, branch, filePath);
141
+ if (!content)
142
+ return null;
143
+ try {
144
+ return JSON.parse(content);
145
+ }
146
+ catch {
147
+ printError(`Failed to parse config from ${filePath}`);
148
+ return null;
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+ export async function mergeConfig(component, scope) {
154
+ const { source, type, componentName, sourcePath } = component;
155
+ const config = await resolveConfig(component);
156
+ if (!config)
157
+ return null;
158
+ const settingsPath = getSettingsFile(scope);
159
+ const settings = readJsonFile(settingsPath);
160
+ const configKeys = [];
161
+ let configFragment = null;
162
+ if (type === 'mcp') {
163
+ const servers = (settings.mcpServers ?? {});
164
+ const mcpConfig = config.mcpServers
165
+ ? config.mcpServers
166
+ : config;
167
+ const ownedFragment = {};
168
+ for (const [key, value] of Object.entries(mcpConfig)) {
169
+ if (key in servers) {
170
+ printError(`MCP server "${key}" already exists in settings — skipping`);
171
+ continue;
172
+ }
173
+ servers[key] = value;
174
+ ownedFragment[key] = value;
175
+ configKeys.push(`mcpServers.${key}`);
176
+ }
177
+ settings.mcpServers = servers;
178
+ configFragment = ownedFragment;
179
+ }
180
+ else if (type === 'lsp') {
181
+ const servers = (settings.lspServers ?? {});
182
+ const lspConfig = config.lspServers
183
+ ? config.lspServers
184
+ : config;
185
+ const ownedFragment = {};
186
+ for (const [key, value] of Object.entries(lspConfig)) {
187
+ if (key in servers) {
188
+ printError(`LSP server "${key}" already exists in settings — skipping`);
189
+ continue;
190
+ }
191
+ servers[key] = value;
192
+ ownedFragment[key] = value;
193
+ configKeys.push(`lspServers.${key}`);
194
+ }
195
+ settings.lspServers = servers;
196
+ configFragment = ownedFragment;
197
+ }
198
+ else if (type === 'hook') {
199
+ const hooks = (settings.hooks ?? {});
200
+ const hookConfig = config.hooks
201
+ ? config.hooks
202
+ : config;
203
+ for (const [event, handlers] of Object.entries(hookConfig)) {
204
+ if (!Array.isArray(handlers))
205
+ continue;
206
+ const existing = (hooks[event] ?? []);
207
+ // Only add handlers not already present (by deep equality)
208
+ const newHandlers = handlers.filter((h) => !existing.some((e) => JSON.stringify(e) === JSON.stringify(h)));
209
+ if (newHandlers.length > 0) {
210
+ hooks[event] = [...existing, ...newHandlers];
211
+ }
212
+ configKeys.push(`hooks.${event}`);
213
+ }
214
+ settings.hooks = hooks;
215
+ configFragment = hookConfig;
216
+ }
217
+ writeJsonFile(settingsPath, settings);
218
+ const branch = await fetchDefaultBranch(source);
219
+ const tree = branch ? await fetchTree(source, branch) : null;
220
+ const hash = tree ? (findBlobSha(tree, sourcePath) ?? 'unknown') : 'unknown';
221
+ return {
222
+ source,
223
+ type,
224
+ componentName,
225
+ sourcePath,
226
+ installedPath: settingsPath,
227
+ hash,
228
+ configFile: settingsPath,
229
+ configKeys,
230
+ configFragment,
231
+ };
232
+ }
233
+ export async function installComponent(component, scope, checkExisting) {
234
+ if (component.type === 'skill') {
235
+ if (checkExisting) {
236
+ const baseDir = getBaseDir(scope);
237
+ const destDir = join(baseDir, 'skills', component.componentName);
238
+ if (existsSync(destDir)) {
239
+ return { status: 'skipped' };
240
+ }
241
+ }
242
+ const lock = await downloadSkill(component, scope);
243
+ return lock ? { status: 'installed', lock } : { status: 'failed' };
244
+ }
245
+ if (component.type === 'command' || component.type === 'agent') {
246
+ if (checkExisting) {
247
+ const baseDir = getBaseDir(scope);
248
+ const subdir = component.type === 'command' ? 'commands' : 'agents';
249
+ const prefix = subdir + '/';
250
+ const relPath = component.sourcePath.startsWith(prefix)
251
+ ? component.sourcePath.slice(prefix.length)
252
+ : component.sourcePath;
253
+ const destPath = join(baseDir, subdir, relPath);
254
+ if (existsSync(destPath)) {
255
+ return { status: 'skipped' };
256
+ }
257
+ }
258
+ const lock = await downloadSingleFile(component, scope);
259
+ return lock ? { status: 'installed', lock } : { status: 'failed' };
260
+ }
261
+ // Config-based: hook, mcp, lsp
262
+ const lock = await mergeConfig(component, scope);
263
+ return lock ? { status: 'installed', lock } : { status: 'failed' };
264
+ }
package/dist/fetch.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { FlatComponent } from './download.js';
1
2
  export interface MarketplacePlugin {
2
3
  name: string;
3
4
  source: {
@@ -22,3 +23,4 @@ export interface MarketplaceJSON {
22
23
  }
23
24
  export declare function resolveMarketplaceUrl(input: string): string | null;
24
25
  export declare function fetchMarketplace(url: string): Promise<MarketplaceJSON | null>;
26
+ export declare function flattenComponents(manifest: MarketplaceJSON): FlatComponent[];
package/dist/fetch.js CHANGED
@@ -42,7 +42,7 @@ export async function fetchMarketplace(url) {
42
42
  });
43
43
  if (!response.ok) {
44
44
  if (response.status === 404) {
45
- printError('Plugin not found. Check the identifier and try again.');
45
+ printError('Collection not found. Check the identifier and try again.');
46
46
  }
47
47
  else {
48
48
  printError(`HTTP ${response.status}: ${response.statusText}`);
@@ -53,7 +53,7 @@ export async function fetchMarketplace(url) {
53
53
  if (!data.name ||
54
54
  !Array.isArray(data.plugins) ||
55
55
  data.plugins.some((p) => !p.name)) {
56
- printError('Invalid marketplace JSON format.');
56
+ printError('Invalid collection JSON format.');
57
57
  return null;
58
58
  }
59
59
  return data;
@@ -63,3 +63,79 @@ export async function fetchMarketplace(url) {
63
63
  return null;
64
64
  }
65
65
  }
66
+ export function flattenComponents(manifest) {
67
+ const components = [];
68
+ for (const plugin of manifest.plugins) {
69
+ const repo = plugin.source.repo;
70
+ if (plugin.commands) {
71
+ for (const path of plugin.commands) {
72
+ // Preserve full path under commands/ prefix, e.g. "commands/git/commit.md"
73
+ const relPath = path.startsWith('commands/') ? path.slice('commands/'.length) : path;
74
+ const name = relPath.replace(/\.md$/, '');
75
+ components.push({
76
+ source: repo,
77
+ type: 'command',
78
+ componentName: name,
79
+ sourcePath: path,
80
+ inlineConfig: null,
81
+ });
82
+ }
83
+ }
84
+ if (plugin.agents) {
85
+ for (const path of plugin.agents) {
86
+ const relPath = path.startsWith('agents/') ? path.slice('agents/'.length) : path;
87
+ const name = relPath.replace(/\.md$/, '');
88
+ components.push({
89
+ source: repo,
90
+ type: 'agent',
91
+ componentName: name,
92
+ sourcePath: path,
93
+ inlineConfig: null,
94
+ });
95
+ }
96
+ }
97
+ if (plugin.skills) {
98
+ for (const path of plugin.skills) {
99
+ // Preserve full path under skills/ prefix, e.g. "skills/review/SKILL.md" -> "review"
100
+ // But "skills/nested/deep/SKILL.md" -> "nested/deep"
101
+ const withoutSkillMd = path.replace(/\/SKILL\.md$/, '');
102
+ const relPath = withoutSkillMd.startsWith('skills/') ? withoutSkillMd.slice('skills/'.length) : withoutSkillMd;
103
+ components.push({
104
+ source: repo,
105
+ type: 'skill',
106
+ componentName: relPath,
107
+ sourcePath: path,
108
+ inlineConfig: null,
109
+ });
110
+ }
111
+ }
112
+ if (plugin.hooks) {
113
+ components.push({
114
+ source: repo,
115
+ type: 'hook',
116
+ componentName: `${plugin.name}-hooks`,
117
+ sourcePath: typeof plugin.hooks === 'string' ? plugin.hooks : 'hooks/hooks.json',
118
+ inlineConfig: plugin.hooks,
119
+ });
120
+ }
121
+ if (plugin.mcpServers) {
122
+ components.push({
123
+ source: repo,
124
+ type: 'mcp',
125
+ componentName: `${plugin.name}-mcp`,
126
+ sourcePath: typeof plugin.mcpServers === 'string' ? plugin.mcpServers : '.mcp.json',
127
+ inlineConfig: plugin.mcpServers,
128
+ });
129
+ }
130
+ if (plugin.lspServers) {
131
+ components.push({
132
+ source: repo,
133
+ type: 'lsp',
134
+ componentName: `${plugin.name}-lsp`,
135
+ sourcePath: typeof plugin.lspServers === 'string' ? plugin.lspServers : '.lsp.json',
136
+ inlineConfig: plugin.lspServers,
137
+ });
138
+ }
139
+ }
140
+ return components;
141
+ }
@@ -0,0 +1,19 @@
1
+ interface TreeEntry {
2
+ path: string;
3
+ mode: string;
4
+ type: 'blob' | 'tree';
5
+ sha: string;
6
+ size?: number;
7
+ }
8
+ interface TreeResponse {
9
+ sha: string;
10
+ tree: TreeEntry[];
11
+ truncated: boolean;
12
+ }
13
+ export declare function fetchDefaultBranch(repo: string): Promise<string | null>;
14
+ export declare function fetchTree(repo: string, branch: string): Promise<TreeResponse | null>;
15
+ export declare function downloadFile(repo: string, branch: string, path: string): Promise<string | null>;
16
+ export declare function findTreeSha(tree: TreeResponse, dirPath: string): string | null;
17
+ export declare function findBlobSha(tree: TreeResponse, filePath: string): string | null;
18
+ export declare function listFilesUnder(tree: TreeResponse, dirPath: string): TreeEntry[];
19
+ export {};
package/dist/github.js ADDED
@@ -0,0 +1,93 @@
1
+ import { printError } from './output.js';
2
+ function getHeaders() {
3
+ const headers = {
4
+ Accept: 'application/vnd.github.v3+json',
5
+ 'User-Agent': 'claudepluginhub-cli',
6
+ };
7
+ const token = process.env.GITHUB_TOKEN;
8
+ if (token) {
9
+ headers.Authorization = `token ${token}`;
10
+ }
11
+ return headers;
12
+ }
13
+ const branchCache = new Map();
14
+ export async function fetchDefaultBranch(repo) {
15
+ const cached = branchCache.get(repo);
16
+ if (cached)
17
+ return cached;
18
+ try {
19
+ const response = await fetch(`https://api.github.com/repos/${repo}`, {
20
+ headers: getHeaders(),
21
+ signal: AbortSignal.timeout(10_000),
22
+ });
23
+ if (!response.ok) {
24
+ if (response.status === 403 || response.status === 404) {
25
+ printError(`Cannot access repo ${repo}. ${!process.env.GITHUB_TOKEN ? 'Set GITHUB_TOKEN for private repos.' : 'Check permissions.'}`);
26
+ }
27
+ else {
28
+ printError(`GitHub API error for ${repo}: ${response.status}`);
29
+ }
30
+ return null;
31
+ }
32
+ const data = (await response.json());
33
+ branchCache.set(repo, data.default_branch);
34
+ return data.default_branch;
35
+ }
36
+ catch (err) {
37
+ printError(`Failed to fetch repo info for ${repo}: ${err.message}`);
38
+ return null;
39
+ }
40
+ }
41
+ const treeCache = new Map();
42
+ export async function fetchTree(repo, branch) {
43
+ const key = `${repo}@${branch}`;
44
+ const cached = treeCache.get(key);
45
+ if (cached)
46
+ return cached;
47
+ try {
48
+ const response = await fetch(`https://api.github.com/repos/${repo}/git/trees/${branch}?recursive=1`, {
49
+ headers: getHeaders(),
50
+ signal: AbortSignal.timeout(15_000),
51
+ });
52
+ if (!response.ok) {
53
+ printError(`Failed to fetch file tree for ${repo}: ${response.status}`);
54
+ return null;
55
+ }
56
+ const data = (await response.json());
57
+ treeCache.set(key, data);
58
+ return data;
59
+ }
60
+ catch (err) {
61
+ printError(`Failed to fetch file tree for ${repo}: ${err.message}`);
62
+ return null;
63
+ }
64
+ }
65
+ export async function downloadFile(repo, branch, path) {
66
+ try {
67
+ const url = `https://raw.githubusercontent.com/${repo}/${branch}/${path}`;
68
+ const response = await fetch(url, {
69
+ signal: AbortSignal.timeout(10_000),
70
+ });
71
+ if (!response.ok) {
72
+ printError(`Failed to download ${path} from ${repo}: ${response.status}`);
73
+ return null;
74
+ }
75
+ return await response.text();
76
+ }
77
+ catch (err) {
78
+ printError(`Failed to download ${path}: ${err.message}`);
79
+ return null;
80
+ }
81
+ }
82
+ export function findTreeSha(tree, dirPath) {
83
+ const entry = tree.tree.find((e) => e.type === 'tree' && e.path === dirPath);
84
+ return entry?.sha ?? null;
85
+ }
86
+ export function findBlobSha(tree, filePath) {
87
+ const entry = tree.tree.find((e) => e.type === 'blob' && e.path === filePath);
88
+ return entry?.sha ?? null;
89
+ }
90
+ export function listFilesUnder(tree, dirPath) {
91
+ const prefix = dirPath.endsWith('/') ? dirPath : dirPath + '/';
92
+ return tree.tree.filter((e) => e.type === 'blob' && e.path.startsWith(prefix));
93
+ }
package/dist/index.js CHANGED
@@ -1,31 +1,33 @@
1
1
  #!/usr/bin/env node
2
- import { resolveMarketplaceUrl, fetchMarketplace } from './fetch.js';
3
- import { detectClaude, addMarketplace, installPlugin } from './install.js';
4
- import { checkboxPrompt, scopePrompt } from './prompts.js';
5
- import { print, printBanner, printStep, printSuccess, printFail, printSummary, printError, } from './output.js';
2
+ import { resolveMarketplaceUrl, fetchMarketplace, flattenComponents } from './fetch.js';
3
+ import { checkboxPrompt, scopePrompt, confirmPrompt } from './prompts.js';
4
+ import { runInstall } from './install.js';
5
+ import { runUpdate } from './update.js';
6
+ import { runRemove } from './remove.js';
7
+ import { runList } from './list.js';
8
+ import { print, printBanner, printStep, printSuccess, printError, DIM, RESET, GREEN, CYAN, YELLOW, } from './output.js';
6
9
  const VALID_SCOPES = ['user', 'project', 'local'];
7
- function formatComponents(plugin) {
8
- const parts = [];
9
- if (plugin.commands?.length)
10
- parts.push(`${plugin.commands.length} command(s)`);
11
- if (plugin.agents?.length)
12
- parts.push(`${plugin.agents.length} agent(s)`);
13
- if (plugin.skills?.length)
14
- parts.push(`${plugin.skills.length} skill(s)`);
15
- if (plugin.hooks)
16
- parts.push('hooks');
17
- if (plugin.mcpServers)
18
- parts.push('MCP');
19
- if (plugin.lspServers)
20
- parts.push('LSP');
21
- return parts.length > 0 ? parts.join(', ') : '';
22
- }
10
+ const TYPE_BADGES = {
11
+ skill: `${GREEN}[skill]${RESET}`,
12
+ command: `${CYAN}[cmd]${RESET}`,
13
+ agent: `${YELLOW}[agent]${RESET}`,
14
+ hook: `\x1b[35m[hook]${RESET}`,
15
+ mcp: `${CYAN}[mcp]${RESET}`,
16
+ lsp: `\x1b[34m[lsp]${RESET}`,
17
+ };
23
18
  function printUsage() {
24
- print(`Usage: npx claudepluginhub <identifier> [options]
19
+ print(`Usage: npx claudepluginhub <command> [options]
20
+
21
+ Commands:
22
+ <identifier> Install components from a user-plugin collection
23
+ add <identifier> Same as above (explicit)
24
+ update [identifier] Update installed components
25
+ list List installed components
26
+ remove <identifier> Remove installed components
25
27
 
26
- Examples:
27
- npx claudepluginhub u/<userId>/<slug>
28
- npx claudepluginhub https://claudepluginhub.com/api/user-plugins/.../marketplace.json
28
+ Identifiers:
29
+ u/<userId>/<slug> Short form
30
+ https://claudepluginhub.com/api/user-plugins/.../marketplace.json
29
31
 
30
32
  Options:
31
33
  --yes, -y Skip prompts (install all, project scope)
@@ -45,9 +47,21 @@ function parseArgs(argv) {
45
47
  }
46
48
  scope = val;
47
49
  }
50
+ // Filter out flags and their values
48
51
  const positional = argv.filter((a, i) => !a.startsWith('-') && (i === 0 || argv[i - 1] !== '--scope'));
49
- const input = positional[0] ?? null;
50
- return { input, yes, scope };
52
+ const first = positional[0] ?? null;
53
+ const second = positional[1] ?? null;
54
+ // Route commands
55
+ if (first === 'list')
56
+ return { command: 'list', identifier: null, yes, scope };
57
+ if (first === 'update')
58
+ return { command: 'update', identifier: second, yes, scope };
59
+ if (first === 'remove')
60
+ return { command: 'remove', identifier: second, yes, scope };
61
+ if (first === 'add')
62
+ return { command: 'add', identifier: second, yes, scope };
63
+ // Default: treat first positional as identifier for install
64
+ return { command: 'add', identifier: first, yes, scope };
51
65
  }
52
66
  async function main() {
53
67
  const args = process.argv.slice(2);
@@ -56,57 +70,71 @@ async function main() {
56
70
  printUsage();
57
71
  process.exit(0);
58
72
  }
59
- const { input, yes, scope: scopeOverride } = parseArgs(args);
60
- if (!input) {
61
- printBanner();
73
+ const { command, identifier, yes, scope: scopeOverride } = parseArgs(args);
74
+ printBanner();
75
+ // Handle non-install commands
76
+ if (command === 'list') {
77
+ runList();
78
+ process.exit(0);
79
+ }
80
+ if (command === 'update') {
81
+ await runUpdate(identifier ?? undefined);
82
+ process.exit(0);
83
+ }
84
+ if (command === 'remove') {
85
+ if (!identifier) {
86
+ printError('Missing identifier. Usage: npx claudepluginhub remove <identifier>');
87
+ process.exit(1);
88
+ }
89
+ await runRemove(identifier);
90
+ process.exit(0);
91
+ }
92
+ // Install flow
93
+ if (!identifier) {
62
94
  printUsage();
63
95
  process.exit(1);
64
96
  }
65
- printBanner();
66
- // Resolve to marketplace URL
67
- const url = resolveMarketplaceUrl(input);
97
+ // Resolve identifier to API URL
98
+ const url = resolveMarketplaceUrl(identifier);
68
99
  if (!url) {
69
- printError(`Invalid input: ${input}`);
100
+ printError(`Invalid identifier: ${identifier}`);
70
101
  print('');
71
- printUsage();
72
- process.exit(1);
73
- }
74
- // Check claude CLI is available
75
- printStep('Checking for Claude CLI...');
76
- const claudePath = detectClaude();
77
- if (!claudePath) {
78
- printError('Claude CLI not found in PATH.\nInstall it from: https://docs.anthropic.com/en/docs/claude-code/overview');
102
+ print(`Expected: u/<userId>/<slug> or a claudepluginhub.com URL`);
79
103
  process.exit(1);
80
104
  }
81
- printSuccess('Claude CLI found');
82
- // Fetch marketplace JSON
83
- printStep('Fetching marketplace...');
84
- const marketplace = await fetchMarketplace(url);
85
- if (!marketplace) {
105
+ // Derive the collection identifier from the input
106
+ const collectionId = identifier.startsWith('http')
107
+ ? extractIdentifierFromUrl(identifier)
108
+ : identifier;
109
+ // Fetch manifest
110
+ printStep('Fetching collection...');
111
+ const manifest = await fetchMarketplace(url);
112
+ if (!manifest) {
86
113
  process.exit(1);
87
114
  }
88
- printSuccess(`"${marketplace.name}" - ${marketplace.plugins.length} plugin(s)`);
89
- if (marketplace.plugins.length === 0) {
90
- printError('No plugins found in this marketplace.');
115
+ const allComponents = flattenComponents(manifest);
116
+ printSuccess(`"${manifest.name}" - ${allComponents.length} component(s)`);
117
+ if (allComponents.length === 0) {
118
+ printError('No components found in this collection.');
91
119
  process.exit(0);
92
120
  }
93
- // Plugin selection (interactive, >1 plugin)
94
- let selectedPlugins = marketplace.plugins;
95
- if (!yes && marketplace.plugins.length > 1) {
121
+ // Component selection (interactive)
122
+ let selectedComponents = allComponents;
123
+ if (!yes && allComponents.length > 1) {
96
124
  print('');
97
- const items = marketplace.plugins.map((p) => ({
98
- label: p.source.repo,
99
- description: formatComponents(p),
125
+ const items = allComponents.map((c) => ({
126
+ label: `${TYPE_BADGES[c.type] ?? `[${c.type}]`} ${c.componentName}`,
127
+ description: `${DIM}${c.source}${RESET}`,
100
128
  selected: true,
101
129
  }));
102
- const chosen = await checkboxPrompt('Select plugins to install:', items);
130
+ const chosen = await checkboxPrompt('Select components to install:', items);
103
131
  if (chosen === null) {
104
132
  print('\nCancelled.');
105
133
  process.exit(0);
106
134
  }
107
- selectedPlugins = chosen.map((i) => marketplace.plugins[i]);
108
- if (selectedPlugins.length === 0) {
109
- printError('No plugins selected.');
135
+ selectedComponents = chosen.map((i) => allComponents[i]);
136
+ if (selectedComponents.length === 0) {
137
+ printError('No components selected.');
110
138
  process.exit(0);
111
139
  }
112
140
  }
@@ -121,35 +149,31 @@ async function main() {
121
149
  }
122
150
  scope = chosenScope;
123
151
  }
124
- // Add marketplace
125
- print('');
126
- printStep('Adding marketplace...');
127
- const addResult = addMarketplace(claudePath, url);
128
- if (!addResult.ok) {
129
- printError(`Failed to add marketplace: ${addResult.error}`);
130
- process.exit(1);
131
- }
132
- printSuccess('Marketplace registered');
133
- // Install each plugin
134
- print('');
135
- printStep(`Installing plugins (${scope} scope)...`);
136
- const results = [];
137
- for (const plugin of selectedPlugins) {
138
- const installName = `${plugin.name}@${marketplace.name}`;
139
- const result = installPlugin(claudePath, installName, scope);
140
- results.push({ name: plugin.name, ...result });
141
- if (result.ok) {
142
- printSuccess(plugin.name);
143
- }
144
- else {
145
- printFail(plugin.name, result.error);
152
+ // Safety warning for hooks/mcp/lsp
153
+ const hasUnsafe = selectedComponents.some((c) => c.type === 'hook' || c.type === 'mcp' || c.type === 'lsp');
154
+ if (hasUnsafe) {
155
+ print('');
156
+ print(`${YELLOW}Warning:${RESET} This collection includes hooks, MCP, or LSP config`);
157
+ print(`that will be merged into your settings file.`);
158
+ print(`Review the source repos before proceeding.`);
159
+ if (!yes) {
160
+ const confirmed = await confirmPrompt('Continue?');
161
+ if (!confirmed) {
162
+ print('\nCancelled.');
163
+ process.exit(0);
164
+ }
146
165
  }
166
+ print('');
147
167
  }
148
- printSummary(results);
149
- const failed = results.filter((r) => !r.ok).length;
150
- if (failed > 0) {
151
- process.exit(1);
168
+ await runInstall(collectionId, url, selectedComponents, scope);
169
+ }
170
+ function extractIdentifierFromUrl(url) {
171
+ // Extract u/<userId>/<slug> from URL path
172
+ const match = url.match(/\/api\/user-plugins\/([^/]+)\/([^/]+)/);
173
+ if (match) {
174
+ return `u/${match[1]}/${match[2]}`;
152
175
  }
176
+ return url;
153
177
  }
154
178
  main().catch((err) => {
155
179
  printError(`Unexpected error: ${err.message}`);
package/dist/install.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- interface CommandResult {
2
- ok: boolean;
3
- error?: string;
4
- }
5
- export declare function detectClaude(): string | null;
6
- export declare function addMarketplace(claudePath: string, url: string): CommandResult;
7
- export declare function installPlugin(claudePath: string, installName: string, scope?: 'user' | 'project' | 'local'): CommandResult;
8
- export {};
1
+ import type { Scope } from './prompts.js';
2
+ import type { FlatComponent } from './download.js';
3
+ export declare function runInstall(identifier: string, apiUrl: string, components: FlatComponent[], scope: Scope): Promise<void>;
package/dist/install.js CHANGED
@@ -1,41 +1,56 @@
1
- import { execFileSync } from 'node:child_process';
2
- export function detectClaude() {
3
- try {
4
- execFileSync('claude', ['--version'], { stdio: 'pipe' });
5
- return 'claude';
1
+ import { installComponent } from './download.js';
2
+ import { readLock, writeLock, addCollection, componentKey } from './lock.js';
3
+ import { print, printStep, printSuccess, printFail, printError, BOLD, RESET, DIM, GREEN, } from './output.js';
4
+ export async function runInstall(identifier, apiUrl, components, scope) {
5
+ // Check if already installed
6
+ const lock = readLock(scope);
7
+ if (lock.collections[identifier]) {
8
+ printError(`Collection "${identifier}" is already installed.`);
9
+ print(`Run ${DIM}npx claudepluginhub update ${identifier}${RESET} to update.`);
10
+ process.exit(1);
6
11
  }
7
- catch {
8
- return null;
12
+ print('');
13
+ printStep(`Installing ${components.length} component(s) to ${scope} scope...`);
14
+ const installed = {};
15
+ let succeeded = 0;
16
+ let skipped = 0;
17
+ let failed = 0;
18
+ for (const component of components) {
19
+ const result = await installComponent(component, scope, true);
20
+ if (result.status === 'installed') {
21
+ const key = componentKey(component.source, component.sourcePath);
22
+ installed[key] = result.lock;
23
+ succeeded++;
24
+ printSuccess(`${component.componentName} (${component.type})`);
25
+ }
26
+ else if (result.status === 'skipped') {
27
+ skipped++;
28
+ printFail(`${component.componentName} (${component.type})`, 'already exists');
29
+ }
30
+ else {
31
+ failed++;
32
+ printFail(`${component.componentName} (${component.type})`, 'download failed');
33
+ }
9
34
  }
10
- }
11
- export function addMarketplace(claudePath, url) {
12
- try {
13
- execFileSync(claudePath, ['plugin', 'marketplace', 'add', url], {
14
- stdio: 'pipe',
15
- timeout: 30_000,
16
- });
17
- return { ok: true };
35
+ // Write lock file (even for partial success)
36
+ if (succeeded > 0) {
37
+ addCollection(lock, identifier, apiUrl, scope, installed);
38
+ writeLock(scope, lock);
18
39
  }
19
- catch (err) {
20
- const message = err.message;
21
- // Marketplace already added is fine (idempotent)
22
- if (message.includes('already') || message.includes('exists')) {
23
- return { ok: true };
24
- }
25
- return { ok: false, error: message.split('\n')[0] };
40
+ print('');
41
+ if (failed === 0 && skipped === 0) {
42
+ print(`${GREEN}${BOLD}Done!${RESET} ${succeeded} component(s) installed.`);
26
43
  }
27
- }
28
- export function installPlugin(claudePath, installName, scope = 'project') {
29
- try {
30
- execFileSync(claudePath, ['plugin', 'install', installName, '--scope', scope], { stdio: 'pipe', timeout: 60_000 });
31
- return { ok: true };
44
+ else {
45
+ const parts = [`${succeeded} installed`];
46
+ if (skipped > 0)
47
+ parts.push(`${skipped} skipped`);
48
+ if (failed > 0)
49
+ parts.push(`${failed} failed`);
50
+ print(`${BOLD}Done.${RESET} ${parts.join(', ')}.`);
32
51
  }
33
- catch (err) {
34
- const message = err.message;
35
- // Already installed is fine (idempotent)
36
- if (message.includes('already installed')) {
37
- return { ok: true };
38
- }
39
- return { ok: false, error: message.split('\n')[0] };
52
+ print('');
53
+ if (failed > 0) {
54
+ process.exit(1);
40
55
  }
41
56
  }
package/dist/list.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runList(): void;
package/dist/list.js ADDED
@@ -0,0 +1,39 @@
1
+ import { readLock } from './lock.js';
2
+ import { print, BOLD, RESET, DIM, CYAN, GREEN, YELLOW } from './output.js';
3
+ const TYPE_COLORS = {
4
+ skill: GREEN,
5
+ command: CYAN,
6
+ agent: YELLOW,
7
+ hook: '\x1b[35m', // magenta
8
+ mcp: '\x1b[36m', // cyan
9
+ lsp: '\x1b[34m', // blue
10
+ };
11
+ function typeBadge(type) {
12
+ const color = TYPE_COLORS[type] ?? DIM;
13
+ return `${color}[${type}]${RESET}`;
14
+ }
15
+ export function runList() {
16
+ const scopes = ['user', 'project', 'local'];
17
+ let totalCollections = 0;
18
+ for (const scope of scopes) {
19
+ const lock = readLock(scope);
20
+ const collections = Object.values(lock.collections);
21
+ if (collections.length === 0)
22
+ continue;
23
+ totalCollections += collections.length;
24
+ print(`${BOLD}${scope}${RESET} scope:`);
25
+ for (const col of collections) {
26
+ const componentCount = Object.keys(col.components).length;
27
+ print(` ${CYAN}${col.identifier}${RESET} ${DIM}(${componentCount} component(s))${RESET}`);
28
+ for (const comp of Object.values(col.components)) {
29
+ print(` ${typeBadge(comp.type)} ${comp.componentName} ${DIM}${comp.source}${RESET}`);
30
+ }
31
+ }
32
+ print('');
33
+ }
34
+ if (totalCollections === 0) {
35
+ print(`No installed collections found.`);
36
+ print(`Run ${DIM}npx claudepluginhub <identifier>${RESET} to install.`);
37
+ print('');
38
+ }
39
+ }
package/dist/lock.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { Scope } from './prompts.js';
2
+ export interface ComponentLock {
3
+ source: string;
4
+ type: string;
5
+ componentName: string;
6
+ sourcePath: string;
7
+ installedPath: string;
8
+ hash: string;
9
+ configFile: string | null;
10
+ configKeys: string[] | null;
11
+ configFragment: unknown | null;
12
+ }
13
+ export interface CollectionLock {
14
+ identifier: string;
15
+ apiUrl: string;
16
+ scope: Scope;
17
+ installedAt: string;
18
+ updatedAt: string;
19
+ components: Record<string, ComponentLock>;
20
+ }
21
+ export interface LockFile {
22
+ version: 1;
23
+ collections: Record<string, CollectionLock>;
24
+ }
25
+ export declare function getLockFilePath(scope: Scope): string;
26
+ export declare function readLock(scope: Scope): LockFile;
27
+ export declare function writeLock(scope: Scope, lock: LockFile): void;
28
+ export declare function addCollection(lock: LockFile, identifier: string, apiUrl: string, scope: Scope, components: Record<string, ComponentLock>): LockFile;
29
+ export declare function removeCollection(lock: LockFile, identifier: string): LockFile;
30
+ export declare function componentKey(source: string, sourcePath: string): string;
package/dist/lock.js ADDED
@@ -0,0 +1,53 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ export function getLockFilePath(scope) {
5
+ if (scope === 'user') {
6
+ return join(homedir(), '.claude', '.claudepluginhub-lock.json');
7
+ }
8
+ const filename = scope === 'local'
9
+ ? '.claudepluginhub-lock.local.json'
10
+ : '.claudepluginhub-lock.json';
11
+ return join(process.cwd(), '.claude', filename);
12
+ }
13
+ export function readLock(scope) {
14
+ const path = getLockFilePath(scope);
15
+ if (!existsSync(path)) {
16
+ return { version: 1, collections: {} };
17
+ }
18
+ try {
19
+ const raw = readFileSync(path, 'utf-8');
20
+ return JSON.parse(raw);
21
+ }
22
+ catch {
23
+ return { version: 1, collections: {} };
24
+ }
25
+ }
26
+ export function writeLock(scope, lock) {
27
+ const path = getLockFilePath(scope);
28
+ const dir = dirname(path);
29
+ if (!existsSync(dir)) {
30
+ mkdirSync(dir, { recursive: true });
31
+ }
32
+ writeFileSync(path, JSON.stringify(lock, null, 2) + '\n');
33
+ }
34
+ export function addCollection(lock, identifier, apiUrl, scope, components) {
35
+ const now = new Date().toISOString();
36
+ const existing = lock.collections[identifier];
37
+ lock.collections[identifier] = {
38
+ identifier,
39
+ apiUrl,
40
+ scope,
41
+ installedAt: existing?.installedAt ?? now,
42
+ updatedAt: now,
43
+ components,
44
+ };
45
+ return lock;
46
+ }
47
+ export function removeCollection(lock, identifier) {
48
+ delete lock.collections[identifier];
49
+ return lock;
50
+ }
51
+ export function componentKey(source, sourcePath) {
52
+ return `${source}:${sourcePath}`;
53
+ }
package/dist/prompts.d.ts CHANGED
@@ -5,4 +5,5 @@ export interface SelectableItem {
5
5
  selected: boolean;
6
6
  }
7
7
  export declare function checkboxPrompt(title: string, items: SelectableItem[]): Promise<number[] | null>;
8
+ export declare function confirmPrompt(message: string): Promise<boolean>;
8
9
  export declare function scopePrompt(defaultScope?: Scope): Promise<Scope | null>;
package/dist/prompts.js CHANGED
@@ -82,6 +82,24 @@ export async function checkboxPrompt(title, items) {
82
82
  write(SHOW_CURSOR);
83
83
  }
84
84
  }
85
+ export async function confirmPrompt(message) {
86
+ if (!isInteractive())
87
+ return true;
88
+ write(`${message} ${DIM}[y/N]${RESET} `);
89
+ const wasRaw = process.stdin.isRaw;
90
+ process.stdin.setRawMode(true);
91
+ process.stdin.resume();
92
+ try {
93
+ const key = await readKey();
94
+ const str = key.toString().toLowerCase();
95
+ write(str + '\n');
96
+ return str === 'y';
97
+ }
98
+ finally {
99
+ process.stdin.setRawMode(wasRaw ?? false);
100
+ process.stdin.pause();
101
+ }
102
+ }
85
103
  const SCOPE_OPTIONS = [
86
104
  { value: 'user', label: 'User', hint: 'available across all projects' },
87
105
  {
@@ -0,0 +1,3 @@
1
+ import type { ComponentLock } from './lock.js';
2
+ export declare function removeComponentByLock(comp: ComponentLock): void;
3
+ export declare function runRemove(identifier: string): Promise<void>;
package/dist/remove.js ADDED
@@ -0,0 +1,117 @@
1
+ import { rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { join, resolve, sep } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { readLock, writeLock, removeCollection } from './lock.js';
5
+ import { printStep, printSuccess, printError, print, BOLD, RESET, DIM } from './output.js';
6
+ function deepEqual(a, b) {
7
+ return JSON.stringify(a) === JSON.stringify(b);
8
+ }
9
+ function removeConfigKeys(lock) {
10
+ if (!lock.configFile || !existsSync(lock.configFile))
11
+ return;
12
+ try {
13
+ const settings = JSON.parse(readFileSync(lock.configFile, 'utf-8'));
14
+ if (lock.type === 'hook' && lock.configFragment) {
15
+ // Remove hooks by deep equality
16
+ const hooks = settings.hooks;
17
+ if (hooks) {
18
+ const fragment = lock.configFragment;
19
+ for (const [event, handlers] of Object.entries(fragment)) {
20
+ if (!Array.isArray(hooks[event]))
21
+ continue;
22
+ hooks[event] = hooks[event].filter((h) => !handlers.some((fh) => deepEqual(h, fh)));
23
+ if (hooks[event].length === 0) {
24
+ delete hooks[event];
25
+ }
26
+ }
27
+ if (Object.keys(hooks).length === 0) {
28
+ delete settings.hooks;
29
+ }
30
+ }
31
+ }
32
+ else if (lock.configKeys) {
33
+ // Remove by key path (mcpServers.x, lspServers.x)
34
+ for (const keyPath of lock.configKeys) {
35
+ const parts = keyPath.split('.');
36
+ if (parts.length === 2) {
37
+ const [section, key] = parts;
38
+ const obj = settings[section];
39
+ if (obj) {
40
+ delete obj[key];
41
+ if (Object.keys(obj).length === 0) {
42
+ delete settings[section];
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ writeFileSync(lock.configFile, JSON.stringify(settings, null, 2) + '\n');
49
+ }
50
+ catch {
51
+ // Settings file corrupted — skip cleanup
52
+ }
53
+ }
54
+ function isSafeRemovePath(targetPath) {
55
+ const resolved = resolve(targetPath);
56
+ const userBase = resolve(join(homedir(), '.claude'));
57
+ const projectBase = resolve(join(process.cwd(), '.claude'));
58
+ return (resolved.startsWith(userBase + sep) ||
59
+ resolved.startsWith(projectBase + sep));
60
+ }
61
+ function removeFiles(lock) {
62
+ if (lock.configFile) {
63
+ // Config-based component — remove config keys, not the settings file
64
+ removeConfigKeys(lock);
65
+ return;
66
+ }
67
+ if (!isSafeRemovePath(lock.installedPath)) {
68
+ printError(`Skipping unsafe remove path: ${lock.installedPath}`);
69
+ return;
70
+ }
71
+ if (!existsSync(lock.installedPath))
72
+ return;
73
+ try {
74
+ if (lock.type === 'skill') {
75
+ rmSync(lock.installedPath, { recursive: true, force: true });
76
+ }
77
+ else {
78
+ rmSync(lock.installedPath, { force: true });
79
+ }
80
+ }
81
+ catch {
82
+ // Best effort
83
+ }
84
+ }
85
+ export function removeComponentByLock(comp) {
86
+ removeFiles(comp);
87
+ }
88
+ export async function runRemove(identifier) {
89
+ // Try all scopes to find the collection
90
+ const scopes = ['project', 'local', 'user'];
91
+ let foundScope = null;
92
+ let foundCollection = null;
93
+ for (const scope of scopes) {
94
+ const lock = readLock(scope);
95
+ if (lock.collections[identifier]) {
96
+ foundScope = scope;
97
+ foundCollection = lock.collections[identifier];
98
+ break;
99
+ }
100
+ }
101
+ if (!foundScope || !foundCollection) {
102
+ printError(`Collection "${identifier}" not found in any lock file.`);
103
+ print(`Run ${DIM}npx claudepluginhub list${RESET} to see installed collections.`);
104
+ process.exit(1);
105
+ }
106
+ printStep(`Removing ${Object.keys(foundCollection.components).length} component(s)...`);
107
+ for (const [, comp] of Object.entries(foundCollection.components)) {
108
+ removeFiles(comp);
109
+ printSuccess(`${comp.componentName} (${comp.type})`);
110
+ }
111
+ const lock = readLock(foundScope);
112
+ removeCollection(lock, identifier);
113
+ writeLock(foundScope, lock);
114
+ print('');
115
+ print(`${BOLD}Removed${RESET} ${identifier}`);
116
+ print('');
117
+ }
@@ -0,0 +1 @@
1
+ export declare function runUpdate(identifier?: string): Promise<void>;
package/dist/update.js ADDED
@@ -0,0 +1,117 @@
1
+ import { readLock, writeLock, componentKey } from './lock.js';
2
+ import { fetchMarketplace, flattenComponents } from './fetch.js';
3
+ import { installComponent } from './download.js';
4
+ import { removeComponentByLock } from './remove.js';
5
+ import { fetchDefaultBranch, fetchTree, findTreeSha, findBlobSha, } from './github.js';
6
+ import { print, printSuccess, printFail, printError, BOLD, RESET, DIM, } from './output.js';
7
+ async function getRemoteHash(component) {
8
+ const branch = await fetchDefaultBranch(component.source);
9
+ if (!branch)
10
+ return null;
11
+ const tree = await fetchTree(component.source, branch);
12
+ if (!tree)
13
+ return null;
14
+ if (component.type === 'skill') {
15
+ const dirPath = component.sourcePath.replace(/\/SKILL\.md$/, '');
16
+ return findTreeSha(tree, dirPath);
17
+ }
18
+ return findBlobSha(tree, component.sourcePath);
19
+ }
20
+ export async function runUpdate(identifier) {
21
+ const scopes = ['user', 'project', 'local'];
22
+ let found = false;
23
+ for (const scope of scopes) {
24
+ const lock = readLock(scope);
25
+ const collections = Object.values(lock.collections);
26
+ if (collections.length === 0)
27
+ continue;
28
+ for (const collection of collections) {
29
+ if (identifier && collection.identifier !== identifier)
30
+ continue;
31
+ found = true;
32
+ print(`${BOLD}Updating${RESET} ${collection.identifier}...`);
33
+ const manifest = await fetchMarketplace(collection.apiUrl);
34
+ if (!manifest) {
35
+ printError(`Failed to fetch manifest for ${collection.identifier}`);
36
+ continue;
37
+ }
38
+ const freshComponents = flattenComponents(manifest);
39
+ const freshKeys = new Set(freshComponents.map((c) => componentKey(c.source, c.sourcePath)));
40
+ const lockedKeys = new Set(Object.keys(collection.components));
41
+ const added = [];
42
+ const toCheck = [];
43
+ const removed = [];
44
+ for (const comp of freshComponents) {
45
+ const key = componentKey(comp.source, comp.sourcePath);
46
+ if (!lockedKeys.has(key)) {
47
+ added.push(comp);
48
+ }
49
+ else {
50
+ toCheck.push(comp);
51
+ }
52
+ }
53
+ for (const key of lockedKeys) {
54
+ if (!freshKeys.has(key)) {
55
+ removed.push(key);
56
+ }
57
+ }
58
+ // Check hashes before downloading to avoid unnecessary re-downloads
59
+ const changed = [];
60
+ for (const comp of toCheck) {
61
+ const key = componentKey(comp.source, comp.sourcePath);
62
+ const existing = collection.components[key];
63
+ const remoteHash = await getRemoteHash(comp);
64
+ if (remoteHash && remoteHash !== existing.hash) {
65
+ changed.push(comp);
66
+ }
67
+ }
68
+ if (added.length === 0 && removed.length === 0 && changed.length === 0) {
69
+ printSuccess('Already up to date');
70
+ continue;
71
+ }
72
+ // Remove deleted components (including config cleanup)
73
+ for (const key of removed) {
74
+ const comp = collection.components[key];
75
+ removeComponentByLock(comp);
76
+ delete collection.components[key];
77
+ printSuccess(`Removed ${comp.componentName} (${comp.type})`);
78
+ }
79
+ // Add new components
80
+ for (const comp of added) {
81
+ const result = await installComponent(comp, scope, false);
82
+ if (result.status === 'installed') {
83
+ const key = componentKey(comp.source, comp.sourcePath);
84
+ collection.components[key] = result.lock;
85
+ printSuccess(`Added ${comp.componentName} (${comp.type})`);
86
+ }
87
+ else {
88
+ printFail(`${comp.componentName} (${comp.type})`, 'download failed');
89
+ }
90
+ }
91
+ // Re-download changed components: clean up old files/config, then reinstall
92
+ for (const comp of changed) {
93
+ const key = componentKey(comp.source, comp.sourcePath);
94
+ const existing = collection.components[key];
95
+ removeComponentByLock(existing);
96
+ const result = await installComponent(comp, scope, false);
97
+ if (result.status === 'installed') {
98
+ collection.components[key] = result.lock;
99
+ printSuccess(`Updated ${comp.componentName} (${comp.type})`);
100
+ }
101
+ }
102
+ collection.updatedAt = new Date().toISOString();
103
+ lock.collections[collection.identifier] = collection;
104
+ writeLock(scope, lock);
105
+ }
106
+ }
107
+ if (!found) {
108
+ if (identifier) {
109
+ printError(`Collection "${identifier}" not found.`);
110
+ print(`Run ${DIM}npx claudepluginhub list${RESET} to see installed collections.`);
111
+ }
112
+ else {
113
+ print('No collections installed. Nothing to update.');
114
+ }
115
+ }
116
+ print('');
117
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "claudepluginhub",
3
- "version": "0.2.0",
4
- "description": "Install Claude Code plugins from ClaudePluginHub",
3
+ "version": "0.3.0",
4
+ "description": "Install Claude Code components from ClaudePluginHub",
5
5
  "bin": {
6
- "claudepluginhub": "./dist/index.js"
6
+ "claudepluginhub": "dist/index.js"
7
7
  },
8
8
  "type": "module",
9
9
  "engines": {