claude-code-hub 1.27.2 → 1.29.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 +4 -0
- package/extensions/xmas/hub.css +33 -0
- package/extensions/xmas/hub.js +199 -0
- package/extensions/xmas/kanban.css +141 -0
- package/extensions/xmas/kanban.js +57 -0
- package/extensions/xmas/mod.json +57 -0
- package/lib/apps.js +1 -0
- package/lib/cli.js +82 -0
- package/lib/ext.js +122 -11
- package/lib/mods.js +148 -0
- package/lib/themes.js +24 -8
- package/lib/voice/grammar.js +31 -4
- package/mod-surface.json +10 -0
- package/package.json +4 -2
- package/packages/claude-hub-sdk/src/client.js +27 -1
- package/packages/claude-hub-sdk/src/mods.js +128 -0
- package/packages/claude-hub-sdk/src/server.js +26 -3
- package/packages/claude-hub-sdk/src/stub.js +3 -1
- package/public/app.js +59 -7
- package/public/index.html +11 -1
- package/server.js +62 -19
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Claude Code Hub SDK: the runtime of hub mods, shared by client.js and the hub page. mount() and
|
|
2
|
+
// sdk:sync put it in front of client.js; the hub page gets it inside /mods/hub.js.
|
|
3
|
+
// var, not const: the hub page and an app page each load it as a classic script.
|
|
4
|
+
var ClaudeHubModKit = (() => {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
// "<mod id>:<name>". The id must be an installed mod's, so two mods cannot share a topic.
|
|
8
|
+
const TOPIC_RE = /^([a-z0-9-]+):[\w.-]+$/;
|
|
9
|
+
|
|
10
|
+
// The page's mod list is in win.ClaudeHubMods, ahead of this file. Each CSS file loads after the
|
|
11
|
+
// page's own styles; each JS file is a deferred script, so it runs after a deferred app.js, in
|
|
12
|
+
// list order. ?nomods in the page URL loads none: the safe mode for a mod that breaks the page.
|
|
13
|
+
function createMods(win) {
|
|
14
|
+
const doc = win.document;
|
|
15
|
+
const off = /[?&]nomods(?:[=&]|$)/.test(win.location?.search ?? '');
|
|
16
|
+
// Only an app's own tab, or the page on its own. A page nested in an app's page (cck's terminal,
|
|
17
|
+
// a pane) is not what the mod was written for, and ?nomods reaches only the tabs.
|
|
18
|
+
const nested = win.parent !== win.top;
|
|
19
|
+
const assets = off || nested ? null : win.ClaudeHubMods;
|
|
20
|
+
const list = assets?.mods ?? [];
|
|
21
|
+
const ids = new Set(list.map((m) => m.id));
|
|
22
|
+
const listeners = new Map();
|
|
23
|
+
const wrappedNames = new Set();
|
|
24
|
+
const outbox = new Map();
|
|
25
|
+
let sender = null;
|
|
26
|
+
const report = (what, err) => win.console?.error?.(`[hub mod] ${what}:`, err);
|
|
27
|
+
|
|
28
|
+
function load() {
|
|
29
|
+
if (!assets || !doc) return;
|
|
30
|
+
const js = assets.js ?? [];
|
|
31
|
+
// Mod code runs with the page's rights. connect-src stops fetch, XHR, WebSocket and beacons to
|
|
32
|
+
// other hosts; it does not stop image or script loads or navigation, so it guards against a
|
|
33
|
+
// careless mod, not a hostile one. Consent at install is the real boundary. A wider policy
|
|
34
|
+
// (script-src, img-src) would break the apps' own inline scripts and remote images.
|
|
35
|
+
if (js.length) {
|
|
36
|
+
doc.head.appendChild(
|
|
37
|
+
Object.assign(doc.createElement('meta'), {
|
|
38
|
+
httpEquiv: 'Content-Security-Policy',
|
|
39
|
+
content: "connect-src 'self'",
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const tags = [
|
|
44
|
+
...(assets.css ?? []).map((href) => Object.assign(doc.createElement('link'), { rel: 'stylesheet', href })),
|
|
45
|
+
...js.map((src) => Object.assign(doc.createElement('script'), { src, defer: true })),
|
|
46
|
+
];
|
|
47
|
+
// document.write keeps a parser-inserted script deferred and in order; appendChild needs async off for that.
|
|
48
|
+
if (!tags.length) return;
|
|
49
|
+
if (doc.readyState === 'loading') doc.write(tags.map((t) => t.outerHTML).join(''));
|
|
50
|
+
else
|
|
51
|
+
for (const t of tags) {
|
|
52
|
+
if (t.tagName === 'SCRIPT') t.async = false;
|
|
53
|
+
(t.tagName === 'LINK' ? doc.head : doc.body).appendChild(t);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// fn(original, ...args). When fn throws, the wrapper returns what original returned, and runs
|
|
58
|
+
// original first if fn had not called it yet, so a broken mod leaves the app as it was.
|
|
59
|
+
function wrap(name, fn) {
|
|
60
|
+
const original = win[name];
|
|
61
|
+
if (typeof original !== 'function') {
|
|
62
|
+
report(`wrap(${name})`, new Error('the app has no such global function'));
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (wrappedNames.has(name))
|
|
66
|
+
win.console?.warn?.(`[hub mod] wrap(${name}): two mods wrap it; the later one runs outside the earlier`);
|
|
67
|
+
wrappedNames.add(name);
|
|
68
|
+
win[name] = function wrapped(...args) {
|
|
69
|
+
let called = false;
|
|
70
|
+
let result;
|
|
71
|
+
const next = (...a) => {
|
|
72
|
+
called = true;
|
|
73
|
+
result = original.apply(this, a.length ? a : args);
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
return fn.call(this, next, ...args);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
report(`wrap(${name})`, err);
|
|
80
|
+
return called ? result : original.apply(this, args);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function on(event, fn) {
|
|
87
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
88
|
+
listeners.get(event).add(fn);
|
|
89
|
+
return () => listeners.get(event)?.delete(fn);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emit(event, detail) {
|
|
93
|
+
for (const fn of listeners.get(event) ?? []) {
|
|
94
|
+
try {
|
|
95
|
+
fn(detail);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
report(`on(${event})`, err);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// To the same mod's code on the hub page, as mod.on(topic) there with {app, data}. Before the
|
|
103
|
+
// hub answers, only the latest data per topic waits.
|
|
104
|
+
function send(topic, data) {
|
|
105
|
+
const m = TOPIC_RE.exec(typeof topic === 'string' ? topic : '');
|
|
106
|
+
if (!m || !ids.has(m[1])) {
|
|
107
|
+
report(`send(${topic})`, new Error('the topic must be "<mod id>:<name>", with the id of an installed mod'));
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (sender) sender({ type: 'hub:mod', topic, data });
|
|
111
|
+
else outbox.set(topic, data);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function connectSender(fn) {
|
|
116
|
+
sender = fn;
|
|
117
|
+
for (const [topic, data] of outbox) fn({ type: 'hub:mod', topic, data });
|
|
118
|
+
outbox.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
load();
|
|
122
|
+
return { mod: { list, wrap, on, emit, send }, connectSender };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { createMods, TOPIC_RE };
|
|
126
|
+
})();
|
|
127
|
+
|
|
128
|
+
if (typeof module === 'object' && module.exports) module.exports = ClaudeHubModKit;
|
|
@@ -40,17 +40,40 @@ function answerHub() {
|
|
|
40
40
|
process.channel.unref();
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// What an app page loads as /vendor/claude-hub-sdk.js: keys.js, then client.js or stub.js.
|
|
43
|
+
// What an app page loads as /vendor/claude-hub-sdk.js: keys.js, then client.js with mods.js ahead of it, or stub.js.
|
|
44
44
|
function bundle(file) {
|
|
45
45
|
const read = (f) => fs.readFileSync(path.join(__dirname, f), 'utf8');
|
|
46
|
-
|
|
46
|
+
const parts = file === 'client.js' ? ['keys.js', 'mods.js', file] : ['keys.js', file];
|
|
47
|
+
return parts.map(read).join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// HUB_MODS, fixed for the process's life: {assets, files}. `assets` is the page's mod list as the
|
|
51
|
+
// client reads it; `files` maps each /mods path to the file on disk, and only those are served.
|
|
52
|
+
// The hub builds both (lib/mods.js appModEnv), so the URL format is not repeated here.
|
|
53
|
+
function readMods() {
|
|
54
|
+
try {
|
|
55
|
+
const { assets, files } = JSON.parse(process.env.HUB_MODS || '{}');
|
|
56
|
+
if (assets && typeof assets === 'object' && files && typeof files === 'object')
|
|
57
|
+
return { assets, files: new Map(Object.entries(files)) };
|
|
58
|
+
} catch {}
|
|
59
|
+
return { assets: null, files: new Map() };
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
// Mount before express.static: the hub's client must win over the app's stub in public/vendor.
|
|
50
63
|
function mount(app) {
|
|
51
64
|
answerHub();
|
|
52
|
-
const
|
|
65
|
+
const { assets, files } = readMods();
|
|
66
|
+
const prefix = assets ? `self.ClaudeHubMods = ${JSON.stringify(assets)};\n` : '';
|
|
67
|
+
const client = prefix + bundle('client.js');
|
|
53
68
|
app.get('/vendor/claude-hub-sdk.js', (_req, res) => res.type('js').send(client));
|
|
69
|
+
if (files.size) {
|
|
70
|
+
// app.use, not a route pattern, so it works on each app's Express version.
|
|
71
|
+
app.use('/mods', (req, res, next) => {
|
|
72
|
+
const abs = files.get(req.path);
|
|
73
|
+
if (!abs || (req.method !== 'GET' && req.method !== 'HEAD')) return next();
|
|
74
|
+
res.sendFile(abs, { maxAge: '1y', immutable: true });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
54
77
|
app.get('/hub-config', (_req, res) => {
|
|
55
78
|
res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
|
|
56
79
|
});
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
forwards: () => false,
|
|
41
41
|
forwardCombos: () => [],
|
|
42
42
|
keyLabel: () => null,
|
|
43
|
+
voiceCommands: () => [],
|
|
43
44
|
closeGuard() {},
|
|
44
45
|
openExternal(url) {
|
|
45
46
|
win.open(url, '_blank', 'noopener');
|
|
@@ -49,7 +50,8 @@
|
|
|
49
50
|
return hub;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
const mod = { list: [], wrap: () => false, on: () => off, emit() {}, send: () => false };
|
|
54
|
+
return { connect, comboOf, mod };
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
|
package/public/app.js
CHANGED
|
@@ -126,6 +126,9 @@ const HUB_VARS = {
|
|
|
126
126
|
};
|
|
127
127
|
// The last applied variables, so the loading screen paints in the theme before /api/config lands.
|
|
128
128
|
const PALETTE_KEY = 'hub-palette';
|
|
129
|
+
// /?nomods: the safe mode for a mod that breaks a page. The hub page and every app frame load no mod.
|
|
130
|
+
const NO_MODS = new URLSearchParams(location.search).has('nomods');
|
|
131
|
+
if (NO_MODS) document.querySelector('link[href="/mods/hub.css"]')?.remove();
|
|
129
132
|
|
|
130
133
|
// Paints the hub's own surfaces (loading screen, palette) in the active theme. The hub relays
|
|
131
134
|
// hub:theme to the sub-apps; this is what makes it apply to the hub's own chrome too.
|
|
@@ -133,6 +136,7 @@ function applyHubTheme() {
|
|
|
133
136
|
const mode = themeState.theme ?? osTheme();
|
|
134
137
|
const root = document.documentElement;
|
|
135
138
|
root.classList.toggle('light', mode === 'light');
|
|
139
|
+
root.dataset.hubMode = mode;
|
|
136
140
|
// No app has named a theme yet: paint the one every app starts with.
|
|
137
141
|
const theme = activeColorTheme() ?? DEFAULT_COLOR_THEME;
|
|
138
142
|
const key = `${theme}/${mode}`;
|
|
@@ -482,15 +486,51 @@ const handledHere = (action) => actions[action]?.app === activeApp;
|
|
|
482
486
|
const voiceRowsHere = () => voiceCommands.filter((c) => handledHere(c.action));
|
|
483
487
|
const tabName = () => apps[activeApp]?.name ?? 'this tab';
|
|
484
488
|
|
|
489
|
+
// The voice.context of the app on screen: {title, rows: {action: false to hide the row, or a help text}}.
|
|
490
|
+
function voiceContextHere() {
|
|
491
|
+
const last = published.get('voice.context');
|
|
492
|
+
return last?.from === activeApp && isPlainObject(last.payload?.rows) ? last.payload : null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// With a title, the rows the app marks come first and the rest fold into one "Also" line.
|
|
496
|
+
function voiceHelpRows(here, ctx) {
|
|
497
|
+
const marks = ctx?.rows ?? {};
|
|
498
|
+
const shown = here.filter((c) => marks[c.action] !== false);
|
|
499
|
+
const row = (c) =>
|
|
500
|
+
helpRow(
|
|
501
|
+
c.action,
|
|
502
|
+
`Hub, ${c.say}`,
|
|
503
|
+
c.param ? `<${c.param}>` : '',
|
|
504
|
+
typeof marks[c.action] === 'string' ? marks[c.action] : c.help,
|
|
505
|
+
);
|
|
506
|
+
if (typeof ctx?.title !== 'string') {
|
|
507
|
+
const draft = helpRow('session.paste', 'anything else', '', 'Typed into the session as a draft', 'other');
|
|
508
|
+
return [...shown.map(row), ...(voice.drafts ? [draft] : [])];
|
|
509
|
+
}
|
|
510
|
+
const folded = [
|
|
511
|
+
...shown.filter((c) => !marks[c.action]),
|
|
512
|
+
...(voice.drafts ? [{ action: 'session.paste', say: 'a draft' }] : []),
|
|
513
|
+
];
|
|
514
|
+
const fold = helpRow(
|
|
515
|
+
folded.map((c) => c.action).join(' '),
|
|
516
|
+
`Also: ${folded.map((c) => c.say).join(', ')}`,
|
|
517
|
+
'',
|
|
518
|
+
'',
|
|
519
|
+
'other fold',
|
|
520
|
+
);
|
|
521
|
+
return [...shown.filter((c) => marks[c.action]).map(row), ...(folded.length ? [fold] : [])];
|
|
522
|
+
}
|
|
523
|
+
|
|
485
524
|
// Open for the whole hold: what the user can say on this tab, with the row the words match lit.
|
|
486
525
|
function showVoiceHelp() {
|
|
487
526
|
const panel = document.getElementById('voice-help');
|
|
488
527
|
clearTimeout(voice.helpTimer);
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
document.getElementById('voice-help-
|
|
493
|
-
|
|
528
|
+
const ctx = voiceContextHere();
|
|
529
|
+
const asks = typeof ctx?.title === 'string';
|
|
530
|
+
document.getElementById('voice-help-rows').replaceChildren(...voiceHelpRows(voiceRowsHere(), ctx));
|
|
531
|
+
const title = document.getElementById('voice-help-title');
|
|
532
|
+
title.replaceChildren(el('span', '', asks ? ctx.title : `What you can say on ${tabName()}`));
|
|
533
|
+
title.classList.toggle('asks', asks);
|
|
494
534
|
document.getElementById('voice-help-tip').textContent = voice.drafts
|
|
495
535
|
? 'Enter during the hold sends · Backspace cancels'
|
|
496
536
|
: 'Backspace cancels';
|
|
@@ -506,7 +546,7 @@ function hideVoiceHelp(afterMs = 0) {
|
|
|
506
546
|
|
|
507
547
|
function markVoiceHelp(action) {
|
|
508
548
|
for (const row of document.getElementById('voice-help-rows').children) {
|
|
509
|
-
row.classList.toggle('on', row.dataset.action
|
|
549
|
+
row.classList.toggle('on', row.dataset.action.split(' ').includes(action));
|
|
510
550
|
}
|
|
511
551
|
}
|
|
512
552
|
|
|
@@ -967,7 +1007,13 @@ function loadApp(id, src, { link = false } = {}) {
|
|
|
967
1007
|
// The terminal token rides in the fragment so it never reaches a server log or a Referer.
|
|
968
1008
|
function appSrc(id, path = '') {
|
|
969
1009
|
const token = apps[id].terminalToken;
|
|
970
|
-
|
|
1010
|
+
let src = apps[id].url + path;
|
|
1011
|
+
const url = NO_MODS && URL.parse(src);
|
|
1012
|
+
if (url) {
|
|
1013
|
+
url.searchParams.set('nomods', '1');
|
|
1014
|
+
src = url.href;
|
|
1015
|
+
}
|
|
1016
|
+
return src + (token ? `#t=${token}` : '');
|
|
971
1017
|
}
|
|
972
1018
|
|
|
973
1019
|
// From each app's manifest. Before /api/config lands there are none, and the first line is generic.
|
|
@@ -1072,6 +1118,9 @@ function welcomeFor(data, appId) {
|
|
|
1072
1118
|
actions: [...Object.keys(actions), ...(hubDictates() ? [DICTATE] : [])],
|
|
1073
1119
|
origins: Object.keys(apps).map(originOf),
|
|
1074
1120
|
settings: apps[appId]?.settings ?? {},
|
|
1121
|
+
voice: voiceOn
|
|
1122
|
+
? voiceCommands.filter((c) => actions[c.action]?.app === appId).map(({ say, help }) => ({ say, help }))
|
|
1123
|
+
: [],
|
|
1075
1124
|
};
|
|
1076
1125
|
}
|
|
1077
1126
|
|
|
@@ -1187,6 +1236,9 @@ function listenMessages() {
|
|
|
1187
1236
|
openExternal(data);
|
|
1188
1237
|
} else if (data.type === 'hub:theme') {
|
|
1189
1238
|
onThemeMessage(data);
|
|
1239
|
+
} else if (data.type === 'hub:mod' && typeof data.topic === 'string') {
|
|
1240
|
+
// From a mod's code in the app (mod.send) to the same mod on this page, which /mods/hub.js loads.
|
|
1241
|
+
window.ClaudeHub?.mod?.emit(data.topic, { app: appId, data: data.data });
|
|
1190
1242
|
}
|
|
1191
1243
|
});
|
|
1192
1244
|
}
|
package/public/index.html
CHANGED
|
@@ -544,9 +544,11 @@
|
|
|
544
544
|
.voice-help {
|
|
545
545
|
position: fixed;
|
|
546
546
|
right: 16px;
|
|
547
|
-
|
|
547
|
+
top: 50%;
|
|
548
|
+
translate: 0 -50%;
|
|
548
549
|
z-index: 30;
|
|
549
550
|
width: min(360px, calc(100vw - 32px));
|
|
551
|
+
max-height: calc(100vh - 32px);
|
|
550
552
|
overflow: hidden;
|
|
551
553
|
background: var(--surface);
|
|
552
554
|
border: 1px solid var(--border);
|
|
@@ -591,6 +593,12 @@
|
|
|
591
593
|
.voice-help-row.other .say { color: var(--text); }
|
|
592
594
|
.voice-help-row .say i { color: var(--text-dim); font-style: normal; }
|
|
593
595
|
.voice-help-row .what { color: var(--text-dim); font-size: 12px; }
|
|
596
|
+
.voice-help-row .what:empty { display: none; }
|
|
597
|
+
.voice-help-row.fold { padding-block: 6px; }
|
|
598
|
+
.voice-help-row.fold .say { color: var(--text-dim); font-size: 12px; }
|
|
599
|
+
.voice-help-title::before { flex-shrink: 0; }
|
|
600
|
+
.voice-help-title > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
601
|
+
.voice-help-title.asks { color: var(--warn); }
|
|
594
602
|
.voice-help-tip { padding: 8px 14px; background: var(--bg); border-top: 1px solid var(--border); color: var(--text-dim); font-size: 12px; }
|
|
595
603
|
@media (prefers-reduced-motion: reduce) {
|
|
596
604
|
.voice-help, .voice-help-title::before { animation: none; }
|
|
@@ -630,6 +638,7 @@
|
|
|
630
638
|
.update-toast-close:hover { color: var(--text); }
|
|
631
639
|
.error-toast { z-index: 25; border-left-color: #e5484d; }
|
|
632
640
|
</style>
|
|
641
|
+
<link rel="stylesheet" href="/mods/hub.css">
|
|
633
642
|
</head>
|
|
634
643
|
<body>
|
|
635
644
|
<div class="iframe-container" id="iframe-container">
|
|
@@ -740,5 +749,6 @@
|
|
|
740
749
|
|
|
741
750
|
<script src="/sdk/keys.js"></script>
|
|
742
751
|
<script src="/app.js"></script>
|
|
752
|
+
<script src="/mods/hub.js"></script>
|
|
743
753
|
</body>
|
|
744
754
|
</html>
|
package/server.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
const { HUB_VERSION, checkArgs, printedUrl } = require('./lib/cli');
|
|
4
|
+
|
|
5
|
+
// Before the other requires: -v and -h answer at once, and a bad flag never reaches the running-hub check,
|
|
6
|
+
// which prints the hub URL with its token.
|
|
7
|
+
const cli = checkArgs(process.argv.slice(2));
|
|
8
|
+
if (cli.exit !== undefined) {
|
|
9
|
+
(cli.exit ? console.error : console.log)(cli.out);
|
|
10
|
+
process.exit(cli.exit);
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
const { spawn } = require('child_process');
|
|
4
14
|
const crypto = require('crypto');
|
|
5
15
|
const express = require('express');
|
|
@@ -16,7 +26,8 @@ const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
|
|
|
16
26
|
const { ping, stats } = require('./lib/ipc');
|
|
17
27
|
const { killChild } = require('./lib/kill-child');
|
|
18
28
|
const { createPortHealth } = require('./lib/port-health');
|
|
19
|
-
const { readRegistry, themeConfig } = require('./lib/themes');
|
|
29
|
+
const { readRegistry, themeConfig, modThemes } = require('./lib/themes');
|
|
30
|
+
const { resolveMods, modAssets, appModEnv, readSurface, hubModScript } = require('./lib/mods');
|
|
20
31
|
const { createUpdateCheck } = require('./lib/update-check');
|
|
21
32
|
const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
|
|
22
33
|
const { HUB_PLUGIN } = require('./lib/install');
|
|
@@ -32,7 +43,6 @@ const {
|
|
|
32
43
|
MAX_POOL_SIZE,
|
|
33
44
|
settingValues,
|
|
34
45
|
} = require('./lib/settings');
|
|
35
|
-
const { version: HUB_VERSION } = require('./package.json');
|
|
36
46
|
|
|
37
47
|
function getArg(name) {
|
|
38
48
|
const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
|
|
@@ -159,6 +169,9 @@ function resolveHubConfig() {
|
|
|
159
169
|
// Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
|
|
160
170
|
if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
|
|
161
171
|
if (Array.isArray(saved.apps)) config.apps = saved.apps;
|
|
172
|
+
if (Array.isArray(saved.addons)) config.addons = saved.addons;
|
|
173
|
+
if (Array.isArray(saved.mods)) config.mods = saved.mods;
|
|
174
|
+
if (typeof saved.modsEnabled === 'boolean') config.modsEnabled = saved.modsEnabled;
|
|
162
175
|
if (saved.keys !== undefined) config.keys = saved.keys;
|
|
163
176
|
if (saved.voice && typeof saved.voice === 'object') config.voice = saved.voice;
|
|
164
177
|
copyLive(saved, config);
|
|
@@ -206,6 +219,18 @@ if (!ENABLED_APPS.length) {
|
|
|
206
219
|
console.error(`No enabled app could load (${ids}). The reasons are logged above.`);
|
|
207
220
|
process.exit(1);
|
|
208
221
|
}
|
|
222
|
+
const SURFACES = {
|
|
223
|
+
hub: readSurface(__dirname),
|
|
224
|
+
...Object.fromEntries(ENABLED_APPS.map((a) => [a.id, readSurface(a.root)])),
|
|
225
|
+
};
|
|
226
|
+
const MODS_OFF = process.argv.includes('--no-mods') || hubConfig.modsEnabled === false;
|
|
227
|
+
if (MODS_OFF && hubConfig.mods?.length) console.log('mods are off (--no-mods or modsEnabled: false): no mod loads');
|
|
228
|
+
const MODS = resolveMods(hubConfig, path.dirname(HUB_CONFIG_FILE), {
|
|
229
|
+
warn: console.log,
|
|
230
|
+
surfaces: SURFACES,
|
|
231
|
+
off: MODS_OFF,
|
|
232
|
+
});
|
|
233
|
+
for (const m of MODS) console.log(`mod ${m.id} changes ${Object.keys(m.targets).join(', ')}`);
|
|
209
234
|
const ACTIONS = actionTable(ENABLED_APPS);
|
|
210
235
|
const { keys: USER_KEYS, lines: keyLines } = userKeys(hubConfig.keys, ENABLED_APPS);
|
|
211
236
|
for (const line of keyLines) console.log(`${HUB_CONFIG_FILE}: ${line}`);
|
|
@@ -299,6 +324,10 @@ function childEnv(pool, name) {
|
|
|
299
324
|
ALLOWED_HOSTS: net.ALLOWED_HOSTS,
|
|
300
325
|
HUB_SDK_SERVER,
|
|
301
326
|
};
|
|
327
|
+
// The app's own process serves its mod files (the SDK's mount), so they load from the app's origin.
|
|
328
|
+
const mods = appModEnv(MODS, name);
|
|
329
|
+
if (mods) env.HUB_MODS = mods;
|
|
330
|
+
else delete env.HUB_MODS;
|
|
302
331
|
if (TERMINAL.enabled && name === TERMINAL_APP.id) {
|
|
303
332
|
env.CCK_TERMINAL = JSON.stringify(TERMINAL);
|
|
304
333
|
env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
|
|
@@ -919,6 +948,7 @@ app.use(express.json());
|
|
|
919
948
|
const THEME_REGISTRY = readRegistry();
|
|
920
949
|
// Read on each /api/config, so an edit applies on the next page load.
|
|
921
950
|
const USER_THEMES_FILE = path.join(HUB_DIR, 'themes.json');
|
|
951
|
+
const MOD_THEMES = modThemes(THEME_REGISTRY, MODS, console.log);
|
|
922
952
|
|
|
923
953
|
function appsConfig() {
|
|
924
954
|
return Object.fromEntries(
|
|
@@ -958,7 +988,7 @@ app.get('/api/update', async (_req, res) => {
|
|
|
958
988
|
|
|
959
989
|
app.get('/api/config', (_req, res) => {
|
|
960
990
|
res.json({
|
|
961
|
-
...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE }),
|
|
991
|
+
...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE, extra: MOD_THEMES }),
|
|
962
992
|
apps: appsConfig(),
|
|
963
993
|
actions: ACTIONS,
|
|
964
994
|
keys: USER_KEYS,
|
|
@@ -1328,7 +1358,6 @@ app.get('/api/projects', async (_req, res) => {
|
|
|
1328
1358
|
});
|
|
1329
1359
|
|
|
1330
1360
|
// Voice control, off unless config.json has voice.enabled; lib/voice loads only then.
|
|
1331
|
-
const VOICE_LOG = path.join(HUB_DIR, 'voice.jsonl');
|
|
1332
1361
|
const voice = hubConfig.voice?.enabled === true ? loadVoice() : null;
|
|
1333
1362
|
|
|
1334
1363
|
function loadVoice() {
|
|
@@ -1353,27 +1382,17 @@ const heldOn = (req) => (typeof req.query.app === 'string' ? req.query.app : und
|
|
|
1353
1382
|
app.post('/api/voice/text', express.json({ limit: '64kb' }), async (req, res) => {
|
|
1354
1383
|
if (!voice) return res.status(404).json({ error: 'voice is off' });
|
|
1355
1384
|
if (typeof req.body?.text !== 'string') return res.status(400).json({ error: 'text is required' });
|
|
1356
|
-
|
|
1357
|
-
const r = await voice.text(req.body.text, req.body.final === true, heldOn(req));
|
|
1358
|
-
if (req.body.final === true) logVoice(r, t0, { provider: 'browser' });
|
|
1359
|
-
res.json(r);
|
|
1385
|
+
res.json(await voice.text(req.body.text, req.body.final === true, heldOn(req)));
|
|
1360
1386
|
});
|
|
1361
1387
|
|
|
1362
1388
|
app.post('/api/voice/:op', async (req, res) => {
|
|
1363
1389
|
if (!voice) return res.status(404).json({ error: 'voice is off' });
|
|
1364
1390
|
const { op } = req.params;
|
|
1365
1391
|
if (!['start', 'stop', 'dictate', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
|
|
1366
|
-
const t0 = Date.now();
|
|
1367
1392
|
const r = await voice[op](heldOn(req));
|
|
1368
|
-
if ((op === 'stop' || op === 'dictate') && r.ok) logVoice(r, t0);
|
|
1369
1393
|
res.status(r.ok ? 200 : 502).json(r);
|
|
1370
1394
|
});
|
|
1371
1395
|
|
|
1372
|
-
function logVoice(r, t0, extra) {
|
|
1373
|
-
const entry = { at: new Date().toISOString(), text: r.text, command: r.command, ms: Date.now() - t0, ...extra };
|
|
1374
|
-
fs.appendFile(VOICE_LOG, `${JSON.stringify(entry)}\n`, () => {});
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
1396
|
// Live speech: the page sends mic audio while the key is held, about 5 posts a second, and each
|
|
1378
1397
|
// answer carries the text so far. 501 sends the page back to /api/voice/start and /stop.
|
|
1379
1398
|
app.post(
|
|
@@ -1385,9 +1404,7 @@ app.post(
|
|
|
1385
1404
|
if (!['start', 'audio', 'finish', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
|
|
1386
1405
|
if (!voice.stream) return res.status(501).json({ error: 'no live speech' });
|
|
1387
1406
|
if (op === 'audio' && !Buffer.isBuffer(req.body)) return res.status(400).json({ error: 'audio is required' });
|
|
1388
|
-
const t0 = Date.now();
|
|
1389
1407
|
const r = await voice.stream[op](req.body, heldOn(req));
|
|
1390
|
-
if (op === 'finish' && r.ok) logVoice(r, t0, { live: true });
|
|
1391
1408
|
res.status(r.ok ? 200 : r.unsupported ? 501 : 502).json(r);
|
|
1392
1409
|
},
|
|
1393
1410
|
);
|
|
@@ -1409,6 +1426,27 @@ app.get('/api/resolve-path', (req, res) => {
|
|
|
1409
1426
|
|
|
1410
1427
|
const HUB_KEYS_JS = path.join(__dirname, 'packages', 'claude-hub-sdk', 'src', 'keys.js');
|
|
1411
1428
|
app.get('/sdk/keys.js', (_req, res) => res.sendFile(HUB_KEYS_JS));
|
|
1429
|
+
// Public like the rest of the page's styles. Only the files a manifest lists are served.
|
|
1430
|
+
const HUB_MOD_ASSETS = modAssets(MODS, 'hub');
|
|
1431
|
+
const HUB_MOD_CSS = HUB_MOD_ASSETS.css.map((u) => `@import url("${u}");`).join('\n');
|
|
1432
|
+
const HUB_MOD_JS = hubModScript(HUB_MOD_ASSETS);
|
|
1433
|
+
app.get('/mods/hub.css', (_req, res) => res.type('css').set('Cache-Control', 'no-cache').send(HUB_MOD_CSS));
|
|
1434
|
+
app.get('/mods/hub.js', (_req, res) => res.type('js').set('Cache-Control', 'no-cache').send(HUB_MOD_JS));
|
|
1435
|
+
app.get('/mods/:id/*', (req, res, next) => {
|
|
1436
|
+
const abs = HUB_MOD_ASSETS.files.get(`/${req.params.id}/${req.params[0]}`);
|
|
1437
|
+
if (!abs) return next();
|
|
1438
|
+
res.sendFile(abs, { maxAge: '1y', immutable: true });
|
|
1439
|
+
});
|
|
1440
|
+
// With no hub mod, or under ?nomods, the page leaves out the mod tags: no two extra requests, and
|
|
1441
|
+
// no render-blocking stylesheet that safe mode would paint before app.js removes it.
|
|
1442
|
+
const HUB_PAGE = path.join(__dirname, 'public', 'index.html');
|
|
1443
|
+
const HUB_HAS_MODS = HUB_MOD_ASSETS.css.length > 0 || HUB_MOD_ASSETS.js.length > 0;
|
|
1444
|
+
const MOD_TAGS = ['<link rel="stylesheet" href="/mods/hub.css">', '<script src="/mods/hub.js"></script>'];
|
|
1445
|
+
app.get(['/', '/index.html'], (req, res, next) => {
|
|
1446
|
+
if (HUB_HAS_MODS && req.query.nomods === undefined) return next();
|
|
1447
|
+
const html = MOD_TAGS.reduce((page, tag) => page.replace(tag, ''), fs.readFileSync(HUB_PAGE, 'utf8'));
|
|
1448
|
+
res.type('html').set('Cache-Control', 'no-cache').send(html);
|
|
1449
|
+
});
|
|
1412
1450
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
1413
1451
|
|
|
1414
1452
|
// Children get HUB_URL at spawn and trust only that origin, so the first pool waits for the port
|
|
@@ -1435,7 +1473,7 @@ if (!TRAY_FLAG) {
|
|
|
1435
1473
|
listenWithFallback(app, HUB_PORT, onReady, '');
|
|
1436
1474
|
return;
|
|
1437
1475
|
}
|
|
1438
|
-
|
|
1476
|
+
printUrl(`Claude Code Hub already runs for ${HUB_DIR} at`, running.port);
|
|
1439
1477
|
if (process.argv.includes('--open')) {
|
|
1440
1478
|
import('open').then((m) => m.default(accessUrl(running.port))).finally(() => process.exit(0));
|
|
1441
1479
|
} else {
|
|
@@ -1448,8 +1486,13 @@ function accessUrl(port) {
|
|
|
1448
1486
|
return `http://localhost:${port}/?token=${HUB_TOKEN}`;
|
|
1449
1487
|
}
|
|
1450
1488
|
|
|
1489
|
+
function printUrl(line, port) {
|
|
1490
|
+
const show = process.argv.includes('--show-token') || require('tty').isatty(1);
|
|
1491
|
+
console.log(printedUrl(line, accessUrl(port), { show, tokenFile: HUB_TOKEN_FILE }));
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1451
1494
|
function printBanner(port) {
|
|
1452
|
-
|
|
1495
|
+
printUrl('Claude Code Hub running at', port);
|
|
1453
1496
|
const warning = net.exposureWarning();
|
|
1454
1497
|
if (warning) console.log(warning);
|
|
1455
1498
|
console.log('Type "q" or "exit" to stop the server');
|