claude-code-hub 1.24.0 → 1.26.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/lib/apps.js +10 -2
- package/lib/ext.js +134 -50
- package/package.json +2 -2
- package/public/app.js +92 -11
- package/public/index.html +4 -0
- package/server.js +60 -1
package/lib/apps.js
CHANGED
|
@@ -81,6 +81,7 @@ function manifestError(m, id, root) {
|
|
|
81
81
|
if (entry === path.resolve(root) || !isContained(entry, root)) return 'run.entry is outside the app directory';
|
|
82
82
|
return (
|
|
83
83
|
providesError(m.provides) ??
|
|
84
|
+
launcherError(m.launcher) ??
|
|
84
85
|
publishesError(m.publishes) ??
|
|
85
86
|
keysError(m.keys) ??
|
|
86
87
|
pluginError(m.plugin, root) ??
|
|
@@ -242,17 +243,23 @@ function publishesError(publishes) {
|
|
|
242
243
|
// The hub fetches each path from the provider's own server.
|
|
243
244
|
const CAPABILITY_PATHS = { projects: 'path', terminal: 'liveWork' };
|
|
244
245
|
|
|
246
|
+
const isPath = (p) => typeof p === 'string' && p.startsWith('/');
|
|
247
|
+
|
|
245
248
|
function providesError(provides) {
|
|
246
249
|
if (provides === undefined) return null;
|
|
247
250
|
if (!isObject(provides)) return 'provides is not an object';
|
|
248
251
|
for (const [cap, key] of Object.entries(CAPABILITY_PATHS)) {
|
|
249
252
|
if (provides[cap] === undefined) continue;
|
|
250
|
-
|
|
251
|
-
if (typeof p !== 'string' || !p.startsWith('/')) return `provides.${cap}.${key} is not a path`;
|
|
253
|
+
if (!isPath(provides[cap]?.[key])) return `provides.${cap}.${key} is not a path`;
|
|
252
254
|
}
|
|
253
255
|
return null;
|
|
254
256
|
}
|
|
255
257
|
|
|
258
|
+
function launcherError(launcher) {
|
|
259
|
+
if (launcher === undefined) return null;
|
|
260
|
+
return isPath(launcher?.items) ? null : 'launcher.items is not a path';
|
|
261
|
+
}
|
|
262
|
+
|
|
256
263
|
// The manifest at root and its resolved plugin. With no id, the manifest's own id is checked. Throws with the file and the reason.
|
|
257
264
|
function readApp(root, id) {
|
|
258
265
|
const file = path.join(root, 'hub-app.json');
|
|
@@ -316,6 +323,7 @@ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve,
|
|
|
316
323
|
loading: manifest.loading ?? {},
|
|
317
324
|
actions: manifest.actions ?? {},
|
|
318
325
|
provides: manifest.provides ?? {},
|
|
326
|
+
launcher: manifest.launcher ?? {},
|
|
319
327
|
publishes: manifest.publishes ?? [],
|
|
320
328
|
keeps: manifest.keys?.keeps ?? [],
|
|
321
329
|
settings: manifest.settings ?? [],
|
package/lib/ext.js
CHANGED
|
@@ -17,13 +17,16 @@ const USAGE = `Usage: claude-code-hub ext <command>
|
|
|
17
17
|
ext install github:<owner>/<repo>[/<folder>][@<commit or branch>]
|
|
18
18
|
Clone the extension into the hub dir, add it to the hub and install its plugin
|
|
19
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
|
|
20
|
+
ext install <id> Install the plugin of an app the hub runs, or put an add-on on the dock, in a config dir
|
|
21
21
|
ext list List the extensions and the state of their plugins
|
|
22
22
|
ext update <id> Get the latest code of a GitHub extension, and install its plugin again
|
|
23
23
|
ext remove <id> Remove the extension's plugin and its entry, and the clone of a GitHub extension
|
|
24
24
|
|
|
25
|
+
A Kanban dock add-on (addon.json) installs the same way: in place of a plugin, it goes on the
|
|
26
|
+
Kanban dock of the config dir, for every session.
|
|
27
|
+
|
|
25
28
|
--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)`;
|
|
29
|
+
--dir <path> The config dir for plugin and dock steps (default CLAUDE_CONFIG_DIR, else ~/.claude)`;
|
|
27
30
|
|
|
28
31
|
const RESTART = ' Restart the hub to load the change.';
|
|
29
32
|
const REGISTRY = 'https://nikiforovall.blog/claude-code-hub/extensions.json';
|
|
@@ -43,19 +46,27 @@ function checkRequires(ctx, { id, requires }, config) {
|
|
|
43
46
|
|
|
44
47
|
const isExt = (e) => typeof e?.id === 'string' && typeof e.source === 'string';
|
|
45
48
|
|
|
49
|
+
// [entry, kind] for each entry of the kinds' lists in config.json; extEntries keeps the extensions.
|
|
50
|
+
const allEntries = (config) => Object.values(KINDS).flatMap((kind) => (config[kind.list] ?? []).map((e) => [e, kind]));
|
|
51
|
+
const extEntries = (config) => allEntries(config).filter(([e]) => isExt(e));
|
|
52
|
+
|
|
53
|
+
const withList = (config, kind, change) => ({ ...config, [kind.list]: change(config[kind.list] ?? []) });
|
|
54
|
+
|
|
46
55
|
function extEntry(config, id) {
|
|
47
56
|
if (!id) throw new Error(USAGE);
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
50
|
-
return
|
|
57
|
+
const found = extEntries(config).find(([e]) => e.id === id);
|
|
58
|
+
if (!found) throw new Error(`"${id}" is not an extension. ext list shows them.`);
|
|
59
|
+
return found;
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
const isGithub = (entry) => entry.source.startsWith('github:');
|
|
54
63
|
|
|
55
64
|
function managedEntry(config, id, verb) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
const found = extEntry(config, id);
|
|
66
|
+
const [entry] = found;
|
|
67
|
+
const { source } = entry;
|
|
68
|
+
if (source !== 'local' && !isGithub(entry)) throw new Error(`${id} has source "${source}": only a local or GitHub extension can ${verb}.`);
|
|
69
|
+
return found;
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
const rootOf = (ctx, entry) => path.resolve(ctx.hubDir, entry.path);
|
|
@@ -108,9 +119,10 @@ function cloneSource(ctx, src) {
|
|
|
108
119
|
}
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
// The retries outlast a dock add-on's server, which the board stops a moment after the add-on goes.
|
|
111
123
|
function removeDir(dir) {
|
|
112
124
|
try {
|
|
113
|
-
fs.rmSync(dir, { recursive: true, force: true, maxRetries:
|
|
125
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10 });
|
|
114
126
|
return true;
|
|
115
127
|
} catch (e) {
|
|
116
128
|
console.log(` Could not delete ${dir} (${e.code ?? e.message}). Delete it by hand.`);
|
|
@@ -181,6 +193,72 @@ function pluginStep(ctx, plugin, step, root) {
|
|
|
181
193
|
return { install: installPlugin, reinstall: reinstallPlugin, uninstall: uninstallPlugin }[step](args);
|
|
182
194
|
}
|
|
183
195
|
|
|
196
|
+
function readAddon(root, id) {
|
|
197
|
+
const file = path.join(root, 'addon.json');
|
|
198
|
+
let manifest;
|
|
199
|
+
try {
|
|
200
|
+
manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
201
|
+
} catch (e) {
|
|
202
|
+
throw new Error(`${file}: ${e.code === 'ENOENT' ? 'not found' : e.message}`);
|
|
203
|
+
}
|
|
204
|
+
// Kanban's addon add --manifest checks the rest of the manifest.
|
|
205
|
+
if (manifest?.kind !== 'addon' || !ID_RE.test(manifest.id ?? '')) {
|
|
206
|
+
throw new Error(`${file}: an add-on manifest needs "kind": "addon" and an id of lowercase letters, digits and dashes`);
|
|
207
|
+
}
|
|
208
|
+
if (id && manifest.id !== id) throw new Error(`${file}: its id is ${manifest.id}, not ${id}`);
|
|
209
|
+
return { manifest };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Kanban's CLI writes the dock entry into the config dir's addons.json, and a running board picks it up.
|
|
213
|
+
// The manifest's id keys the entry, so an add again updates it.
|
|
214
|
+
function dockStep(ctx, id, _ext, step, root, config) {
|
|
215
|
+
const kanban = selectApps(config.apps, ctx.hubDir).apps.find((a) => a.id === 'kanban');
|
|
216
|
+
if (!kanban) {
|
|
217
|
+
console.log(' Kanban is off in the hub, so it has no dock to change.');
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
const configDir = ctx.configDir();
|
|
221
|
+
console.log(`\n Dock add-on ${id}\n Config dir: ${configDir}\n`);
|
|
222
|
+
const [label, cmd] =
|
|
223
|
+
step === 'uninstall'
|
|
224
|
+
? ['Take it off the dock', `claude-code-kanban addon rm --key ${id} --offline`]
|
|
225
|
+
: ['Put it on the dock', `claude-code-kanban addon add --manifest "${root}" --offline`];
|
|
226
|
+
return report(label, ctx.exec(appCommand(cmd, appRoot(kanban, HUB_ROOT, require.resolve)), claudeEnv(configDir)));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// What differs between an app the hub runs (hub-app.json, with a plugin) and a Kanban dock add-on
|
|
230
|
+
// (addon.json, put on the dock). The hub keeps add-ons in `addons`, because it runs every entry of `apps`.
|
|
231
|
+
const KINDS = {
|
|
232
|
+
app: {
|
|
233
|
+
list: 'apps',
|
|
234
|
+
file: 'hub-app.json',
|
|
235
|
+
read: readApp,
|
|
236
|
+
step: (ctx, _id, ext, step, root) => !ext?.plugin || pluginStep(ctx, ext.plugin, step, root),
|
|
237
|
+
stepName: 'Plugin install',
|
|
238
|
+
removeName: 'Plugin removal',
|
|
239
|
+
unread: 'The plugin is not removed, because the manifest does not name it.',
|
|
240
|
+
installed: RESTART,
|
|
241
|
+
removed: RESTART,
|
|
242
|
+
updated: 'Restart it (Ctrl+R in the app launcher) to load app changes, and start a new Claude Code session to load plugin changes.',
|
|
243
|
+
noPlugin: 'no plugin',
|
|
244
|
+
},
|
|
245
|
+
addon: {
|
|
246
|
+
list: 'addons',
|
|
247
|
+
file: 'addon.json',
|
|
248
|
+
read: readAddon,
|
|
249
|
+
step: dockStep,
|
|
250
|
+
stepName: 'Putting it on the dock',
|
|
251
|
+
removeName: 'Taking it off the dock',
|
|
252
|
+
unread: 'It is taken off the dock by its id.',
|
|
253
|
+
installed: ' It is on the Kanban dock of every session in that config dir.',
|
|
254
|
+
removed: ' It is off the Kanban dock of that config dir.',
|
|
255
|
+
updated: 'Restart its server (Ctrl+R on its row in the app launcher) to load the new code.',
|
|
256
|
+
noPlugin: 'dock add-on',
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const kindOf = (root) => (fs.existsSync(path.join(root, 'addon.json')) ? KINDS.addon : KINDS.app);
|
|
261
|
+
|
|
184
262
|
// An id the hub runs gets its plugin installed. A folder is added to the hub, with its plugin, unless the
|
|
185
263
|
// hub already runs it from there; then only the plugin is installed. Any other name is looked up in the registry.
|
|
186
264
|
function install(ctx, target) {
|
|
@@ -190,6 +268,8 @@ function install(ctx, target) {
|
|
|
190
268
|
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.');
|
|
191
269
|
const app = selectApps(config.apps, ctx.hubDir).apps.find((a) => a.id === target);
|
|
192
270
|
if (app) return installAppPlugin(ctx, app);
|
|
271
|
+
const [addon] = extEntries(config).find(([e, k]) => k === KINDS.addon && e.id === target) ?? [];
|
|
272
|
+
if (addon) return dockStep(ctx, target, null, 'install', rootOf(ctx, addon), config);
|
|
193
273
|
if (!fs.existsSync(target) && ID_RE.test(target)) return installRegistry(ctx, target, file, config);
|
|
194
274
|
return installFolder(ctx, target, file, config);
|
|
195
275
|
}
|
|
@@ -226,22 +306,23 @@ function installRegistry(ctx, name, file, config) {
|
|
|
226
306
|
}
|
|
227
307
|
|
|
228
308
|
// The entry the hub has for the id when `same` accepts it; throws when the id is built-in or taken.
|
|
229
|
-
function claimId(id, config, file, same) {
|
|
309
|
+
function claimId(id, config, file, kind, same) {
|
|
230
310
|
if (APPS.some((a) => a.id === id)) throw new Error(`Cannot install ${id}: it is the id of a built-in app.`);
|
|
231
|
-
const entry = (config
|
|
232
|
-
|
|
311
|
+
const [entry, k] = allEntries(config).find(([e]) => e?.id === id) ?? [];
|
|
312
|
+
const ours = entry && isExt(entry) && k === kind && same(entry);
|
|
313
|
+
if (entry && !ours) throw new Error(`Cannot install ${id}: ${file} already has an entry for it.`);
|
|
233
314
|
return entry;
|
|
234
315
|
}
|
|
235
316
|
|
|
236
|
-
//
|
|
237
|
-
function addExtension(ctx, root, file, config, fields) {
|
|
238
|
-
const ext =
|
|
317
|
+
// Runs the install step, then adds the entry. Returns false with nothing written when the step fails.
|
|
318
|
+
function addExtension(ctx, root, file, config, fields, kind) {
|
|
319
|
+
const ext = kind.read(root);
|
|
239
320
|
const { id } = ext.manifest;
|
|
240
|
-
if (
|
|
241
|
-
console.log(`\n
|
|
321
|
+
if (!kind.step(ctx, id, ext, 'install', root, config)) {
|
|
322
|
+
console.log(`\n ${kind.stepName} failed, so ${id} is not added. See the errors above.`);
|
|
242
323
|
return false;
|
|
243
324
|
}
|
|
244
|
-
writeConfigFile(file,
|
|
325
|
+
writeConfigFile(file, withList(config, kind, (list) => [...list, { id, ...fields }]));
|
|
245
326
|
return true;
|
|
246
327
|
}
|
|
247
328
|
|
|
@@ -249,16 +330,18 @@ function installGithub(ctx, target, file, config, name) {
|
|
|
249
330
|
const src = parseGithub(target);
|
|
250
331
|
const { dir, sha } = cloneSource(ctx, src);
|
|
251
332
|
let id;
|
|
333
|
+
let kind;
|
|
252
334
|
try {
|
|
253
|
-
const
|
|
335
|
+
const extDir = path.join(dir, src.subdir);
|
|
336
|
+
kind = kindOf(extDir);
|
|
337
|
+
const ext = kind.read(extDir);
|
|
254
338
|
id = ext.manifest.id;
|
|
255
|
-
if (name && id !== name) throw new Error(`The registry lists ${name} at ${src.source}, but its
|
|
256
|
-
const entry = claimId(id, config, file, (e) => e.source === src.source);
|
|
339
|
+
if (name && id !== name) throw new Error(`The registry lists ${name} at ${src.source}, but its ${kind.file} has the id ${id}.`);
|
|
340
|
+
const entry = claimId(id, config, file, kind, (e) => e.source === src.source);
|
|
257
341
|
if (entry) {
|
|
258
342
|
removeDir(dir);
|
|
259
343
|
const root = rootOf(ctx, entry);
|
|
260
|
-
|
|
261
|
-
if (plugin && !pluginStep(ctx, plugin, 'install', root)) return false;
|
|
344
|
+
if (!kind.step(ctx, id, kind.read(root, id), 'install', root, config)) return false;
|
|
262
345
|
console.log(`\n ${id} is already in the hub, at ${short(entry.sha ?? '')}. ext update ${id} gets the latest code.`);
|
|
263
346
|
return true;
|
|
264
347
|
}
|
|
@@ -276,11 +359,11 @@ function installGithub(ctx, target, file, config, name) {
|
|
|
276
359
|
const root = path.join(clone, src.subdir);
|
|
277
360
|
const rel = path.relative(ctx.hubDir, root).replaceAll('\\', '/');
|
|
278
361
|
const fields = { path: rel, source: src.source, ...(src.ref && { ref: src.ref }), sha };
|
|
279
|
-
if (!installDeps(ctx, root) || !addExtension(ctx, root, file, config, fields)) {
|
|
362
|
+
if (!installDeps(ctx, root) || !addExtension(ctx, root, file, config, fields, kind)) {
|
|
280
363
|
removeDir(clone);
|
|
281
364
|
return false;
|
|
282
365
|
}
|
|
283
|
-
console.log(`\n Installed ${id} from ${src.source} at ${short(sha)}.\n${
|
|
366
|
+
console.log(`\n Installed ${id} from ${src.source} at ${short(sha)}.\n${kind.installed}`);
|
|
284
367
|
return true;
|
|
285
368
|
}
|
|
286
369
|
|
|
@@ -297,37 +380,38 @@ function installFolder(ctx, target, file, config) {
|
|
|
297
380
|
} catch {
|
|
298
381
|
throw new Error(`"${target}" is not an app the hub runs, and no folder has that name.`);
|
|
299
382
|
}
|
|
300
|
-
const
|
|
383
|
+
const kind = kindOf(root);
|
|
384
|
+
const ext = kind.read(root);
|
|
301
385
|
const { id } = ext.manifest;
|
|
302
|
-
const entry = claimId(id, config, file, (e) => rootOf(ctx, e) === root);
|
|
386
|
+
const entry = claimId(id, config, file, kind, (e) => rootOf(ctx, e) === root);
|
|
303
387
|
checkRequires(ctx, ext.manifest, config);
|
|
304
388
|
if (entry) {
|
|
305
|
-
if (
|
|
306
|
-
console.log(
|
|
389
|
+
if (!kind.step(ctx, id, ext, 'install', root, config)) {
|
|
390
|
+
console.log(`\n ${kind.stepName} failed. See the errors above.`);
|
|
307
391
|
return false;
|
|
308
392
|
}
|
|
309
393
|
console.log(`\n ${id} is already in the hub.`);
|
|
310
394
|
return true;
|
|
311
395
|
}
|
|
312
396
|
const linked = root.replaceAll('\\', '/');
|
|
313
|
-
if (!addExtension(ctx, root, file, config, { path: linked, source: 'local' })) return false;
|
|
314
|
-
console.log(`\n Linked ${id} to ${linked}.\n${
|
|
397
|
+
if (!addExtension(ctx, root, file, config, { path: linked, source: 'local' }, kind)) return false;
|
|
398
|
+
console.log(`\n Linked ${id} to ${linked}.\n${kind.installed}`);
|
|
315
399
|
return true;
|
|
316
400
|
}
|
|
317
401
|
|
|
318
402
|
function list(ctx) {
|
|
319
403
|
const { config } = readConfig(ctx.hubDir);
|
|
320
|
-
const entries = (config
|
|
404
|
+
const entries = extEntries(config);
|
|
321
405
|
if (!entries.length) {
|
|
322
406
|
console.log(' No extensions.');
|
|
323
407
|
return true;
|
|
324
408
|
}
|
|
325
409
|
const check = pluginChecker(ctx.configDir());
|
|
326
|
-
for (const e of entries) {
|
|
410
|
+
for (const [e, kind] of entries) {
|
|
327
411
|
const where = e.source === 'local' ? `local ${e.path}` : [e.source, e.ref, e.sha && short(e.sha)].filter(Boolean).join(' ');
|
|
328
|
-
let plugin =
|
|
412
|
+
let plugin = kind.noPlugin;
|
|
329
413
|
try {
|
|
330
|
-
const p =
|
|
414
|
+
const p = kind.read(rootOf(ctx, e), e.id).plugin;
|
|
331
415
|
if (p) plugin = `${p.id} ${check(p).state}`;
|
|
332
416
|
} catch (err) {
|
|
333
417
|
plugin = err.message;
|
|
@@ -340,7 +424,7 @@ function list(ctx) {
|
|
|
340
424
|
// A GitHub extension moves to the tip of its ref, or of the default branch, and goes back when the new code fails.
|
|
341
425
|
function update(ctx, id) {
|
|
342
426
|
const { file, config } = readConfig(ctx.hubDir);
|
|
343
|
-
const entry = managedEntry(config, id, 'update');
|
|
427
|
+
const [entry, kind] = managedEntry(config, id, 'update');
|
|
344
428
|
const root = rootOf(ctx, entry);
|
|
345
429
|
let sha;
|
|
346
430
|
if (isGithub(entry)) {
|
|
@@ -349,7 +433,7 @@ function update(ctx, id) {
|
|
|
349
433
|
sha = checkout(clone, entry.ref);
|
|
350
434
|
const back = () => entry.sha && entry.sha !== sha && checkout(clone, entry.sha);
|
|
351
435
|
try {
|
|
352
|
-
checkRequires(ctx,
|
|
436
|
+
checkRequires(ctx, kind.read(root, id).manifest, config);
|
|
353
437
|
} catch (e) {
|
|
354
438
|
back();
|
|
355
439
|
throw e;
|
|
@@ -359,37 +443,37 @@ function update(ctx, id) {
|
|
|
359
443
|
return false;
|
|
360
444
|
}
|
|
361
445
|
} else {
|
|
362
|
-
checkRequires(ctx,
|
|
446
|
+
checkRequires(ctx, kind.read(root, id).manifest, config);
|
|
363
447
|
}
|
|
364
|
-
|
|
365
|
-
if (
|
|
366
|
-
if (sha && sha !== entry.sha) writeConfigFile(file, { ...config, apps: config.apps.map((e) => (e === entry ? { ...e, sha } : e)) });
|
|
448
|
+
if (!kind.step(ctx, id, kind.read(root, id), 'reinstall', root, config)) return false;
|
|
449
|
+
if (sha && sha !== entry.sha) writeConfigFile(file, withList(config, kind, (list) => list.map((e) => (e === entry ? { ...e, sha } : e))));
|
|
367
450
|
const at = sha ? ` to ${short(sha)}` : '';
|
|
368
|
-
console.log(`\n Updated ${id}${at}.
|
|
451
|
+
console.log(`\n Updated ${id}${at}. ${kind.updated}`);
|
|
369
452
|
return true;
|
|
370
453
|
}
|
|
371
454
|
|
|
372
455
|
function remove(ctx, id) {
|
|
373
456
|
const { file, config } = readConfig(ctx.hubDir);
|
|
374
|
-
const entry = managedEntry(config, id, 'be removed');
|
|
457
|
+
const [entry, kind] = managedEntry(config, id, 'be removed');
|
|
458
|
+
const root = rootOf(ctx, entry);
|
|
375
459
|
let ext = null;
|
|
376
460
|
try {
|
|
377
|
-
ext =
|
|
461
|
+
ext = kind.read(root, id);
|
|
378
462
|
} catch (e) {
|
|
379
|
-
console.log(` ${e.message}\n
|
|
463
|
+
console.log(` ${e.message}\n ${kind.unread}`);
|
|
380
464
|
}
|
|
381
|
-
if (
|
|
382
|
-
console.log(`\n
|
|
465
|
+
if (!kind.step(ctx, id, ext, 'uninstall', root, config)) {
|
|
466
|
+
console.log(`\n ${kind.removeName} failed, so ${id} keeps its entry. See the errors above.`);
|
|
383
467
|
return false;
|
|
384
468
|
}
|
|
385
469
|
const extensions = path.join(ctx.hubDir, 'extensions');
|
|
386
470
|
const clone = isGithub(entry) ? path.join(extensions, id) : null;
|
|
387
|
-
writeConfigFile(file,
|
|
471
|
+
writeConfigFile(file, withList(config, kind, (list) => list.filter((e) => e !== entry)));
|
|
388
472
|
if (clone && isContained(clone, extensions) && fs.existsSync(clone)) {
|
|
389
|
-
if (removeDir(clone)) console.log(`\n Removed ${id} and its clone.\n${
|
|
473
|
+
if (removeDir(clone)) console.log(`\n Removed ${id} and its clone.\n${kind.removed}`);
|
|
390
474
|
return true;
|
|
391
475
|
}
|
|
392
|
-
console.log(`\n Removed ${id}. ${entry.path} is left in place.\n${
|
|
476
|
+
console.log(`\n Removed ${id}. ${entry.path} is left in place.\n${kind.removed}`);
|
|
393
477
|
return true;
|
|
394
478
|
}
|
|
395
479
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"claude-code-cost": "1.6.0",
|
|
50
|
-
"claude-code-kanban": "7.
|
|
50
|
+
"claude-code-kanban": "7.23.0",
|
|
51
51
|
"claude-code-marketplace": "1.7.1",
|
|
52
52
|
"claude-code-memory-explorer": "1.3.4",
|
|
53
53
|
"express": "^4.21.0",
|
package/public/app.js
CHANGED
|
@@ -1029,6 +1029,14 @@ const APP_ICONS = {
|
|
|
1029
1029
|
['path', { d: 'M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3' }],
|
|
1030
1030
|
['path', { d: 'M12 12V8' }],
|
|
1031
1031
|
],
|
|
1032
|
+
puzzle: [
|
|
1033
|
+
[
|
|
1034
|
+
'path',
|
|
1035
|
+
{
|
|
1036
|
+
d: 'M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z',
|
|
1037
|
+
},
|
|
1038
|
+
],
|
|
1039
|
+
],
|
|
1032
1040
|
};
|
|
1033
1041
|
|
|
1034
1042
|
const ROW_ICONS = {
|
|
@@ -1074,12 +1082,80 @@ function appIcon(app) {
|
|
|
1074
1082
|
return box;
|
|
1075
1083
|
}
|
|
1076
1084
|
|
|
1085
|
+
// An app's own rows (`items` in its stats) follow its row and match on their own name.
|
|
1077
1086
|
function appRows(q) {
|
|
1078
1087
|
const tabs = tabIds();
|
|
1079
|
-
const list = Object.entries(apps).
|
|
1088
|
+
const list = Object.entries(apps).flatMap(([id, a]) => [
|
|
1089
|
+
{ kind: 'app', id, app: a, index: tabs.indexOf(id), name: a.name },
|
|
1090
|
+
...(palette.appStats?.[id]?.items ?? []).map((item) => ({ kind: 'item', id, app: a, item, name: item.name })),
|
|
1091
|
+
]);
|
|
1080
1092
|
if (!q) return list;
|
|
1081
|
-
const hit = list.filter((r) => r.
|
|
1082
|
-
return hit.length ? hit : list.filter((r) => subseq(r.
|
|
1093
|
+
const hit = list.filter((r) => r.name.toLowerCase().includes(q) || (r.kind === 'app' && r.id.includes(q)));
|
|
1094
|
+
return hit.length ? hit : list.filter((r) => subseq(r.name.toLowerCase(), q));
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function fillItemRow(li, row, q) {
|
|
1098
|
+
const { item } = row;
|
|
1099
|
+
li.classList.add('palette-item');
|
|
1100
|
+
li.append(appIcon({ icon: 'puzzle' }), markedSpan('name', item.name, q), el('span', 'url', item.detail));
|
|
1101
|
+
li.append(el('span', 'stats', item.state), el('span', 'age', item.kind || row.app.name));
|
|
1102
|
+
if (item.path)
|
|
1103
|
+
li.append(rowButton('palette-copy', 'copy', 'c', 'Copy the path (Ctrl+C)', `Copy the path of ${item.name}`));
|
|
1104
|
+
for (const op of item.ops) {
|
|
1105
|
+
const { key, glyph, title } = ITEM_OPS[op];
|
|
1106
|
+
li.append(rowButton(`palette-${op}`, glyph, key, `${title} (Ctrl+${key.toUpperCase()})`, `${title} ${item.name}`));
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// The ops the hub knows for an app's launcher rows; the app lists which ones each row offers.
|
|
1111
|
+
const ITEM_OPS = {
|
|
1112
|
+
restart: { key: 'r', glyph: 'restart', title: 'Restart', doing: 'Restarting', done: 'Restarted' },
|
|
1113
|
+
remove: { key: 'd', glyph: '×', title: 'Remove', doing: 'Removing', done: 'Removed', confirm: true },
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const itemKeys = (item) =>
|
|
1117
|
+
[item.path && 'Ctrl+C copy path', ...item.ops.map((op) => `Ctrl+${ITEM_OPS[op].key.toUpperCase()} ${op}`)]
|
|
1118
|
+
.filter(Boolean)
|
|
1119
|
+
.join(' · ');
|
|
1120
|
+
|
|
1121
|
+
const APP_ROW_KEYS = {
|
|
1122
|
+
c: (row) => copyToHint(apps[row.id].url),
|
|
1123
|
+
r: (row) => appOpFromPalette(row.id, 'restart'),
|
|
1124
|
+
s: (row) => toggleFromPalette(row.id),
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
const ITEM_ROW_KEYS = {
|
|
1128
|
+
c: (row) => row.item.path && copyToHint(row.item.path),
|
|
1129
|
+
...Object.fromEntries(Object.entries(ITEM_OPS).map(([op, { key }]) => [key, (row) => itemOpFromPalette(row, op)])),
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
// An op with `confirm` asks once: the same key again on the same row sends it.
|
|
1133
|
+
async function itemOpFromPalette(row, op) {
|
|
1134
|
+
const { id, item } = row;
|
|
1135
|
+
if (!item.ops.includes(op)) return;
|
|
1136
|
+
const { key, doing, done } = ITEM_OPS[op];
|
|
1137
|
+
const confirm = `item\n${op}\n${id}\n${item.id}`;
|
|
1138
|
+
if (ITEM_OPS[op].confirm && palette.confirmOp !== confirm) {
|
|
1139
|
+
palette.confirmOp = confirm;
|
|
1140
|
+
setPaletteHint(`Press Ctrl+${key.toUpperCase()} again to ${op} ${item.name} from ${row.app.name}.`);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
palette.confirmOp = null;
|
|
1144
|
+
setPaletteHint(`${doing} ${item.name}…`);
|
|
1145
|
+
let body;
|
|
1146
|
+
try {
|
|
1147
|
+
body = await withPaletteBusy(() =>
|
|
1148
|
+
sendJson('POST', `/api/apps/${encodeURIComponent(id)}/items/${encodeURIComponent(item.id)}/${op}`),
|
|
1149
|
+
);
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
setPaletteHint(`Could not ${op} ${item.name}: ${err.message}`);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
setPaletteHint(body?.message ?? `${done} ${item.name}`);
|
|
1155
|
+
if (op === 'remove' && palette.appStats?.[id]?.items) {
|
|
1156
|
+
palette.appStats[id].items = palette.appStats[id].items.filter((i) => i.id !== item.id);
|
|
1157
|
+
} else if (body?.state) item.state = body.state;
|
|
1158
|
+
renderPalette();
|
|
1083
1159
|
}
|
|
1084
1160
|
|
|
1085
1161
|
// A click on the button runs the mode's rowKeys entry for `key`, the same as Ctrl+<key>.
|
|
@@ -1247,7 +1323,11 @@ const PALETTE_MODES = {
|
|
|
1247
1323
|
placeholder: 'Search apps...',
|
|
1248
1324
|
label: 'Switch app',
|
|
1249
1325
|
get hint() {
|
|
1250
|
-
const
|
|
1326
|
+
const row = palette.rows?.[palette.sel];
|
|
1327
|
+
const keys =
|
|
1328
|
+
row?.kind === 'item'
|
|
1329
|
+
? itemKeys(row.item)
|
|
1330
|
+
: 'Enter switch · Ctrl+C copy URL · Ctrl+R restart · Ctrl+S start/stop';
|
|
1251
1331
|
if (!hubInfo.version) return keys;
|
|
1252
1332
|
const plugin = hubInfo.plugin ? [pluginNode(hubInfo.plugin), ' · '] : [];
|
|
1253
1333
|
return [`Hub ${hubInfo.version} · `, ...plugin, keys];
|
|
@@ -1260,6 +1340,10 @@ const PALETTE_MODES = {
|
|
|
1260
1340
|
return prev < 0 ? 0 : prev;
|
|
1261
1341
|
},
|
|
1262
1342
|
fillRow(li, row, q) {
|
|
1343
|
+
if (row.kind === 'item') {
|
|
1344
|
+
fillItemRow(li, row, q);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1263
1347
|
li.append(appIcon(row.app), markedSpan('name', row.app.name, q), el('span', 'url', new URL(row.app.url).host));
|
|
1264
1348
|
const s = palette.appStats?.[row.id];
|
|
1265
1349
|
if (s?.version) li.append(el('span', 'ver', s.version));
|
|
@@ -1289,11 +1373,9 @@ const PALETTE_MODES = {
|
|
|
1289
1373
|
: rowButton('palette-toggle', 'stop', 's', 'Stop the app (Ctrl+S)', `Stop ${row.app.name}`),
|
|
1290
1374
|
);
|
|
1291
1375
|
},
|
|
1292
|
-
rowKeys:
|
|
1293
|
-
c
|
|
1294
|
-
|
|
1295
|
-
s: (row) => toggleFromPalette(row.id),
|
|
1296
|
-
},
|
|
1376
|
+
rowKeys: Object.fromEntries(
|
|
1377
|
+
['c', 'd', 'r', 's'].map((k) => [k, (row) => (row.kind === 'item' ? ITEM_ROW_KEYS : APP_ROW_KEYS)[k]?.(row)]),
|
|
1378
|
+
),
|
|
1297
1379
|
commit(row) {
|
|
1298
1380
|
closePalette();
|
|
1299
1381
|
switchTab(row.id);
|
|
@@ -1552,6 +1634,7 @@ function renderPalette() {
|
|
|
1552
1634
|
// sel is null until the first render that has rows: the cached list may be empty until load() lands.
|
|
1553
1635
|
if (palette.sel === null && rows.length > 0) palette.sel = spec.initialSel?.(rows) ?? 0;
|
|
1554
1636
|
palette.sel = Math.min(palette.sel, Math.max(0, rows.length - 1));
|
|
1637
|
+
drawPaletteHint();
|
|
1555
1638
|
if (rows.length === 0) {
|
|
1556
1639
|
list.replaceChildren(el('li', 'palette-empty', spec.empty));
|
|
1557
1640
|
return;
|
|
@@ -1597,7 +1680,6 @@ function openPalette(mode) {
|
|
|
1597
1680
|
input.value = '';
|
|
1598
1681
|
input.placeholder = spec.placeholder;
|
|
1599
1682
|
document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
|
|
1600
|
-
drawPaletteHint();
|
|
1601
1683
|
showOverlay('palette', closePalette);
|
|
1602
1684
|
renderPalette();
|
|
1603
1685
|
input.focus();
|
|
@@ -1605,7 +1687,6 @@ function openPalette(mode) {
|
|
|
1605
1687
|
Promise.resolve(spec.load?.())
|
|
1606
1688
|
.then(() => {
|
|
1607
1689
|
if (!palette.open || palette.mode !== mode) return;
|
|
1608
|
-
drawPaletteHint();
|
|
1609
1690
|
renderPalette();
|
|
1610
1691
|
})
|
|
1611
1692
|
.catch((err) => {
|
package/public/index.html
CHANGED
|
@@ -252,6 +252,10 @@
|
|
|
252
252
|
|
|
253
253
|
.palette-row .name { font-weight: 600; }
|
|
254
254
|
|
|
255
|
+
.palette-row.palette-item { padding-left: 40px; }
|
|
256
|
+
|
|
257
|
+
.palette-row.palette-item .name { font-weight: 400; }
|
|
258
|
+
|
|
255
259
|
/* The letters that made this row match. Accent-coloured rather than block-highlighted so it
|
|
256
260
|
reads inside a bold name without fighting the selected row's bar. */
|
|
257
261
|
.palette-row mark {
|
package/server.js
CHANGED
|
@@ -1173,6 +1173,36 @@ async function checkPortHealth() {
|
|
|
1173
1173
|
}
|
|
1174
1174
|
|
|
1175
1175
|
const STATS_TIMEOUT_MS = 1000;
|
|
1176
|
+
const ITEM_OPS = ['restart', 'remove'];
|
|
1177
|
+
// A restart can wait 20 s for the old server to free its port and 20 s for the new one to answer.
|
|
1178
|
+
const ITEM_OP_TIMEOUT_MS = 45000;
|
|
1179
|
+
|
|
1180
|
+
// The app's own rows for the launcher (`launcher.items`), or null when it has none or does not answer in time.
|
|
1181
|
+
async function launcherItems(pool, a) {
|
|
1182
|
+
const itemsPath = a.launcher.items;
|
|
1183
|
+
const port = pool?.ports[a.id];
|
|
1184
|
+
if (!itemsPath || !port || pool.stopped.has(a.id)) return null;
|
|
1185
|
+
try {
|
|
1186
|
+
const r = await fetch(`http://127.0.0.1:${port}${itemsPath}`, { signal: AbortSignal.timeout(STATS_TIMEOUT_MS) });
|
|
1187
|
+
if (!r.ok) return null;
|
|
1188
|
+
const { items } = await r.json();
|
|
1189
|
+
if (!Array.isArray(items)) return null;
|
|
1190
|
+
return items
|
|
1191
|
+
.filter((i) => typeof i?.id === 'string' && typeof i.name === 'string')
|
|
1192
|
+
.map((i) => ({
|
|
1193
|
+
id: i.id,
|
|
1194
|
+
name: i.name,
|
|
1195
|
+
kind: typeof i.kind === 'string' ? i.kind : '',
|
|
1196
|
+
detail: typeof i.detail === 'string' ? i.detail : '',
|
|
1197
|
+
state: typeof i.state === 'string' ? i.state : '',
|
|
1198
|
+
path: typeof i.path === 'string' ? i.path : '',
|
|
1199
|
+
ops: Array.isArray(i.ops) ? i.ops.filter((op) => ITEM_OPS.includes(op)) : [],
|
|
1200
|
+
}));
|
|
1201
|
+
} catch {
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1176
1206
|
app.get('/api/apps/stats', async (_req, res) => {
|
|
1177
1207
|
res.setHeader('Cache-Control', 'no-store');
|
|
1178
1208
|
const pool = activePool();
|
|
@@ -1182,10 +1212,12 @@ app.get('/api/apps/stats', async (_req, res) => {
|
|
|
1182
1212
|
ENABLED_APPS.map(async (a) => {
|
|
1183
1213
|
const child = pool?.children.get(a.id);
|
|
1184
1214
|
const slowMs = (child && portHealth.flagged(child)?.worstMs) ?? null;
|
|
1215
|
+
const [usage, items] = await Promise.all([stats(child, STATS_TIMEOUT_MS), launcherItems(pool, a)]);
|
|
1185
1216
|
return [
|
|
1186
1217
|
a.id,
|
|
1187
1218
|
{
|
|
1188
|
-
...
|
|
1219
|
+
...usage,
|
|
1220
|
+
items,
|
|
1189
1221
|
slowMs,
|
|
1190
1222
|
stopped: !!pool?.stopped.has(a.id),
|
|
1191
1223
|
linked: a.linked,
|
|
@@ -1234,6 +1266,33 @@ app.post('/api/apps/:id/:op(restart|start|stop)', async (req, res) => {
|
|
|
1234
1266
|
}
|
|
1235
1267
|
});
|
|
1236
1268
|
|
|
1269
|
+
// An op on one of an app's launcher rows, sent to `<items path>/<item id>/<op>` on that app.
|
|
1270
|
+
app.post(`/api/apps/:id/items/:item/:op(${ITEM_OPS.join('|')})`, async (req, res) => {
|
|
1271
|
+
const { id, item, op } = req.params;
|
|
1272
|
+
const target = ENABLED_APPS.find((a) => a.id === id);
|
|
1273
|
+
const itemsPath = target?.launcher.items;
|
|
1274
|
+
if (!itemsPath) {
|
|
1275
|
+
res.status(404).json({ error: 'Unknown app or app with no launcher items' });
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
const port = await waitForPort(id, Date.now() + API_PORT_WAIT_MS);
|
|
1279
|
+
if (!port) {
|
|
1280
|
+
res.status(503).json({ error: `${target.name} ${noPortReason(id).headline}` });
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
console.log(`[${id}] ${op} ${item} asked for by the hub page`);
|
|
1284
|
+
try {
|
|
1285
|
+
const upstream = await fetch(`http://127.0.0.1:${port}${itemsPath}/${encodeURIComponent(item)}/${op}`, {
|
|
1286
|
+
method: 'POST',
|
|
1287
|
+
signal: AbortSignal.timeout(ITEM_OP_TIMEOUT_MS),
|
|
1288
|
+
});
|
|
1289
|
+
const body = await upstream.json().catch(() => ({}));
|
|
1290
|
+
res.status(upstream.status).json(body);
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
res.status(502).json({ error: `${target.name} unavailable: ${err.message}` });
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1237
1296
|
// Project list for the switcher palette, proxied from the app that provides `projects`. The port is
|
|
1238
1297
|
// read per request because the provider's real port is only known once its banner has been scraped
|
|
1239
1298
|
// (see spawnApp).
|