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.
- package/dist/download.d.ts +21 -0
- package/dist/download.js +264 -0
- package/dist/fetch.d.ts +2 -0
- package/dist/fetch.js +78 -2
- package/dist/github.d.ts +19 -0
- package/dist/github.js +93 -0
- package/dist/index.js +109 -85
- package/dist/install.d.ts +3 -8
- package/dist/install.js +49 -34
- package/dist/list.d.ts +1 -0
- package/dist/list.js +39 -0
- package/dist/lock.d.ts +30 -0
- package/dist/lock.js +53 -0
- package/dist/prompts.d.ts +1 -0
- package/dist/prompts.js +18 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +117 -0
- package/dist/update.d.ts +1 -0
- package/dist/update.js +117 -0
- package/package.json +3 -3
|
@@ -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>;
|
package/dist/download.js
ADDED
|
@@ -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('
|
|
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
|
|
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
|
+
}
|
package/dist/github.d.ts
ADDED
|
@@ -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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 <
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
50
|
-
|
|
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 {
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
const url = resolveMarketplaceUrl(input);
|
|
97
|
+
// Resolve identifier to API URL
|
|
98
|
+
const url = resolveMarketplaceUrl(identifier);
|
|
68
99
|
if (!url) {
|
|
69
|
-
printError(`Invalid
|
|
100
|
+
printError(`Invalid identifier: ${identifier}`);
|
|
70
101
|
print('');
|
|
71
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
//
|
|
94
|
-
let
|
|
95
|
-
if (!yes &&
|
|
121
|
+
// Component selection (interactive)
|
|
122
|
+
let selectedComponents = allComponents;
|
|
123
|
+
if (!yes && allComponents.length > 1) {
|
|
96
124
|
print('');
|
|
97
|
-
const items =
|
|
98
|
-
label:
|
|
99
|
-
description:
|
|
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
|
|
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
|
-
|
|
108
|
-
if (
|
|
109
|
-
printError('No
|
|
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
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
{
|
package/dist/remove.d.ts
ADDED
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
|
+
}
|
package/dist/update.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Install Claude Code
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Install Claude Code components from ClaudePluginHub",
|
|
5
5
|
"bin": {
|
|
6
|
-
"claudepluginhub": "
|
|
6
|
+
"claudepluginhub": "dist/index.js"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"engines": {
|