claude-code-marketplace 0.2.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/README.md +85 -0
- package/package.json +55 -0
- package/public/app.js +1088 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +9 -0
- package/public/index.html +151 -0
- package/public/manifest.json +14 -0
- package/public/style.css +1242 -0
- package/public/sw.js +33 -0
- package/server.js +733 -0
package/server.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { execFile } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const app = express();
|
|
9
|
+
app.use(express.json());
|
|
10
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
11
|
+
|
|
12
|
+
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
13
|
+
const PLUGINS_DIR = path.join(CLAUDE_DIR, 'plugins');
|
|
14
|
+
|
|
15
|
+
let _marketplaceCache = null;
|
|
16
|
+
function getCachedMarketplaces() {
|
|
17
|
+
if (!_marketplaceCache) _marketplaceCache = loadMarketplaces();
|
|
18
|
+
return _marketplaceCache;
|
|
19
|
+
}
|
|
20
|
+
function invalidateCache() { _marketplaceCache = null; }
|
|
21
|
+
|
|
22
|
+
function getArg(name) {
|
|
23
|
+
const idx = process.argv.findIndex(a => a.startsWith(`--${name}`));
|
|
24
|
+
if (idx === -1) return null;
|
|
25
|
+
const arg = process.argv[idx];
|
|
26
|
+
if (arg.includes('=')) return arg.split('=').slice(1).join('=');
|
|
27
|
+
return process.argv[idx + 1] || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let projectPath = getArg('project') || process.cwd();
|
|
31
|
+
if (projectPath.startsWith('~')) projectPath = projectPath.replace('~', os.homedir());
|
|
32
|
+
const PORT = parseInt(getArg('port') || process.env.PORT || '3457', 10);
|
|
33
|
+
|
|
34
|
+
function readJsonSafe(filePath) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readJsonKey(filePath, key) {
|
|
41
|
+
const data = readJsonSafe(filePath);
|
|
42
|
+
return data ? (data[key] || {}) : {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- Data loading (ported from lazyclaude) ---
|
|
46
|
+
|
|
47
|
+
function loadRegistry() {
|
|
48
|
+
const v2File = path.join(PLUGINS_DIR, 'installed_plugins.json');
|
|
49
|
+
const v2Data = readJsonSafe(v2File);
|
|
50
|
+
const installed = {};
|
|
51
|
+
if (v2Data && v2Data.plugins) {
|
|
52
|
+
for (const [pluginId, installations] of Object.entries(v2Data.plugins)) {
|
|
53
|
+
installed[pluginId] = installations.map(inst => ({
|
|
54
|
+
scope: inst.scope || 'user',
|
|
55
|
+
installPath: inst.installPath || '',
|
|
56
|
+
version: inst.version || 'unknown',
|
|
57
|
+
isLocal: inst.isLocal || false,
|
|
58
|
+
projectPath: inst.projectPath || null,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const userEnabled = readJsonKey(path.join(CLAUDE_DIR, 'settings.json'), 'enabledPlugins');
|
|
64
|
+
|
|
65
|
+
const projectConfigPath = projectPath ? path.join(projectPath, '.claude') : null;
|
|
66
|
+
const projectEnabled = projectConfigPath
|
|
67
|
+
? readJsonKey(path.join(projectConfigPath, 'settings.json'), 'enabledPlugins')
|
|
68
|
+
: {};
|
|
69
|
+
const localEnabled = projectConfigPath
|
|
70
|
+
? readJsonKey(path.join(projectConfigPath, 'settings.local.json'), 'enabledPlugins')
|
|
71
|
+
: {};
|
|
72
|
+
|
|
73
|
+
return { installed, userEnabled, projectEnabled, localEnabled };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildScopeData(registry) {
|
|
77
|
+
const installedScopes = {};
|
|
78
|
+
const scopeInstallPaths = {};
|
|
79
|
+
const scopeVersions = {};
|
|
80
|
+
const userInstalledIds = new Set();
|
|
81
|
+
const projectInstalledIds = new Set();
|
|
82
|
+
|
|
83
|
+
const resolvedRoot = projectPath ? path.resolve(projectPath) : null;
|
|
84
|
+
|
|
85
|
+
for (const [pid, installations] of Object.entries(registry.installed)) {
|
|
86
|
+
const scopes = [];
|
|
87
|
+
if (!scopeInstallPaths[pid]) {
|
|
88
|
+
scopeInstallPaths[pid] = {};
|
|
89
|
+
scopeVersions[pid] = {};
|
|
90
|
+
}
|
|
91
|
+
for (const inst of installations) {
|
|
92
|
+
const scope = inst.scope;
|
|
93
|
+
if (scope === 'user') {
|
|
94
|
+
userInstalledIds.add(pid);
|
|
95
|
+
scopes.push('user');
|
|
96
|
+
} else if (scope === 'project' || scope === 'local') {
|
|
97
|
+
if (resolvedRoot && inst.projectPath) {
|
|
98
|
+
try {
|
|
99
|
+
if (path.resolve(inst.projectPath) === resolvedRoot) {
|
|
100
|
+
projectInstalledIds.add(pid);
|
|
101
|
+
scopes.push(scope);
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
} else {
|
|
105
|
+
scopes.push(scope);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (inst.installPath) {
|
|
109
|
+
scopeInstallPaths[pid][scope] = inst.installPath;
|
|
110
|
+
scopeVersions[pid][scope] = inst.version;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (scopes.length) {
|
|
114
|
+
installedScopes[pid] = [...new Set(scopes)];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const enabledIds = new Set();
|
|
119
|
+
const allEnabled = { ...registry.userEnabled, ...registry.projectEnabled, ...registry.localEnabled };
|
|
120
|
+
const allInstalled = new Set(Object.keys(registry.installed));
|
|
121
|
+
for (const pid of allInstalled) {
|
|
122
|
+
if (allEnabled[pid] === false) continue;
|
|
123
|
+
enabledIds.add(pid);
|
|
124
|
+
}
|
|
125
|
+
for (const [pid, enabled] of Object.entries(allEnabled)) {
|
|
126
|
+
if (enabled) enabledIds.add(pid);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { installedScopes, scopeInstallPaths, scopeVersions, userInstalledIds, projectInstalledIds, enabledIds };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function loadMarketplaces() {
|
|
133
|
+
const knownFile = path.join(PLUGINS_DIR, 'known_marketplaces.json');
|
|
134
|
+
const known = readJsonSafe(knownFile);
|
|
135
|
+
if (!known) return [];
|
|
136
|
+
|
|
137
|
+
const registry = loadRegistry();
|
|
138
|
+
const scope = buildScopeData(registry);
|
|
139
|
+
|
|
140
|
+
const marketplaces = [];
|
|
141
|
+
for (const [name, entryData] of Object.entries(known)) {
|
|
142
|
+
const sourceData = entryData.source || {};
|
|
143
|
+
const installLocation = entryData.installLocation;
|
|
144
|
+
if (!installLocation) continue;
|
|
145
|
+
|
|
146
|
+
const marketplace = {
|
|
147
|
+
name,
|
|
148
|
+
source: {
|
|
149
|
+
type: sourceData.source || 'unknown',
|
|
150
|
+
repo: sourceData.repo || null,
|
|
151
|
+
path: sourceData.path || null,
|
|
152
|
+
url: sourceData.url || null,
|
|
153
|
+
},
|
|
154
|
+
installLocation,
|
|
155
|
+
lastUpdated: entryData.lastUpdated || null,
|
|
156
|
+
version: null,
|
|
157
|
+
plugins: [],
|
|
158
|
+
error: null,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const marketplaceJson = path.join(installLocation, '.claude-plugin', 'marketplace.json');
|
|
162
|
+
const mData = readJsonSafe(marketplaceJson);
|
|
163
|
+
if (!mData) {
|
|
164
|
+
marketplace.error = `marketplace.json not found at ${marketplaceJson}`;
|
|
165
|
+
marketplaces.push(marketplace);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
marketplace.version = mData.version || null;
|
|
169
|
+
marketplace.owner = mData.owner || null;
|
|
170
|
+
marketplace.description = mData.description || null;
|
|
171
|
+
|
|
172
|
+
for (const pd of (mData.plugins || [])) {
|
|
173
|
+
if (!pd.name) continue;
|
|
174
|
+
const fullId = `${pd.name}@${name}`;
|
|
175
|
+
const installedScopes = scope.installedScopes[fullId] || [];
|
|
176
|
+
const isInstalled = installedScopes.length > 0;
|
|
177
|
+
const isEnabled = scope.enabledIds.has(fullId);
|
|
178
|
+
|
|
179
|
+
const paths = scope.scopeInstallPaths[fullId] || {};
|
|
180
|
+
const versions = scope.scopeVersions[fullId] || {};
|
|
181
|
+
|
|
182
|
+
const scopeDetails = {};
|
|
183
|
+
for (const s of ['user', 'project', 'local']) {
|
|
184
|
+
if (installedScopes.includes(s)) {
|
|
185
|
+
const enabledMap = s === 'user' ? registry.userEnabled
|
|
186
|
+
: s === 'project' ? registry.projectEnabled
|
|
187
|
+
: registry.localEnabled;
|
|
188
|
+
const explicitlyDisabled = enabledMap[fullId] === false;
|
|
189
|
+
scopeDetails[s] = {
|
|
190
|
+
installed: true,
|
|
191
|
+
enabled: !explicitlyDisabled,
|
|
192
|
+
version: versions[s] || null,
|
|
193
|
+
installPath: paths[s] || null,
|
|
194
|
+
};
|
|
195
|
+
} else {
|
|
196
|
+
scopeDetails[s] = { installed: false, enabled: false, version: null, installPath: null };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let source = pd.source || '';
|
|
201
|
+
if (typeof source === 'object') source = source.url || JSON.stringify(source);
|
|
202
|
+
|
|
203
|
+
const compKeys = ['skills', 'commands', 'agents', 'mcpServers', 'hooks', 'lspServers'];
|
|
204
|
+
|
|
205
|
+
// Resolve plugin dir for filesystem-based component counts
|
|
206
|
+
let pluginDir = null;
|
|
207
|
+
for (const s of ['user', 'project', 'local']) {
|
|
208
|
+
const ip = scopeDetails[s]?.installPath;
|
|
209
|
+
const resolved = resolveInstallPath(ip);
|
|
210
|
+
if (resolved) { pluginDir = resolved; break; }
|
|
211
|
+
}
|
|
212
|
+
if (!pluginDir && installLocation) {
|
|
213
|
+
const pluginSubdir = path.join(installLocation, 'plugins', pd.name);
|
|
214
|
+
if (fs.existsSync(pluginSubdir)) pluginDir = pluginSubdir;
|
|
215
|
+
else if ((mData.plugins || []).length === 1) pluginDir = installLocation;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const fsComps = pluginDir ? countComponents(pluginDir) : null;
|
|
219
|
+
const components = {};
|
|
220
|
+
for (const k of compKeys) {
|
|
221
|
+
if (fsComps && Array.isArray(fsComps[k]) && fsComps[k].length > 0) {
|
|
222
|
+
components[k] = fsComps[k];
|
|
223
|
+
} else if (Array.isArray(pd[k]) && pd[k].length > 0) {
|
|
224
|
+
components[k] = pd[k].map(p => typeof p === 'string' ? path.basename(p) : (p.name || String(p)));
|
|
225
|
+
} else if (pd[k]) {
|
|
226
|
+
components[k] = Array.isArray(pd[k]) ? [] : [String(pd[k])];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const installedVersion = [scopeDetails.user, scopeDetails.project, scopeDetails.local]
|
|
231
|
+
.find(d => d?.version && d.version !== 'unknown')?.version || null;
|
|
232
|
+
|
|
233
|
+
let availableVersion = pd.version || null;
|
|
234
|
+
if (!availableVersion && installLocation) {
|
|
235
|
+
const sourceDir = pd.source || `plugins/${pd.name}`;
|
|
236
|
+
const pluginJson = path.join(installLocation, typeof sourceDir === 'string' ? sourceDir : pd.name, '.claude-plugin', 'plugin.json');
|
|
237
|
+
const pjData = readJsonSafe(pluginJson);
|
|
238
|
+
if (pjData?.version) availableVersion = pjData.version;
|
|
239
|
+
}
|
|
240
|
+
const hasUpdate = isInstalled && semverNewer(availableVersion, installedVersion);
|
|
241
|
+
|
|
242
|
+
marketplace.plugins.push({
|
|
243
|
+
name: pd.name,
|
|
244
|
+
fullId,
|
|
245
|
+
description: pd.description || '',
|
|
246
|
+
source,
|
|
247
|
+
version: installedVersion || availableVersion,
|
|
248
|
+
availableVersion,
|
|
249
|
+
hasUpdate,
|
|
250
|
+
isInstalled,
|
|
251
|
+
isEnabled: isInstalled ? isEnabled : true,
|
|
252
|
+
scopeDetails,
|
|
253
|
+
installedScopes,
|
|
254
|
+
components,
|
|
255
|
+
_pluginDir: pluginDir,
|
|
256
|
+
_fsComps: fsComps,
|
|
257
|
+
metadata: Object.fromEntries(
|
|
258
|
+
Object.entries(pd).filter(([k]) => !['name', 'description', 'source', 'version', ...compKeys].includes(k))
|
|
259
|
+
),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
marketplaces.push(marketplace);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Virtual marketplaces for user/project customizations
|
|
267
|
+
const userCustom = scanCustomizations(CLAUDE_DIR, 'user');
|
|
268
|
+
if (userCustom) marketplaces.unshift(userCustom);
|
|
269
|
+
|
|
270
|
+
if (projectPath) {
|
|
271
|
+
const projectClaudeDir = path.join(projectPath, '.claude');
|
|
272
|
+
if (fs.existsSync(projectClaudeDir)) {
|
|
273
|
+
const projectCustom = scanCustomizations(projectClaudeDir, 'project');
|
|
274
|
+
if (projectCustom) {
|
|
275
|
+
const insertIdx = userCustom ? 1 : 0;
|
|
276
|
+
marketplaces.splice(insertIdx, 0, projectCustom);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return marketplaces;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function countComponents(pluginDir, meta = {}) {
|
|
285
|
+
const result = { skills: [], commands: [], agents: [], mcpServers: [], hooks: [], lspServers: [] };
|
|
286
|
+
if (!pluginDir || !fs.existsSync(pluginDir)) return result;
|
|
287
|
+
|
|
288
|
+
// Skills: check custom paths from metadata, then default
|
|
289
|
+
const skillPaths = meta.skills
|
|
290
|
+
? (Array.isArray(meta.skills) ? meta.skills : [meta.skills])
|
|
291
|
+
: ['skills'];
|
|
292
|
+
for (const sp of skillPaths) {
|
|
293
|
+
const skillsDir = path.resolve(pluginDir, sp);
|
|
294
|
+
if (fs.existsSync(skillsDir) && fs.statSync(skillsDir).isDirectory()) {
|
|
295
|
+
try {
|
|
296
|
+
// If the path points to a skill dir (has SKILL.md), it's a single skill
|
|
297
|
+
if (fs.existsSync(path.join(skillsDir, 'SKILL.md'))) {
|
|
298
|
+
result.skills.push(path.basename(skillsDir));
|
|
299
|
+
} else {
|
|
300
|
+
const dirs = fs.readdirSync(skillsDir).filter(d =>
|
|
301
|
+
fs.statSync(path.join(skillsDir, d)).isDirectory()
|
|
302
|
+
);
|
|
303
|
+
result.skills.push(...dirs);
|
|
304
|
+
}
|
|
305
|
+
} catch {}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Commands: check custom path then default
|
|
310
|
+
const cmdPath = meta.commands || 'commands';
|
|
311
|
+
const cmdsDir = path.resolve(pluginDir, cmdPath);
|
|
312
|
+
if (fs.existsSync(cmdsDir) && fs.statSync(cmdsDir).isDirectory()) {
|
|
313
|
+
try { result.commands = findFiles(cmdsDir, '.md'); } catch {}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Agents: check custom path then default
|
|
317
|
+
const agentPath = meta.agents || 'agents';
|
|
318
|
+
const agentsDir = path.resolve(pluginDir, agentPath);
|
|
319
|
+
if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) {
|
|
320
|
+
try {
|
|
321
|
+
result.agents = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md'));
|
|
322
|
+
} catch {}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const configFiles = {};
|
|
326
|
+
|
|
327
|
+
// MCPs
|
|
328
|
+
const mcpPath = meta.mcpServers || '.mcp.json';
|
|
329
|
+
const mcpFile = path.resolve(pluginDir, mcpPath);
|
|
330
|
+
if (fs.existsSync(mcpFile) && fs.statSync(mcpFile).isFile()) {
|
|
331
|
+
const data = readJsonSafe(mcpFile);
|
|
332
|
+
if (data) {
|
|
333
|
+
result.mcpServers = Object.keys(data.mcpServers || data);
|
|
334
|
+
if (result.mcpServers.length) configFiles.mcpServers = mcpPath;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Hooks
|
|
339
|
+
const hooksPath = meta.hooks || path.join('hooks', 'hooks.json');
|
|
340
|
+
const hooksFile = path.resolve(pluginDir, hooksPath);
|
|
341
|
+
if (fs.existsSync(hooksFile) && fs.statSync(hooksFile).isFile()) {
|
|
342
|
+
const data = readJsonSafe(hooksFile);
|
|
343
|
+
if (data) {
|
|
344
|
+
const hooksObj = data.hooks || data;
|
|
345
|
+
result.hooks = Object.keys(hooksObj).filter(k => k !== 'description');
|
|
346
|
+
if (result.hooks.length) configFiles.hooks = hooksPath;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// LSP
|
|
351
|
+
const lspPath = meta.lspServers || '.lsp.json';
|
|
352
|
+
const lspFile = path.resolve(pluginDir, lspPath);
|
|
353
|
+
if (fs.existsSync(lspFile) && fs.statSync(lspFile).isFile()) {
|
|
354
|
+
const data = readJsonSafe(lspFile);
|
|
355
|
+
if (data) {
|
|
356
|
+
result.lspServers = Object.keys(data.lspServers || data);
|
|
357
|
+
if (result.lspServers.length) configFiles.lspServers = lspPath;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
result._configFiles = configFiles;
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function findFiles(dir, ext) {
|
|
366
|
+
const results = [];
|
|
367
|
+
try {
|
|
368
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
369
|
+
const full = path.join(dir, entry.name);
|
|
370
|
+
if (entry.isDirectory()) {
|
|
371
|
+
results.push(...findFiles(full, ext));
|
|
372
|
+
} else if (entry.name.endsWith(ext)) {
|
|
373
|
+
results.push(entry.name);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch {}
|
|
377
|
+
return results;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const VIRTUAL_PREFIX = '_custom/';
|
|
381
|
+
const SCOPE_LABELS = { user: 'User Customizations', project: 'Project Customizations' };
|
|
382
|
+
const EMPTY_SCOPE = { installed: false, enabled: false, version: null, installPath: null };
|
|
383
|
+
|
|
384
|
+
function scanCustomizations(basePath, scope) {
|
|
385
|
+
const components = countComponents(basePath);
|
|
386
|
+
|
|
387
|
+
// Strip .md extensions from command/agent names for cleaner display
|
|
388
|
+
components.commands = components.commands.map(n => n.replace(/\.md$/, ''));
|
|
389
|
+
components.agents = components.agents.map(n => n.replace(/\.md$/, ''));
|
|
390
|
+
|
|
391
|
+
// Fallback: read hooks from settings.json if hooks.json didn't have any
|
|
392
|
+
if (!components.hooks.length) {
|
|
393
|
+
const settings = readJsonSafe(path.join(basePath, 'settings.json'));
|
|
394
|
+
if (settings?.hooks) {
|
|
395
|
+
components.hooks = Object.keys(settings.hooks);
|
|
396
|
+
if (components.hooks.length) {
|
|
397
|
+
if (!components._configFiles) components._configFiles = {};
|
|
398
|
+
components._configFiles.hooks = 'settings.json';
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Add settings files as browsable entries
|
|
404
|
+
const settingsFiles = [];
|
|
405
|
+
if (fs.existsSync(path.join(basePath, 'settings.json'))) settingsFiles.push('settings.json');
|
|
406
|
+
if (fs.existsSync(path.join(basePath, 'settings.local.json'))) settingsFiles.push('settings.local.json');
|
|
407
|
+
if (settingsFiles.length) components.settings = settingsFiles;
|
|
408
|
+
|
|
409
|
+
const hasAny = Object.values(components).some(v => Array.isArray(v) && v.length > 0);
|
|
410
|
+
if (!hasAny) return null;
|
|
411
|
+
|
|
412
|
+
const label = SCOPE_LABELS[scope];
|
|
413
|
+
const activeScope = { installed: true, enabled: true, version: null, installPath: null };
|
|
414
|
+
const scopeDetails = {
|
|
415
|
+
user: scope === 'user' ? activeScope : EMPTY_SCOPE,
|
|
416
|
+
project: scope === 'project' ? activeScope : EMPTY_SCOPE,
|
|
417
|
+
local: EMPTY_SCOPE,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
name: label,
|
|
422
|
+
source: { type: 'directory', repo: null, path: basePath, url: null },
|
|
423
|
+
installLocation: basePath,
|
|
424
|
+
lastUpdated: null,
|
|
425
|
+
isVirtual: true,
|
|
426
|
+
error: null,
|
|
427
|
+
plugins: [{
|
|
428
|
+
name: label,
|
|
429
|
+
fullId: `${VIRTUAL_PREFIX}${scope}`,
|
|
430
|
+
description: `Custom skills, commands, agents, hooks, and servers from ${scope === 'user' ? '~/.claude/' : '.claude/'}`,
|
|
431
|
+
source: '',
|
|
432
|
+
version: null,
|
|
433
|
+
isInstalled: true,
|
|
434
|
+
isEnabled: true,
|
|
435
|
+
isVirtual: true,
|
|
436
|
+
scopeDetails,
|
|
437
|
+
installedScopes: [scope],
|
|
438
|
+
components,
|
|
439
|
+
_pluginDir: basePath,
|
|
440
|
+
_fsComps: components,
|
|
441
|
+
metadata: {},
|
|
442
|
+
}],
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function parseVer(v) {
|
|
447
|
+
const parts = String(v).split('.').map(Number);
|
|
448
|
+
return parts.some(isNaN) ? null : parts;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function semverCompare(a, b) {
|
|
452
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
453
|
+
const diff = (a[i] || 0) - (b[i] || 0);
|
|
454
|
+
if (diff !== 0) return diff;
|
|
455
|
+
}
|
|
456
|
+
return 0;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function semverNewer(available, installed) {
|
|
460
|
+
if (!available || !installed) return false;
|
|
461
|
+
const a = parseVer(available), b = parseVer(installed);
|
|
462
|
+
if (!a || !b) return false;
|
|
463
|
+
return semverCompare(a, b) > 0;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function findLatestVersionDir(parentDir) {
|
|
467
|
+
try {
|
|
468
|
+
const subdirs = fs.readdirSync(parentDir, { withFileTypes: true })
|
|
469
|
+
.filter(d => d.isDirectory())
|
|
470
|
+
.map(d => d.name);
|
|
471
|
+
if (!subdirs.length) return null;
|
|
472
|
+
subdirs.sort((a, b) => {
|
|
473
|
+
const pa = parseVer(a), pb = parseVer(b);
|
|
474
|
+
if (!pa || !pb) return 0;
|
|
475
|
+
return semverCompare(pb, pa);
|
|
476
|
+
});
|
|
477
|
+
return path.join(parentDir, subdirs[0]);
|
|
478
|
+
} catch { return null; }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function resolveInstallPath(ip) {
|
|
482
|
+
if (!ip) return null;
|
|
483
|
+
if (fs.existsSync(ip)) {
|
|
484
|
+
try {
|
|
485
|
+
if (fs.statSync(ip).isDirectory()) return ip;
|
|
486
|
+
} catch {}
|
|
487
|
+
}
|
|
488
|
+
// Try parent with version subdirectories (lazyclaude pattern)
|
|
489
|
+
const parent = path.dirname(ip);
|
|
490
|
+
if (fs.existsSync(parent)) {
|
|
491
|
+
const latest = findLatestVersionDir(parent);
|
|
492
|
+
if (latest) return latest;
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function findPlugin(fullId, marketplaces) {
|
|
498
|
+
for (const m of marketplaces) {
|
|
499
|
+
const p = m.plugins?.find(p => p.fullId === fullId);
|
|
500
|
+
if (p) return p;
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function resolvePluginDir(fullId, marketplaces) {
|
|
506
|
+
return findPlugin(fullId, marketplaces)?._pluginDir || null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// --- API Routes ---
|
|
510
|
+
|
|
511
|
+
app.get('/api/marketplaces', (req, res) => {
|
|
512
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
513
|
+
try {
|
|
514
|
+
res.json(getCachedMarketplaces());
|
|
515
|
+
} catch (err) {
|
|
516
|
+
res.status(500).json({ error: err.message });
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
app.get('/api/plugins/:pluginId/components', (req, res) => {
|
|
521
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
522
|
+
const pluginId = decodeURIComponent(req.params.pluginId);
|
|
523
|
+
const mktData = getCachedMarketplaces();
|
|
524
|
+
const plugin = findPlugin(pluginId, mktData);
|
|
525
|
+
|
|
526
|
+
if (plugin?.isVirtual) {
|
|
527
|
+
return res.json({ ...plugin.components, _pluginDir: plugin._pluginDir });
|
|
528
|
+
}
|
|
529
|
+
if (!plugin?._pluginDir) return res.status(404).json({ error: 'Plugin directory not found', pluginId });
|
|
530
|
+
|
|
531
|
+
const comps = plugin._fsComps || countComponents(plugin._pluginDir, plugin.metadata);
|
|
532
|
+
comps._pluginDir = plugin._pluginDir;
|
|
533
|
+
res.json(comps);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
app.get('/api/plugins/:pluginId/preview/*', (req, res) => {
|
|
537
|
+
const pluginId = decodeURIComponent(req.params.pluginId);
|
|
538
|
+
const relPath = req.params[0];
|
|
539
|
+
const marketplaces = getCachedMarketplaces();
|
|
540
|
+
const pluginDir = resolvePluginDir(pluginId, marketplaces);
|
|
541
|
+
if (!pluginDir) return res.status(404).json({ error: 'Plugin not found' });
|
|
542
|
+
|
|
543
|
+
const fullPath = path.resolve(pluginDir, relPath);
|
|
544
|
+
if (!fullPath.startsWith(path.resolve(pluginDir))) {
|
|
545
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
const stat = fs.statSync(fullPath);
|
|
550
|
+
if (stat.isDirectory()) {
|
|
551
|
+
const entries = fs.readdirSync(fullPath, { withFileTypes: true }).map(e => ({
|
|
552
|
+
name: e.name,
|
|
553
|
+
isDirectory: e.isDirectory(),
|
|
554
|
+
}));
|
|
555
|
+
res.json({ type: 'directory', entries });
|
|
556
|
+
} else {
|
|
557
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
558
|
+
res.json({ type: 'file', content, name: path.basename(fullPath) });
|
|
559
|
+
}
|
|
560
|
+
} catch {
|
|
561
|
+
res.status(404).json({ error: 'Path not found' });
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
app.get('/api/project', (req, res) => {
|
|
566
|
+
res.json({ path: projectPath });
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
app.put('/api/project', (req, res) => {
|
|
570
|
+
const newPath = req.body.path;
|
|
571
|
+
if (!newPath) return res.status(400).json({ error: 'path required' });
|
|
572
|
+
const resolved = newPath.startsWith('~') ? newPath.replace('~', os.homedir()) : newPath;
|
|
573
|
+
if (!fs.existsSync(resolved)) return res.status(400).json({ error: 'Directory does not exist' });
|
|
574
|
+
projectPath = resolved;
|
|
575
|
+
res.json({ path: projectPath });
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
app.post('/api/refresh', (req, res) => {
|
|
579
|
+
invalidateCache();
|
|
580
|
+
res.json({ ok: true });
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
function runClaudePlugin(args) {
|
|
584
|
+
return new Promise((resolve, reject) => {
|
|
585
|
+
execFile('claude', ['plugin', ...args], { timeout: 30000, shell: true }, (err, stdout, stderr) => {
|
|
586
|
+
if (err) return reject(new Error(stderr || err.message));
|
|
587
|
+
resolve(stdout.trim());
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function rejectVirtual(pluginId, res) {
|
|
593
|
+
if (pluginId?.startsWith(VIRTUAL_PREFIX)) {
|
|
594
|
+
res.status(400).json({ error: 'Cannot modify virtual customization entries' });
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
app.post('/api/plugins/install', async (req, res) => {
|
|
601
|
+
const { pluginId, scope } = req.body;
|
|
602
|
+
if (!pluginId) return res.status(400).json({ error: 'pluginId required' });
|
|
603
|
+
if (rejectVirtual(pluginId, res)) return;
|
|
604
|
+
try {
|
|
605
|
+
const args = ['install', pluginId];
|
|
606
|
+
if (scope) args.push('--scope', scope);
|
|
607
|
+
const output = await runClaudePlugin(args);
|
|
608
|
+
invalidateCache();
|
|
609
|
+
res.json({ ok: true, output });
|
|
610
|
+
} catch (err) {
|
|
611
|
+
res.status(500).json({ error: err.message });
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
app.post('/api/plugins/uninstall', async (req, res) => {
|
|
616
|
+
const { pluginId, scope } = req.body;
|
|
617
|
+
if (!pluginId) return res.status(400).json({ error: 'pluginId required' });
|
|
618
|
+
if (rejectVirtual(pluginId, res)) return;
|
|
619
|
+
try {
|
|
620
|
+
const args = ['uninstall', pluginId];
|
|
621
|
+
if (scope) args.push('--scope', scope);
|
|
622
|
+
const output = await runClaudePlugin(args);
|
|
623
|
+
invalidateCache();
|
|
624
|
+
res.json({ ok: true, output });
|
|
625
|
+
} catch (err) {
|
|
626
|
+
res.status(500).json({ error: err.message });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
app.post('/api/plugins/enable', async (req, res) => {
|
|
631
|
+
const { pluginId, scope } = req.body;
|
|
632
|
+
if (!pluginId) return res.status(400).json({ error: 'pluginId required' });
|
|
633
|
+
if (rejectVirtual(pluginId, res)) return;
|
|
634
|
+
try {
|
|
635
|
+
const args = ['enable', pluginId];
|
|
636
|
+
if (scope) args.push('--scope', scope);
|
|
637
|
+
const output = await runClaudePlugin(args);
|
|
638
|
+
invalidateCache();
|
|
639
|
+
res.json({ ok: true, output });
|
|
640
|
+
} catch (err) {
|
|
641
|
+
res.status(500).json({ error: err.message });
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
app.post('/api/plugins/disable', async (req, res) => {
|
|
646
|
+
const { pluginId, scope } = req.body;
|
|
647
|
+
if (!pluginId) return res.status(400).json({ error: 'pluginId required' });
|
|
648
|
+
if (rejectVirtual(pluginId, res)) return;
|
|
649
|
+
try {
|
|
650
|
+
const args = ['disable', pluginId];
|
|
651
|
+
if (scope) args.push('--scope', scope);
|
|
652
|
+
const output = await runClaudePlugin(args);
|
|
653
|
+
invalidateCache();
|
|
654
|
+
res.json({ ok: true, output });
|
|
655
|
+
} catch (err) {
|
|
656
|
+
res.status(500).json({ error: err.message });
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
app.post('/api/plugins/update', async (req, res) => {
|
|
661
|
+
const { pluginId, scope } = req.body;
|
|
662
|
+
if (!pluginId) return res.status(400).json({ error: 'pluginId required' });
|
|
663
|
+
try {
|
|
664
|
+
const args = ['update', pluginId];
|
|
665
|
+
if (scope) args.push('--scope', scope);
|
|
666
|
+
const output = await runClaudePlugin(args);
|
|
667
|
+
invalidateCache();
|
|
668
|
+
res.json({ ok: true, output });
|
|
669
|
+
} catch (err) {
|
|
670
|
+
res.status(500).json({ error: err.message });
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
app.post('/api/marketplace/add', async (req, res) => {
|
|
675
|
+
const { source } = req.body;
|
|
676
|
+
if (!source) return res.status(400).json({ error: 'source required' });
|
|
677
|
+
try {
|
|
678
|
+
const output = await runClaudePlugin(['marketplace', 'add', source]);
|
|
679
|
+
invalidateCache();
|
|
680
|
+
res.json({ ok: true, output });
|
|
681
|
+
} catch (err) {
|
|
682
|
+
res.status(500).json({ error: err.message });
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
app.post('/api/marketplace/remove', async (req, res) => {
|
|
687
|
+
const { name } = req.body;
|
|
688
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
689
|
+
try {
|
|
690
|
+
const output = await runClaudePlugin(['marketplace', 'remove', name]);
|
|
691
|
+
invalidateCache();
|
|
692
|
+
res.json({ ok: true, output });
|
|
693
|
+
} catch (err) {
|
|
694
|
+
res.status(500).json({ error: err.message });
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
app.post('/api/marketplace/update', async (req, res) => {
|
|
699
|
+
const { name } = req.body;
|
|
700
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
701
|
+
try {
|
|
702
|
+
const output = await runClaudePlugin(['marketplace', 'update', name]);
|
|
703
|
+
invalidateCache();
|
|
704
|
+
res.json({ ok: true, output });
|
|
705
|
+
} catch (err) {
|
|
706
|
+
res.status(500).json({ error: err.message });
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// --- Start ---
|
|
711
|
+
|
|
712
|
+
const server = app.listen(PORT, () => {
|
|
713
|
+
const actual = server.address().port;
|
|
714
|
+
console.log(`Claude Code Marketplace running at http://localhost:${actual}`);
|
|
715
|
+
if (process.argv.includes('--open')) {
|
|
716
|
+
import('open').then(m => m.default(`http://localhost:${actual}`));
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
server.on('error', (err) => {
|
|
721
|
+
if (err.code === 'EADDRINUSE') {
|
|
722
|
+
console.log(`Port ${PORT} in use, trying random port...`);
|
|
723
|
+
const fallback = app.listen(0, () => {
|
|
724
|
+
const actual = fallback.address().port;
|
|
725
|
+
console.log(`Claude Code Marketplace running at http://localhost:${actual}`);
|
|
726
|
+
if (process.argv.includes('--open')) {
|
|
727
|
+
import('open').then(m => m.default(`http://localhost:${actual}`));
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
} else {
|
|
731
|
+
throw err;
|
|
732
|
+
}
|
|
733
|
+
});
|