claude-code-hub 1.10.1 → 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/server.js
CHANGED
|
@@ -21,6 +21,7 @@ const { createUpdateCheck } = require('./lib/update-check');
|
|
|
21
21
|
const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
|
|
22
22
|
const { HUB_PLUGIN } = require('./lib/install');
|
|
23
23
|
const { pluginChecker } = require('./lib/plugin-status');
|
|
24
|
+
const { serializeConfig, writeConfigFile } = require('./lib/hub-config');
|
|
24
25
|
const { version: HUB_VERSION } = require('./package.json');
|
|
25
26
|
|
|
26
27
|
function getArg(name) {
|
|
@@ -70,19 +71,33 @@ if (TRAY_FLAG) {
|
|
|
70
71
|
}).then((ok) => process.exit(ok ? 0 : 1));
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
const { runInstall, runUninstall } = require('./lib/install');
|
|
74
|
+
function cliConfigDir() {
|
|
75
75
|
const dir = getArg('dir') || process.env.CLAUDE_CONFIG_DIR || '~/.claude';
|
|
76
|
-
let configDir;
|
|
77
76
|
try {
|
|
78
|
-
|
|
77
|
+
return canonicalDir(dir);
|
|
79
78
|
} catch {
|
|
80
79
|
console.error(`Config dir not found: ${dir}`);
|
|
81
80
|
process.exit(1);
|
|
82
81
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (process.argv[2] === 'ext') {
|
|
85
|
+
const { runExt } = require('./lib/ext');
|
|
86
|
+
const ok = runExt(process.argv.slice(3), { hubDir: HUB_DIR, configDir: cliConfigDir, hubVersion: HUB_VERSION });
|
|
87
|
+
process.exit(ok ? 0 : 1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (process.argv.includes('--install') || process.argv.includes('--uninstall')) {
|
|
91
|
+
const { runInstall, runUninstall } = require('./lib/install');
|
|
92
|
+
const configDir = cliConfigDir();
|
|
93
|
+
let ok;
|
|
94
|
+
if (process.argv.includes('--install')) {
|
|
95
|
+
const { installBuiltInPlugins } = require('./lib/ext');
|
|
96
|
+
const hubOk = runInstall({ hubDir: HUB_DIR, configDir });
|
|
97
|
+
ok = installBuiltInPlugins({ hubDir: HUB_DIR, configDir: () => configDir }) && hubOk;
|
|
98
|
+
} else {
|
|
99
|
+
ok = runUninstall({ configDir });
|
|
100
|
+
}
|
|
86
101
|
process.exit(ok ? 0 : 1);
|
|
87
102
|
}
|
|
88
103
|
|
|
@@ -138,17 +153,17 @@ function resolveHubConfig() {
|
|
|
138
153
|
return { config, raw, fallback };
|
|
139
154
|
}
|
|
140
155
|
|
|
141
|
-
function serializeConfig(config) {
|
|
142
|
-
return `${JSON.stringify(config, null, 2)}\n`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
156
|
function writeHubConfig(config) {
|
|
146
|
-
|
|
147
|
-
fs.writeFileSync(HUB_CONFIG_FILE, serializeConfig(config));
|
|
157
|
+
writeConfigFile(HUB_CONFIG_FILE, config);
|
|
148
158
|
}
|
|
149
159
|
|
|
160
|
+
// `ext` and hand edits change the other keys while the hub runs, so a save keeps the file's copy of them.
|
|
150
161
|
function saveHubConfig() {
|
|
151
|
-
|
|
162
|
+
let saved = hubConfig;
|
|
163
|
+
try {
|
|
164
|
+
saved = JSON.parse(fs.readFileSync(HUB_CONFIG_FILE, 'utf8'));
|
|
165
|
+
} catch {}
|
|
166
|
+
writeHubConfig({ ...saved, configDirs: hubConfig.configDirs, activeConfigDir: hubConfig.activeConfigDir });
|
|
152
167
|
}
|
|
153
168
|
|
|
154
169
|
const hubConfig = loadHubConfig();
|
|
@@ -255,6 +270,7 @@ function childEnv(pool, name) {
|
|
|
255
270
|
CLAUDE_CONFIG_DIR: pool.dir,
|
|
256
271
|
CLAUDE_HUB: '1',
|
|
257
272
|
HUB_URL: `http://localhost:${hubPort}`,
|
|
273
|
+
CLAUDE_HUB_DIR: HUB_DIR,
|
|
258
274
|
HOST: net.BIND_HOST,
|
|
259
275
|
ALLOWED_HOSTS: net.ALLOWED_HOSTS,
|
|
260
276
|
HUB_SDK_SERVER,
|
|
@@ -324,17 +340,28 @@ function spawnApp(pool, name, cmd, args) {
|
|
|
324
340
|
// Asked for by the user, so it does not count toward MAX_RESTARTS and it brings back an app that gave up.
|
|
325
341
|
async function restartApp(pool, app) {
|
|
326
342
|
const { id } = app;
|
|
327
|
-
const old = pool.children.get(id);
|
|
328
|
-
pool.children.delete(id);
|
|
329
|
-
delete pool.ports[id];
|
|
330
343
|
pool.restarts[id] = [];
|
|
331
|
-
|
|
344
|
+
pool.stopped.delete(id);
|
|
345
|
+
await killApp(pool, id);
|
|
332
346
|
if (pool.retired) return false;
|
|
333
347
|
const child = spawnOne(pool, app);
|
|
334
348
|
await withTimeout(child.ready, 20000);
|
|
335
349
|
return !!pool.ports[id];
|
|
336
350
|
}
|
|
337
351
|
|
|
352
|
+
async function stopApp(pool, { id }) {
|
|
353
|
+
pool.stopped.add(id);
|
|
354
|
+
await killApp(pool, id);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Out of pool.children before the kill, so the exit handler does not restart it.
|
|
358
|
+
async function killApp(pool, id) {
|
|
359
|
+
const old = pool.children.get(id);
|
|
360
|
+
pool.children.delete(id);
|
|
361
|
+
delete pool.ports[id];
|
|
362
|
+
if (old) await killChild(old);
|
|
363
|
+
}
|
|
364
|
+
|
|
338
365
|
function killPool(pool) {
|
|
339
366
|
pool.retired = true;
|
|
340
367
|
return Promise.all([...pool.children.values()].map((child) => killChild(child)));
|
|
@@ -365,7 +392,8 @@ async function ensurePool(dir) {
|
|
|
365
392
|
if (pools.size >= POOL_SIZE) await evictPools(dir);
|
|
366
393
|
pool = pools.get(dir);
|
|
367
394
|
if (pool) return pool.ready;
|
|
368
|
-
|
|
395
|
+
const stopped = new Set(ENABLED_APPS.filter((a) => a.deferredStart).map((a) => a.id));
|
|
396
|
+
pool = { dir, children: new Map(), ports: {}, restarts: {}, stopped, retired: false };
|
|
369
397
|
pools.set(dir, pool);
|
|
370
398
|
spawnChildren(pool);
|
|
371
399
|
const ready = [...pool.children.values()].map((c) => c.ready);
|
|
@@ -457,7 +485,7 @@ function spawnOne(pool, app) {
|
|
|
457
485
|
}
|
|
458
486
|
|
|
459
487
|
function spawnChildren(pool) {
|
|
460
|
-
for (const a of ENABLED_APPS) spawnOne(pool, a);
|
|
488
|
+
for (const a of ENABLED_APPS) if (!pool.stopped.has(a.id)) spawnOne(pool, a);
|
|
461
489
|
}
|
|
462
490
|
|
|
463
491
|
const publicPorts = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, a.port]));
|
|
@@ -507,7 +535,7 @@ async function waitForPort(name, deadline) {
|
|
|
507
535
|
const pool = activePool();
|
|
508
536
|
const port = pool?.ports[name];
|
|
509
537
|
if (port) return port;
|
|
510
|
-
if (pool?.children.get(name)?.gaveUp) return null;
|
|
538
|
+
if (pool?.children.get(name)?.gaveUp || pool?.stopped.has(name)) return null;
|
|
511
539
|
const remaining = deadline - Date.now();
|
|
512
540
|
if (remaining <= 0) return null;
|
|
513
541
|
await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
|
|
@@ -515,10 +543,14 @@ async function waitForPort(name, deadline) {
|
|
|
515
543
|
}
|
|
516
544
|
|
|
517
545
|
function noPortReason(name) {
|
|
518
|
-
const
|
|
546
|
+
const pool = activePool();
|
|
547
|
+
if (pool?.stopped.has(name)) {
|
|
548
|
+
return { headline: 'is stopped', detail: `Start ${name} from the app launcher`, final: true };
|
|
549
|
+
}
|
|
550
|
+
const child = pool?.children.get(name);
|
|
519
551
|
if (child?.gaveUp) {
|
|
520
552
|
const detail = `${name} exited after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s. The hub stopped restarting it`;
|
|
521
|
-
return { headline: 'stopped', detail };
|
|
553
|
+
return { headline: 'stopped', detail, final: true };
|
|
522
554
|
}
|
|
523
555
|
return { headline: 'is starting', detail: `${name} is starting` };
|
|
524
556
|
}
|
|
@@ -529,25 +561,29 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt
|
|
|
529
561
|
// hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
|
|
530
562
|
// and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
|
|
531
563
|
// whose only job is to reload must still reload there.
|
|
532
|
-
|
|
533
|
-
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
|
|
534
|
-
<style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
|
|
535
|
-
div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
|
|
536
|
-
</head><body><div><p id="m">${escapeHtml(name)} ${escapeHtml(headline)}…</p><p><code>${escapeHtml(detail)}</code></p></div>
|
|
537
|
-
<script>
|
|
564
|
+
const RETRY_SCRIPT = `<script>
|
|
538
565
|
let n = 0;
|
|
539
566
|
const k = 'hub-retry:' + location.pathname;
|
|
540
567
|
try { n = +(sessionStorage.getItem(k) || 0); sessionStorage.setItem(k, n + 1); } catch {}
|
|
541
568
|
if (n < 3) setTimeout(() => location.reload(), 1000);
|
|
542
569
|
else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m').textContent = 'not responding'; }
|
|
543
|
-
</script
|
|
570
|
+
</script>`;
|
|
571
|
+
|
|
572
|
+
// A final page (an app stopped by the user or given up on) does not reload: only the user brings that app back.
|
|
573
|
+
function retryPage(name, { headline, detail, final }) {
|
|
574
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
|
|
575
|
+
<style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
|
|
576
|
+
div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
|
|
577
|
+
</head><body><div><p id="m">${escapeHtml(name)} ${escapeHtml(headline)}${final ? '' : '…'}</p><p><code>${escapeHtml(detail)}</code></p></div>
|
|
578
|
+
${final ? '' : RETRY_SCRIPT}</body></html>`;
|
|
544
579
|
}
|
|
545
580
|
|
|
546
|
-
function sendUnavailable(req, res, name, status,
|
|
581
|
+
function sendUnavailable(req, res, name, status, reason) {
|
|
547
582
|
if (res.destroyed || res.headersSent) return;
|
|
583
|
+
const { detail } = reason;
|
|
548
584
|
if (isNavigation(req)) {
|
|
549
585
|
res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
|
|
550
|
-
res.end(retryPage(name,
|
|
586
|
+
res.end(retryPage(name, reason));
|
|
551
587
|
return;
|
|
552
588
|
}
|
|
553
589
|
// The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
|
|
@@ -735,8 +771,7 @@ function proxyHandler(name) {
|
|
|
735
771
|
for (let attempt = 1; !res.destroyed; attempt++) {
|
|
736
772
|
const childPort = await waitForPort(name, budget.deadline);
|
|
737
773
|
if (!childPort) {
|
|
738
|
-
|
|
739
|
-
sendUnavailable(req, res, name, 503, detail, headline);
|
|
774
|
+
sendUnavailable(req, res, name, 503, noPortReason(name));
|
|
740
775
|
return;
|
|
741
776
|
}
|
|
742
777
|
if (req.headers.origin) {
|
|
@@ -747,12 +782,12 @@ function proxyHandler(name) {
|
|
|
747
782
|
});
|
|
748
783
|
if (!err) return;
|
|
749
784
|
if (body === null || !RETRYABLE.has(err.code)) {
|
|
750
|
-
sendUnavailable(req, res, name, 502,
|
|
785
|
+
sendUnavailable(req, res, name, 502, { headline: 'did not respond', detail: err.message });
|
|
751
786
|
return;
|
|
752
787
|
}
|
|
753
788
|
const next = await afterConnectFailure(name, childPort, err, attempt, budget);
|
|
754
789
|
if (!next.wait) {
|
|
755
|
-
sendUnavailable(req, res, name, 503, next
|
|
790
|
+
sendUnavailable(req, res, name, 503, next);
|
|
756
791
|
return;
|
|
757
792
|
}
|
|
758
793
|
await delay(next.wait);
|
|
@@ -871,6 +906,7 @@ function appsConfig() {
|
|
|
871
906
|
loading: a.loading,
|
|
872
907
|
publishes: a.publishes,
|
|
873
908
|
};
|
|
909
|
+
if (a.hidden) entry.hidden = true;
|
|
874
910
|
if (TERMINAL_TOKEN && a.id === TERMINAL_APP.id) entry.terminalToken = TERMINAL_TOKEN;
|
|
875
911
|
return [a.id, entry];
|
|
876
912
|
}),
|
|
@@ -1010,18 +1046,26 @@ app.get('/api/apps/stats', async (_req, res) => {
|
|
|
1010
1046
|
const slowMs = (child && portHealth.flagged(child)?.worstMs) ?? null;
|
|
1011
1047
|
return [
|
|
1012
1048
|
a.id,
|
|
1013
|
-
{
|
|
1049
|
+
{
|
|
1050
|
+
...(await stats(child, STATS_TIMEOUT_MS)),
|
|
1051
|
+
slowMs,
|
|
1052
|
+
stopped: !!pool?.stopped.has(a.id),
|
|
1053
|
+
linked: a.linked,
|
|
1054
|
+
version: a.version,
|
|
1055
|
+
plugin: plugin(a.plugin),
|
|
1056
|
+
},
|
|
1014
1057
|
];
|
|
1015
1058
|
}),
|
|
1016
1059
|
);
|
|
1017
1060
|
res.json({ hub: { plugin: plugin(HUB_PLUGIN) }, apps: Object.fromEntries(entries) });
|
|
1018
1061
|
});
|
|
1019
1062
|
|
|
1020
|
-
// Restarting the terminal provider ends its live terminals, so that takes force: true.
|
|
1063
|
+
// Restarting or stopping the terminal provider ends its live terminals, so that takes force: true.
|
|
1064
|
+
// Start and restart are the same call: both kill the app's process if it has one and spawn it.
|
|
1021
1065
|
const restarting = new Set();
|
|
1022
|
-
app.post('/api/apps/:id
|
|
1066
|
+
app.post('/api/apps/:id/:op(restart|start|stop)', async (req, res) => {
|
|
1023
1067
|
const pool = activePool();
|
|
1024
|
-
const { id } = req.params;
|
|
1068
|
+
const { id, op } = req.params;
|
|
1025
1069
|
const target = ENABLED_APPS.find((a) => a.id === id);
|
|
1026
1070
|
if (!pool || !target) {
|
|
1027
1071
|
res.status(404).json({ error: 'Unknown app' });
|
|
@@ -1029,7 +1073,7 @@ app.post('/api/apps/:id/restart', async (req, res) => {
|
|
|
1029
1073
|
}
|
|
1030
1074
|
const key = `${pool.dir}\n${id}`;
|
|
1031
1075
|
if (restarting.has(key)) {
|
|
1032
|
-
res.status(409).json({ error: `${target.name} is already restarting` });
|
|
1076
|
+
res.status(409).json({ error: `${target.name} is already restarting or stopping` });
|
|
1033
1077
|
return;
|
|
1034
1078
|
}
|
|
1035
1079
|
restarting.add(key);
|
|
@@ -1041,8 +1085,11 @@ app.post('/api/apps/:id/restart', async (req, res) => {
|
|
|
1041
1085
|
return;
|
|
1042
1086
|
}
|
|
1043
1087
|
}
|
|
1044
|
-
console.log(`[${id}]
|
|
1045
|
-
if (
|
|
1088
|
+
console.log(`[${id}] ${op} asked for by the hub page`);
|
|
1089
|
+
if (op === 'stop') {
|
|
1090
|
+
await stopApp(pool, target);
|
|
1091
|
+
res.json({ ok: true });
|
|
1092
|
+
} else if (await restartApp(pool, target)) res.json({ ok: true });
|
|
1046
1093
|
else res.status(503).json({ error: `${target.name} did not start` });
|
|
1047
1094
|
} finally {
|
|
1048
1095
|
restarting.delete(key);
|