claude-code-hub 1.11.0 → 1.12.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 +3 -3
- package/lib/apps.js +125 -25
- package/lib/ext.js +470 -0
- package/lib/hub-config.js +15 -0
- package/lib/install.js +60 -19
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +25 -10
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/SKILL.md +2 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensions.md +56 -0
- package/public/app.js +231 -76
- package/public/index.html +20 -13
- package/server.js +90 -43
package/lib/ext.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { parseArgs } = require('node:util');
|
|
7
|
+
const { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, readApp } = require('./apps');
|
|
8
|
+
const { isContained } = require('./contain');
|
|
9
|
+
const { writeConfigFile } = require('./hub-config');
|
|
10
|
+
const { claudeEnv, hasClaude, installPlugin, reinstallPlugin, uninstallPlugin, report, run } = require('./install');
|
|
11
|
+
const { pluginChecker } = require('./plugin-status');
|
|
12
|
+
const { isNewer } = require('./update-check');
|
|
13
|
+
|
|
14
|
+
const USAGE = `Usage: claude-code-hub ext <command>
|
|
15
|
+
|
|
16
|
+
ext install <name> Install an extension from the registry, https://nikiforovall.blog/claude-code-hub/extensions/
|
|
17
|
+
ext install github:<owner>/<repo>[/<folder>][@<commit or branch>]
|
|
18
|
+
Clone the extension into the hub dir, add it to the hub and install its plugin
|
|
19
|
+
ext install <dir> Run the extension in <dir> from the hub and install its plugin
|
|
20
|
+
ext install <id> Install the plugin of an app the hub runs, in a config dir
|
|
21
|
+
ext list List the extensions and the state of their plugins
|
|
22
|
+
ext update <id> Get the latest code of a GitHub extension, and install its plugin again
|
|
23
|
+
ext remove <id> Remove the extension's plugin and its entry, and the clone of a GitHub extension
|
|
24
|
+
|
|
25
|
+
--hub-dir <path> The hub dir (default CLAUDE_HUB_DIR, else ~/.claude-hub)
|
|
26
|
+
--dir <path> The config dir for plugin steps (default CLAUDE_CONFIG_DIR, else ~/.claude)`;
|
|
27
|
+
|
|
28
|
+
const RESTART = ' Restart the hub to load the change.';
|
|
29
|
+
const REGISTRY = 'https://nikiforovall.blog/claude-code-hub/extensions.json';
|
|
30
|
+
|
|
31
|
+
function readConfig(hubDir) {
|
|
32
|
+
const file = path.join(hubDir, 'config.json');
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (e.code === 'ENOENT') return { file, config: {} };
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return { file, config: JSON.parse(raw) };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
throw new Error(`${file}: ${e.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkRequires(ctx, { id, requires }, config) {
|
|
48
|
+
if (!requires) return;
|
|
49
|
+
const fail = (why) => {
|
|
50
|
+
throw new Error(`Cannot use ${id}: ${why}.`);
|
|
51
|
+
};
|
|
52
|
+
if (requires.hub && isNewer(requires.hub, ctx.hubVersion)) fail(`it needs hub ${requires.hub}, this hub is ${ctx.hubVersion}`);
|
|
53
|
+
if (requires.node && isNewer(requires.node, ctx.nodeVersion)) fail(`it needs Node ${requires.node}, this is ${ctx.nodeVersion}`);
|
|
54
|
+
const enabled = selectApps(config.apps, ctx.hubDir).apps.map((a) => a.id);
|
|
55
|
+
const off = (requires.apps ?? []).filter((app) => !enabled.includes(app));
|
|
56
|
+
if (off.length) fail(`it needs ${off.join(', ')} enabled in the hub`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isExt = (e) => typeof e?.id === 'string' && typeof e.source === 'string';
|
|
60
|
+
|
|
61
|
+
function extEntry(config, id) {
|
|
62
|
+
if (!id) throw new Error(USAGE);
|
|
63
|
+
const entry = (config.apps ?? []).find((e) => isExt(e) && e.id === id);
|
|
64
|
+
if (!entry) throw new Error(`"${id}" is not an extension. ext list shows them.`);
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isGithub = (entry) => entry.source.startsWith('github:');
|
|
69
|
+
|
|
70
|
+
function managedEntry(config, id, verb) {
|
|
71
|
+
const entry = extEntry(config, id);
|
|
72
|
+
if (entry.source !== 'local' && !isGithub(entry)) throw new Error(`${id} has source "${entry.source}": only a local or GitHub extension can ${verb}.`);
|
|
73
|
+
return entry;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rootOf = (ctx, entry) => path.resolve(ctx.hubDir, entry.path);
|
|
77
|
+
|
|
78
|
+
const GITHUB_RE = /^github:([\w.-]+)\/([\w.-]+)((?:\/[\w.-]+)*)(?:@(\w[\w./-]*))?$/;
|
|
79
|
+
|
|
80
|
+
function parseGithub(target) {
|
|
81
|
+
const m = GITHUB_RE.exec(target);
|
|
82
|
+
if (!m) throw new Error(`"${target}" is not a GitHub source. Write it as github:<owner>/<repo>[/<folder>][@<commit or branch>].`);
|
|
83
|
+
const [, owner, repo, sub, ref] = m;
|
|
84
|
+
return { owner, repo, subdir: sub.slice(1), ref, source: `github:${owner}/${repo}${sub}` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function git(args, cwd) {
|
|
88
|
+
try {
|
|
89
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (e.code === 'ENOENT') throw new Error('git is not on PATH. Install git to install an extension from GitHub.');
|
|
92
|
+
throw new Error(`git ${args[0]} failed: ${e.stderr?.trim() || e.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveRef(dir, ref) {
|
|
97
|
+
for (const name of ref ? [`origin/${ref}`, ref] : ['origin/HEAD']) {
|
|
98
|
+
try {
|
|
99
|
+
return git(['rev-parse', '--verify', '--quiet', `${name}^{commit}`], dir);
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`${ref} is not a branch or a commit of the repo.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkout(dir, ref) {
|
|
106
|
+
const sha = resolveRef(dir, ref);
|
|
107
|
+
git(['checkout', '--quiet', '--detach', sha], dir);
|
|
108
|
+
return sha;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A partial clone with only the extension's folder checked out, because an extension can live in a big repo.
|
|
112
|
+
function cloneSource(ctx, src) {
|
|
113
|
+
const base = path.join(ctx.hubDir, 'extensions');
|
|
114
|
+
fs.mkdirSync(base, { recursive: true });
|
|
115
|
+
const dir = fs.mkdtempSync(path.join(base, '.clone-'));
|
|
116
|
+
try {
|
|
117
|
+
git(['clone', '--quiet', '--filter=blob:none', '--no-checkout', `${ctx.githubBase}/${src.owner}/${src.repo}`, dir]);
|
|
118
|
+
if (src.subdir) git(['sparse-checkout', 'set', src.subdir], dir);
|
|
119
|
+
return { dir, sha: checkout(dir, src.ref) };
|
|
120
|
+
} catch (e) {
|
|
121
|
+
removeDir(dir);
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function removeDir(dir) {
|
|
127
|
+
try {
|
|
128
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
129
|
+
return true;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.log(` Could not delete ${dir} (${e.code ?? e.message}). Delete it by hand.`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Install scripts are off: they would run code from the clone before anyone has looked at it.
|
|
137
|
+
function installDeps(ctx, root) {
|
|
138
|
+
let pkg;
|
|
139
|
+
try {
|
|
140
|
+
pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
141
|
+
} catch {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (!Object.keys(pkg?.dependencies ?? {}).length) return true;
|
|
145
|
+
return report('Install dependencies', ctx.exec(`npm install --omit=dev --ignore-scripts --no-audit --no-fund --prefix "${root}"`));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const cloneOf = (ctx, entry) => path.resolve(git(['rev-parse', '--show-toplevel'], rootOf(ctx, entry)));
|
|
149
|
+
const short = (sha) => sha.slice(0, 7);
|
|
150
|
+
|
|
151
|
+
// The registered marketplace with the plugin's marketplace name, when it comes from another folder.
|
|
152
|
+
function marketplaceClash(plugin, env, exec) {
|
|
153
|
+
const res = exec('claude plugin marketplace list --json', env);
|
|
154
|
+
if (!res.ok) return null;
|
|
155
|
+
let list;
|
|
156
|
+
try {
|
|
157
|
+
list = JSON.parse(res.output);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const { name, dir } = plugin.marketplace;
|
|
162
|
+
const same = Array.isArray(list) ? list.find((m) => m?.name === name) : undefined;
|
|
163
|
+
if (!same || (typeof same.path === 'string' && isContained(same.path, dir) && isContained(dir, same.path))) return null;
|
|
164
|
+
return `marketplace "${name}" is registered from ${same.path ?? same.repo ?? same.source}. Remove it with: claude plugin marketplace remove ${name}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A command that starts with one of the app's own bins runs that bin through node, because the bin is on
|
|
168
|
+
// PATH only when the app is installed on its own, not when it comes with the hub.
|
|
169
|
+
function appCommand(cmd, root) {
|
|
170
|
+
const [name, ...rest] = cmd.split(' ');
|
|
171
|
+
let pkg;
|
|
172
|
+
try {
|
|
173
|
+
pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
174
|
+
} catch {
|
|
175
|
+
return cmd;
|
|
176
|
+
}
|
|
177
|
+
const file = typeof pkg?.bin === 'string' ? pkg.name === name && pkg.bin : pkg?.bin?.[name];
|
|
178
|
+
if (typeof file !== 'string') return cmd;
|
|
179
|
+
return [`"${process.execPath}"`, `"${path.join(root, file)}"`, ...rest].join(' ');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function pluginStep(ctx, plugin, step, root) {
|
|
183
|
+
const configDir = ctx.configDir();
|
|
184
|
+
const env = claudeEnv(configDir);
|
|
185
|
+
console.log(`\n Plugin ${plugin.id}\n Config dir: ${configDir}\n`);
|
|
186
|
+
if (!hasClaude(env, ctx.exec)) return false;
|
|
187
|
+
if (plugin.custom && step !== 'uninstall') return report(plugin.custom, ctx.exec(appCommand(plugin.custom, root), env));
|
|
188
|
+
if (step === 'install') {
|
|
189
|
+
const clash = marketplaceClash(plugin, env, ctx.exec);
|
|
190
|
+
if (clash) {
|
|
191
|
+
console.log(` Cannot register the plugin: ${clash}`);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const args = { marketplaceDir: plugin.marketplace.dir, marketplace: plugin.marketplace.name, pluginId: plugin.id, env, exec: ctx.exec };
|
|
196
|
+
return { install: installPlugin, reinstall: reinstallPlugin, uninstall: uninstallPlugin }[step](args);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// An id the hub runs gets its plugin installed. A folder is added to the hub, with its plugin, unless the
|
|
200
|
+
// hub already runs it from there; then only the plugin is installed. Any other name is looked up in the registry.
|
|
201
|
+
function install(ctx, target) {
|
|
202
|
+
if (!target) throw new Error(USAGE);
|
|
203
|
+
const { file, config } = readConfig(ctx.hubDir);
|
|
204
|
+
if (target.startsWith('github:')) return installGithub(ctx, target, file, config);
|
|
205
|
+
if (/^(npm|https?):/.test(target)) throw new Error('Only a registry name, a github: source or a local folder installs. ext --help shows the forms.');
|
|
206
|
+
const app = selectApps(config.apps, ctx.hubDir).apps.find((a) => a.id === target);
|
|
207
|
+
if (app) return installAppPlugin(ctx, app);
|
|
208
|
+
if (!fs.existsSync(target) && ID_RE.test(target)) return installRegistry(ctx, target, file, config);
|
|
209
|
+
return installFolder(ctx, target, file, config);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// fetch is async and ext runs synchronously, so a child node reads the index.
|
|
213
|
+
const FETCH =
|
|
214
|
+
'fetch(process.argv[1]).then(async (r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); process.stdout.write(await r.text()); })' +
|
|
215
|
+
'.catch((e) => { process.stderr.write(e.cause?.message ?? e.message); process.exitCode = 1; })';
|
|
216
|
+
|
|
217
|
+
function readRegistry(where) {
|
|
218
|
+
let text;
|
|
219
|
+
try {
|
|
220
|
+
text = /^https?:/.test(where)
|
|
221
|
+
? execFileSync(process.execPath, ['-e', FETCH, where], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 })
|
|
222
|
+
: fs.readFileSync(where, 'utf8');
|
|
223
|
+
} catch (e) {
|
|
224
|
+
throw new Error(`Cannot read the registry ${where}: ${e.stderr?.trim() || e.message}`);
|
|
225
|
+
}
|
|
226
|
+
let list;
|
|
227
|
+
try {
|
|
228
|
+
list = JSON.parse(text).extensions;
|
|
229
|
+
} catch {}
|
|
230
|
+
if (!Array.isArray(list)) throw new Error(`The registry ${where} has no extensions list.`);
|
|
231
|
+
return list;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The entry keeps the GitHub source, not the name, so a later change to the registry does not move an installed extension.
|
|
235
|
+
function installRegistry(ctx, name, file, config) {
|
|
236
|
+
const found = readRegistry(ctx.registry).find((x) => x?.id === name);
|
|
237
|
+
if (!found) throw new Error(`"${name}" is not an app the hub runs, a folder, or an extension in the registry ${ctx.registry}.`);
|
|
238
|
+
const target = found.pinned ? `${found.source}@${found.sha}` : found.source;
|
|
239
|
+
console.log(` ${name}: ${target}, from the registry`);
|
|
240
|
+
return installGithub(ctx, target, file, config, name);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The entry the hub has for the id when `same` accepts it; throws when the id is built-in or taken.
|
|
244
|
+
function claimId(id, config, file, same) {
|
|
245
|
+
if (APPS.some((a) => a.id === id)) throw new Error(`Cannot install ${id}: it is the id of a built-in app.`);
|
|
246
|
+
const entry = (config.apps ?? []).find((e) => e?.id === id);
|
|
247
|
+
if (entry && !(isExt(entry) && same(entry))) throw new Error(`Cannot install ${id}: ${file} already has an entry for it.`);
|
|
248
|
+
return entry;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Installs the plugin, then adds the entry. Returns false with nothing written when the plugin fails.
|
|
252
|
+
function addExtension(ctx, root, file, config, fields) {
|
|
253
|
+
const ext = readApp(root);
|
|
254
|
+
const { id } = ext.manifest;
|
|
255
|
+
if (ext.plugin && !pluginStep(ctx, ext.plugin, 'install', root)) {
|
|
256
|
+
console.log(`\n Plugin install failed, so ${id} is not added. See the errors above.`);
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
writeConfigFile(file, { ...config, apps: [...(config.apps ?? []), { id, ...fields }] });
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function installGithub(ctx, target, file, config, name) {
|
|
264
|
+
const src = parseGithub(target);
|
|
265
|
+
const { dir, sha } = cloneSource(ctx, src);
|
|
266
|
+
let id;
|
|
267
|
+
try {
|
|
268
|
+
const ext = readApp(path.join(dir, src.subdir));
|
|
269
|
+
id = ext.manifest.id;
|
|
270
|
+
if (name && id !== name) throw new Error(`The registry lists ${name} at ${src.source}, but its hub-app.json has the id ${id}.`);
|
|
271
|
+
const entry = claimId(id, config, file, (e) => e.source === src.source);
|
|
272
|
+
if (entry) {
|
|
273
|
+
removeDir(dir);
|
|
274
|
+
const root = rootOf(ctx, entry);
|
|
275
|
+
const { plugin } = readApp(root, id);
|
|
276
|
+
if (plugin && !pluginStep(ctx, plugin, 'install', root)) return false;
|
|
277
|
+
console.log(`\n ${id} is already in the hub, at ${short(entry.sha ?? '')}. ext update ${id} gets the latest code.`);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
checkRequires(ctx, ext.manifest, config);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
removeDir(dir);
|
|
283
|
+
throw e;
|
|
284
|
+
}
|
|
285
|
+
const clone = path.join(ctx.hubDir, 'extensions', id);
|
|
286
|
+
if (fs.existsSync(clone) && !removeDir(clone)) {
|
|
287
|
+
removeDir(dir);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
fs.renameSync(dir, clone);
|
|
291
|
+
const root = path.join(clone, src.subdir);
|
|
292
|
+
const rel = path.relative(ctx.hubDir, root).replaceAll('\\', '/');
|
|
293
|
+
const fields = { path: rel, source: src.source, ...(src.ref && { ref: src.ref }), sha };
|
|
294
|
+
if (!installDeps(ctx, root) || !addExtension(ctx, root, file, config, fields)) {
|
|
295
|
+
removeDir(clone);
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
console.log(`\n Installed ${id} from ${src.source} at ${short(sha)}.\n${RESTART}`);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function installAppPlugin(ctx, app) {
|
|
303
|
+
const { root, plugin: p } = appPlugin(app);
|
|
304
|
+
if (!p) throw new Error(`${app.id} ships no plugin.`);
|
|
305
|
+
return pluginStep(ctx, p, 'install', root);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function installFolder(ctx, target, file, config) {
|
|
309
|
+
let root;
|
|
310
|
+
try {
|
|
311
|
+
root = fs.realpathSync.native(path.resolve(target));
|
|
312
|
+
} catch {
|
|
313
|
+
throw new Error(`"${target}" is not an app the hub runs, and no folder has that name.`);
|
|
314
|
+
}
|
|
315
|
+
const ext = readApp(root);
|
|
316
|
+
const { id } = ext.manifest;
|
|
317
|
+
const entry = claimId(id, config, file, (e) => rootOf(ctx, e) === root);
|
|
318
|
+
checkRequires(ctx, ext.manifest, config);
|
|
319
|
+
if (entry) {
|
|
320
|
+
if (ext.plugin && !pluginStep(ctx, ext.plugin, 'install', root)) {
|
|
321
|
+
console.log('\n Plugin install failed. See the errors above.');
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
console.log(`\n ${id} is already in the hub.`);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
const linked = root.replaceAll('\\', '/');
|
|
328
|
+
if (!addExtension(ctx, root, file, config, { path: linked, source: 'local' })) return false;
|
|
329
|
+
console.log(`\n Linked ${id} to ${linked}.\n${RESTART}`);
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function list(ctx) {
|
|
334
|
+
const { config } = readConfig(ctx.hubDir);
|
|
335
|
+
const entries = (config.apps ?? []).filter(isExt);
|
|
336
|
+
if (!entries.length) {
|
|
337
|
+
console.log(' No extensions.');
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
const check = pluginChecker(ctx.configDir());
|
|
341
|
+
for (const e of entries) {
|
|
342
|
+
const where = e.source === 'local' ? `local ${e.path}` : [e.source, e.ref, e.sha && short(e.sha)].filter(Boolean).join(' ');
|
|
343
|
+
let plugin = 'no plugin';
|
|
344
|
+
try {
|
|
345
|
+
const p = readApp(rootOf(ctx, e), e.id).plugin;
|
|
346
|
+
if (p) plugin = `${p.id} ${check(p).state}`;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
plugin = err.message;
|
|
349
|
+
}
|
|
350
|
+
console.log(` ${e.id} ${where} ${plugin}`);
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A GitHub extension moves to the tip of its ref, or of the default branch, and goes back when the new code fails.
|
|
356
|
+
function update(ctx, id) {
|
|
357
|
+
const { file, config } = readConfig(ctx.hubDir);
|
|
358
|
+
const entry = managedEntry(config, id, 'update');
|
|
359
|
+
const root = rootOf(ctx, entry);
|
|
360
|
+
let sha;
|
|
361
|
+
if (isGithub(entry)) {
|
|
362
|
+
const clone = cloneOf(ctx, entry);
|
|
363
|
+
git(['fetch', '--quiet', 'origin'], clone);
|
|
364
|
+
sha = checkout(clone, entry.ref);
|
|
365
|
+
const back = () => entry.sha && entry.sha !== sha && checkout(clone, entry.sha);
|
|
366
|
+
try {
|
|
367
|
+
checkRequires(ctx, readApp(root, id).manifest, config);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
back();
|
|
370
|
+
throw e;
|
|
371
|
+
}
|
|
372
|
+
if (!installDeps(ctx, root)) {
|
|
373
|
+
back();
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
checkRequires(ctx, readApp(root, id).manifest, config);
|
|
378
|
+
}
|
|
379
|
+
const { plugin } = readApp(root, id);
|
|
380
|
+
if (plugin && !pluginStep(ctx, plugin, 'reinstall', root)) return false;
|
|
381
|
+
if (sha && sha !== entry.sha) writeConfigFile(file, { ...config, apps: config.apps.map((e) => (e === entry ? { ...e, sha } : e)) });
|
|
382
|
+
const at = sha ? ` to ${short(sha)}` : '';
|
|
383
|
+
console.log(`\n Updated ${id}${at}. Restart it (Ctrl+R in the app launcher) to load app changes, and start a new Claude Code session to load plugin changes.`);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function remove(ctx, id) {
|
|
388
|
+
const { file, config } = readConfig(ctx.hubDir);
|
|
389
|
+
const entry = managedEntry(config, id, 'be removed');
|
|
390
|
+
let ext = null;
|
|
391
|
+
try {
|
|
392
|
+
ext = readApp(rootOf(ctx, entry), id);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
console.log(` ${e.message}\n The plugin is not removed, because the manifest does not name it.`);
|
|
395
|
+
}
|
|
396
|
+
if (ext?.plugin && !pluginStep(ctx, ext.plugin, 'uninstall')) {
|
|
397
|
+
console.log(`\n Plugin removal failed, so ${id} keeps its entry. See the errors above.`);
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
const extensions = path.join(ctx.hubDir, 'extensions');
|
|
401
|
+
const clone = isGithub(entry) ? path.join(extensions, id) : null;
|
|
402
|
+
writeConfigFile(file, { ...config, apps: config.apps.filter((e) => e !== entry) });
|
|
403
|
+
if (clone && isContained(clone, extensions) && fs.existsSync(clone)) {
|
|
404
|
+
if (removeDir(clone)) console.log(`\n Removed ${id} and its clone.\n${RESTART}`);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
console.log(`\n Removed ${id}. ${entry.path} is left in place.\n${RESTART}`);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function appPlugin(app) {
|
|
412
|
+
const root = appRoot(app, HUB_ROOT, require.resolve);
|
|
413
|
+
return { root, plugin: readApp(root, app.id).plugin };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// For --install: the plugins of the enabled built-in apps. Extensions install theirs with ext install.
|
|
417
|
+
function installBuiltInPlugins(ctx) {
|
|
418
|
+
const full = { exec: run, ...ctx };
|
|
419
|
+
let ok = true;
|
|
420
|
+
let config;
|
|
421
|
+
try {
|
|
422
|
+
({ config } = readConfig(full.hubDir));
|
|
423
|
+
} catch (e) {
|
|
424
|
+
console.error(` ${e.message}`);
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
for (const app of selectApps(config.apps, full.hubDir).apps.filter((a) => APPS.some((b) => b.id === a.id))) {
|
|
428
|
+
try {
|
|
429
|
+
const { root, plugin: p } = appPlugin(app);
|
|
430
|
+
if (p) ok = pluginStep(full, p, 'install', root) && ok;
|
|
431
|
+
} catch (e) {
|
|
432
|
+
console.error(` ${e.message}`);
|
|
433
|
+
ok = false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return ok;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const COMMANDS = { install, list, update, remove };
|
|
440
|
+
|
|
441
|
+
// ctx: {hubDir, configDir: () => dir for plugin steps, hubVersion, nodeVersion?, exec?, githubBase?, registry?: URL or file}.
|
|
442
|
+
function runExt(argv, ctx) {
|
|
443
|
+
const registry = process.env.CLAUDE_HUB_REGISTRY || REGISTRY;
|
|
444
|
+
const full = { nodeVersion: process.versions.node, exec: run, githubBase: 'https://github.com', registry, ...ctx };
|
|
445
|
+
let parsed;
|
|
446
|
+
try {
|
|
447
|
+
parsed = parseArgs({
|
|
448
|
+
args: argv,
|
|
449
|
+
allowPositionals: true,
|
|
450
|
+
options: { dir: { type: 'string' }, 'hub-dir': { type: 'string' }, help: { type: 'boolean', short: 'h' } },
|
|
451
|
+
});
|
|
452
|
+
} catch (e) {
|
|
453
|
+
console.error(`${e.message}\n\n${USAGE}`);
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
const [name, target] = parsed.positionals;
|
|
457
|
+
const command = COMMANDS[name];
|
|
458
|
+
if (!command || parsed.values.help) {
|
|
459
|
+
console.log(USAGE);
|
|
460
|
+
return !!parsed.values.help;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
return command(full, target, parsed.values);
|
|
464
|
+
} catch (e) {
|
|
465
|
+
console.error(` ${e.message}`);
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
module.exports = { runExt, installBuiltInPlugins, USAGE };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
function serializeConfig(config) {
|
|
7
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function writeConfigFile(file, config) {
|
|
11
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
12
|
+
fs.writeFileSync(file, serializeConfig(config));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { serializeConfig, writeConfigFile };
|
package/lib/install.js
CHANGED
|
@@ -17,7 +17,9 @@ const HUB_PLUGIN = {
|
|
|
17
17
|
// Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but inside the
|
|
18
18
|
// dir when it is set, so the default dir must stay unset rather than be spelled out.
|
|
19
19
|
function claudeEnv(configDir) {
|
|
20
|
-
|
|
20
|
+
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
|
21
|
+
if (isDefaultConfigDir(configDir)) delete env.CLAUDE_CONFIG_DIR;
|
|
22
|
+
return env;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
// `idempotent` when the output names an ok pattern, on exit 0 too: some commands report "already" and succeed.
|
|
@@ -50,32 +52,61 @@ function report(label, result, idempotentText) {
|
|
|
50
52
|
return result.ok;
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
function hasClaude(env, exec = run) {
|
|
56
|
+
if (exec('claude --version', env).ok) return true;
|
|
57
|
+
console.log(' claude CLI not found. Install Claude Code first.');
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function registerMarketplace({ marketplaceDir, marketplace, env, exec = run }) {
|
|
62
|
+
const ok = report('Register marketplace', exec(`claude plugin marketplace add "${marketplaceDir}"`, env, ['already']), 'already registered');
|
|
63
|
+
// Claude caches the marketplace manifest, so a re-copied plugin stays invisible until refreshed.
|
|
64
|
+
return report('Refresh marketplace', exec(`claude plugin marketplace update ${marketplace}`, env)) && ok;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function removePlugin({ pluginId, env, exec = run }) {
|
|
68
|
+
return report('Remove plugin', exec(`claude plugin uninstall ${pluginId}`, env, ['not found', 'not installed']), 'not installed');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function installPlugin(args) {
|
|
72
|
+
const { pluginId, env, exec = run } = args;
|
|
73
|
+
let ok = registerMarketplace(args);
|
|
74
|
+
const install = exec(`claude plugin install ${pluginId}`, env, ['already installed']);
|
|
75
|
+
ok = report('Install plugin', install, 'already installed') && ok;
|
|
76
|
+
// `install` exits 0 on a plugin that is present and keeps the cached old version; `update` loads the new copy.
|
|
77
|
+
if (install.idempotent) {
|
|
78
|
+
ok = report('Update plugin', exec(`claude plugin update ${pluginId}`, env, ['already at the latest']), 'already at the latest version') && ok;
|
|
79
|
+
}
|
|
80
|
+
return ok;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `update` keeps the cached copy while the version is unchanged; uninstall and install copy the plugin again.
|
|
84
|
+
function reinstallPlugin(args) {
|
|
85
|
+
const { pluginId, env, exec = run } = args;
|
|
86
|
+
let ok = registerMarketplace(args);
|
|
87
|
+
ok = removePlugin(args) && ok;
|
|
88
|
+
return report('Install plugin', exec(`claude plugin install ${pluginId}`, env)) && ok;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function uninstallPlugin(args) {
|
|
92
|
+
const { marketplace, env, exec = run } = args;
|
|
93
|
+
const ok = removePlugin(args);
|
|
94
|
+
return report('Remove marketplace', exec(`claude plugin marketplace remove ${marketplace}`, env, ['not found', 'not configured']), 'not registered') && ok;
|
|
95
|
+
}
|
|
96
|
+
|
|
53
97
|
// One copy of the plugin in the hub dir; each config dir registers that copy as a marketplace.
|
|
54
98
|
function runInstall({ hubDir, configDir }) {
|
|
55
99
|
const dest = path.join(hubDir, 'plugin');
|
|
56
100
|
const env = claudeEnv(configDir);
|
|
57
101
|
console.log(`\n claude-code-hub plugin install\n Config dir: ${configDir}\n Plugin copy: ${dest}\n`);
|
|
58
102
|
|
|
59
|
-
if (!
|
|
60
|
-
console.log(' claude CLI not found. Install Claude Code first.');
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
103
|
+
if (!hasClaude(env)) return false;
|
|
63
104
|
|
|
64
105
|
clearFiles(dest);
|
|
65
106
|
fs.cpSync(PLUGIN_SRC, dest, { recursive: true });
|
|
66
107
|
console.log(' Copy plugin: done');
|
|
67
108
|
|
|
68
|
-
|
|
69
|
-
// Claude caches the marketplace manifest, so a re-copied plugin stays invisible until refreshed.
|
|
70
|
-
ok = report('Refresh marketplace', run(`claude plugin marketplace update ${MARKETPLACE}`, env)) && ok;
|
|
71
|
-
|
|
72
|
-
const install = run(`claude plugin install ${PLUGIN_ID}`, env, ['already installed']);
|
|
73
|
-
ok = report('Install plugin', install, 'already installed') && ok;
|
|
74
|
-
// `install` exits 0 on a plugin that is present and keeps the cached old version; `update` loads the new copy.
|
|
75
|
-
if (install.idempotent) {
|
|
76
|
-
ok = report('Update plugin', run(`claude plugin update ${PLUGIN_ID}`, env, ['already at the latest']), 'already at the latest version') && ok;
|
|
77
|
-
}
|
|
78
|
-
|
|
109
|
+
const ok = installPlugin({ marketplaceDir: dest, marketplace: MARKETPLACE, pluginId: PLUGIN_ID, env });
|
|
79
110
|
console.log(ok ? '\n Done. Start a new Claude Code session to load the hub-builder skill.\n' : '\n Install incomplete, see the errors above.\n');
|
|
80
111
|
return ok;
|
|
81
112
|
}
|
|
@@ -84,10 +115,20 @@ function runInstall({ hubDir, configDir }) {
|
|
|
84
115
|
function runUninstall({ configDir }) {
|
|
85
116
|
const env = claudeEnv(configDir);
|
|
86
117
|
console.log(`\n claude-code-hub plugin uninstall\n Config dir: ${configDir}\n`);
|
|
87
|
-
|
|
88
|
-
ok = report('Remove marketplace', run(`claude plugin marketplace remove ${MARKETPLACE}`, env, ['not found', 'not configured']), 'not registered') && ok;
|
|
118
|
+
const ok = uninstallPlugin({ marketplace: MARKETPLACE, pluginId: PLUGIN_ID, env });
|
|
89
119
|
console.log(ok ? '\n Done.\n' : '\n Uninstall incomplete, see the errors above.\n');
|
|
90
120
|
return ok;
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
module.exports = {
|
|
123
|
+
module.exports = {
|
|
124
|
+
runInstall,
|
|
125
|
+
runUninstall,
|
|
126
|
+
installPlugin,
|
|
127
|
+
reinstallPlugin,
|
|
128
|
+
uninstallPlugin,
|
|
129
|
+
hasClaude,
|
|
130
|
+
report,
|
|
131
|
+
run,
|
|
132
|
+
claudeEnv,
|
|
133
|
+
HUB_PLUGIN,
|
|
134
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"claude-code-cost": "1.3.
|
|
50
|
-
"claude-code-kanban": "7.
|
|
51
|
-
"claude-code-marketplace": "1.5.
|
|
52
|
-
"claude-code-memory-explorer": "1.3.
|
|
49
|
+
"claude-code-cost": "1.3.2",
|
|
50
|
+
"claude-code-kanban": "7.8.0",
|
|
51
|
+
"claude-code-marketplace": "1.5.2",
|
|
52
|
+
"claude-code-memory-explorer": "1.3.2",
|
|
53
53
|
"express": "^4.21.0",
|
|
54
54
|
"open": "^10.1.0"
|
|
55
55
|
},
|