claude-code-hub 0.42.0 → 1.0.1
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 +6 -0
- package/lib/actions.js +39 -0
- package/lib/apps.js +136 -9
- package/lib/contain.js +78 -0
- package/lib/install.js +89 -0
- package/lib/kill-child.js +32 -0
- package/lib/net-guard.js +6 -1
- package/lib/ping.js +23 -0
- package/lib/themes.js +134 -0
- package/package.json +12 -9
- package/packages/claude-hub-sdk/src/client.js +371 -0
- package/packages/claude-hub-sdk/src/server.js +30 -0
- package/packages/claude-hub-sdk/src/stub.js +49 -0
- package/plugin/.claude-plugin/marketplace.json +14 -0
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +8 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/SKILL.md +30 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +93 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/themes.md +57 -0
- package/public/app.js +382 -132
- package/public/index.html +23 -2
- package/public/locked.html +1 -1
- package/server.js +304 -115
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Claude Code Hub SDK: the app side of the hub protocol v1.
|
|
2
|
+
// A classic script. Load it as the first element in <body>, with no defer or async,
|
|
3
|
+
// so the cached theme is on the page before the first paint (protocol section 6, rule 3).
|
|
4
|
+
((root) => {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const VARS_KEY = 'claude-hub:vars';
|
|
8
|
+
const WELCOME_WAIT_MS = 2000;
|
|
9
|
+
|
|
10
|
+
// The hub's comboOf(). macOS turns Option+<key> into another character, so e.code is the fallback.
|
|
11
|
+
function comboOf(e) {
|
|
12
|
+
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
13
|
+
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
14
|
+
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
15
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
16
|
+
return [...mods, key].filter(Boolean).join('+');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isVars(v) {
|
|
20
|
+
return (
|
|
21
|
+
!!v &&
|
|
22
|
+
typeof v === 'object' &&
|
|
23
|
+
!Array.isArray(v) &&
|
|
24
|
+
Object.entries(v).every(([k, val]) => k.startsWith('--') && typeof val === 'string')
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sameVars(a, b) {
|
|
29
|
+
if (!a || !b) return a === b;
|
|
30
|
+
const keys = Object.keys(a);
|
|
31
|
+
return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const strings = (list) => new Set(Array.isArray(list) ? list.filter((s) => typeof s === 'string') : []);
|
|
35
|
+
|
|
36
|
+
function createClaudeHub(win) {
|
|
37
|
+
const doc = win.document;
|
|
38
|
+
const framed = win.top !== win;
|
|
39
|
+
let appliedVars = null;
|
|
40
|
+
let hub = null;
|
|
41
|
+
|
|
42
|
+
function setVars(vars) {
|
|
43
|
+
const body = doc.body;
|
|
44
|
+
if (!body || sameVars(vars, appliedVars)) return false;
|
|
45
|
+
for (const k of Object.keys(appliedVars || {})) if (!vars || !(k in vars)) body.style.removeProperty(k);
|
|
46
|
+
for (const [k, v] of Object.entries(vars || {})) body.style.setProperty(k, v);
|
|
47
|
+
appliedVars = vars;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function adoptVars(vars) {
|
|
52
|
+
if (!setVars(vars) && vars) return;
|
|
53
|
+
try {
|
|
54
|
+
if (vars) win.localStorage.setItem(VARS_KEY, JSON.stringify(vars));
|
|
55
|
+
else win.localStorage.removeItem(VARS_KEY);
|
|
56
|
+
} catch (_) {}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readVars() {
|
|
60
|
+
try {
|
|
61
|
+
const v = JSON.parse(win.localStorage.getItem(VARS_KEY));
|
|
62
|
+
return isVars(v) ? v : null;
|
|
63
|
+
} catch (_) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (framed) setVars(readVars());
|
|
69
|
+
|
|
70
|
+
function connect({ standalone = {} } = {}) {
|
|
71
|
+
if (hub) return hub;
|
|
72
|
+
const topics = new Map();
|
|
73
|
+
const activeFns = new Set();
|
|
74
|
+
const statusFns = new Set();
|
|
75
|
+
const handlers = new Map();
|
|
76
|
+
const pending = new Map();
|
|
77
|
+
const tokenWaiters = new Set();
|
|
78
|
+
const themesFns = new Set();
|
|
79
|
+
const queued = [];
|
|
80
|
+
// Topic → payload: the latest publish before welcome.
|
|
81
|
+
const outbox = new Map();
|
|
82
|
+
let status = 'connecting';
|
|
83
|
+
let origin = null;
|
|
84
|
+
let welcome = null;
|
|
85
|
+
let forward = null;
|
|
86
|
+
let themeBinding = null;
|
|
87
|
+
let lastTheme = null;
|
|
88
|
+
let swatchesPainted = false;
|
|
89
|
+
let nextId = 0;
|
|
90
|
+
|
|
91
|
+
const waiting = () => status === 'connecting' || status === 'waiting';
|
|
92
|
+
const standaloneFn = (action) => {
|
|
93
|
+
if (status !== 'standalone') return null;
|
|
94
|
+
const fn = (typeof standalone === 'function' ? standalone() : standalone)[action];
|
|
95
|
+
return typeof fn === 'function' ? fn : null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function post(msg) {
|
|
99
|
+
if (origin) win.parent.postMessage(msg, origin);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setStatus(next) {
|
|
103
|
+
status = next;
|
|
104
|
+
for (const fn of statusFns) fn(next);
|
|
105
|
+
if (waiting()) return;
|
|
106
|
+
for (const run of queued.splice(0)) run();
|
|
107
|
+
for (const [topic, payload] of outbox) hub.publish(topic, payload);
|
|
108
|
+
outbox.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function emit(topic, payload) {
|
|
112
|
+
for (const fn of topics.get(topic) || []) fn(payload);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function themeKey(t) {
|
|
116
|
+
return `${t.theme}|${t.colorTheme}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function applyMode(p) {
|
|
120
|
+
if (!themeBinding || typeof p.theme !== 'string') return;
|
|
121
|
+
const cur = themeBinding.get();
|
|
122
|
+
const colorTheme = typeof p.colorTheme === 'string' ? p.colorTheme : cur.colorTheme;
|
|
123
|
+
if (cur.theme !== p.theme || cur.colorTheme !== colorTheme) themeBinding.set({ theme: p.theme, colorTheme });
|
|
124
|
+
themeBinding.last = themeKey(themeBinding.get());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function themeIn(p) {
|
|
128
|
+
if (!p || typeof p !== 'object') return;
|
|
129
|
+
if (isVars(p.vars)) adoptVars(p.vars);
|
|
130
|
+
else if (!waiting()) adoptVars(null);
|
|
131
|
+
lastTheme = p;
|
|
132
|
+
applyMode(p);
|
|
133
|
+
emit('theme.changed', p);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The rules have the form scripts/generate-themes.mjs writes into each app's themes.css, so a
|
|
137
|
+
// built-in id gets the hub's swatch, which carries the user's change to that theme.
|
|
138
|
+
function pickerThemes() {
|
|
139
|
+
if (!swatchesPainted) {
|
|
140
|
+
swatchesPainted = true;
|
|
141
|
+
const vars = (s) =>
|
|
142
|
+
['bg', 'accent', 'ink', 'border']
|
|
143
|
+
.filter((k) => typeof s?.[k] === 'string')
|
|
144
|
+
.map((k) => `--sw-${k}: ${s[k]};`)
|
|
145
|
+
.join(' ');
|
|
146
|
+
const style = doc.createElement('style');
|
|
147
|
+
style.textContent = welcome.themes
|
|
148
|
+
.map(
|
|
149
|
+
(t) =>
|
|
150
|
+
`.theme-swatch-${t.id} { ${vars(t.swatch?.dark)} }\nbody.light .theme-swatch-${t.id} { ${vars(t.swatch?.light)} }`,
|
|
151
|
+
)
|
|
152
|
+
.join('\n');
|
|
153
|
+
doc.head.appendChild(style);
|
|
154
|
+
}
|
|
155
|
+
return welcome.themes.map(({ id, label }) => ({ id, label }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function onWelcome(m) {
|
|
159
|
+
if (m.protocol !== 1 || welcome) return;
|
|
160
|
+
welcome = { actions: strings(m.actions), themes: Array.isArray(m.themes) ? m.themes : [] };
|
|
161
|
+
forward = strings(m.forward);
|
|
162
|
+
setStatus('live');
|
|
163
|
+
if (welcome.themes.length) for (const fn of themesFns) fn(pickerThemes());
|
|
164
|
+
themesFns.clear();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function onMessage(e) {
|
|
168
|
+
if (!origin || e.source !== win.parent || e.origin !== origin) return;
|
|
169
|
+
const m = e.data;
|
|
170
|
+
if (!m || typeof m.type !== 'string') return;
|
|
171
|
+
switch (m.type) {
|
|
172
|
+
case 'hub:welcome':
|
|
173
|
+
return onWelcome(m);
|
|
174
|
+
case 'hub:event':
|
|
175
|
+
if (typeof m.topic !== 'string') return;
|
|
176
|
+
if (m.topic === 'theme.changed') return themeIn(m.payload);
|
|
177
|
+
return emit(m.topic, m.payload);
|
|
178
|
+
case 'hub:active':
|
|
179
|
+
for (const fn of activeFns) fn(!!m.active);
|
|
180
|
+
return;
|
|
181
|
+
case 'hub:action': {
|
|
182
|
+
const fn = handlers.get(m.action);
|
|
183
|
+
if (fn) fn(m.params || {}, { id: m.id });
|
|
184
|
+
else console.warn(`[claude-hub] no handler for action ${m.action}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
case 'hub:result': {
|
|
188
|
+
const resolve = pending.get(m.id);
|
|
189
|
+
if (!resolve) return;
|
|
190
|
+
pending.delete(m.id);
|
|
191
|
+
const { type, ...result } = m;
|
|
192
|
+
return resolve(result);
|
|
193
|
+
}
|
|
194
|
+
case 'hub:terminalToken':
|
|
195
|
+
for (const done of [...tokenWaiters]) done(typeof m.token === 'string' ? m.token : null);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function forwards(e) {
|
|
201
|
+
return !!forward && forward.has(comboOf(e));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function onKeydown(e) {
|
|
205
|
+
if (!forwards(e)) return;
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
post({
|
|
208
|
+
type: 'hub:keydown',
|
|
209
|
+
key: e.key,
|
|
210
|
+
code: e.code,
|
|
211
|
+
ctrl: e.ctrlKey,
|
|
212
|
+
alt: e.altKey,
|
|
213
|
+
shift: e.shiftKey,
|
|
214
|
+
meta: e.metaKey,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function run(action, params, resolve) {
|
|
219
|
+
if (status === 'live') {
|
|
220
|
+
const id = String(++nextId);
|
|
221
|
+
pending.set(id, resolve);
|
|
222
|
+
return post({ type: 'hub:invoke', id, action, params });
|
|
223
|
+
}
|
|
224
|
+
const target = standaloneFn(action)?.(params);
|
|
225
|
+
if (!target) return resolve({ ok: false, reason: 'unhandled' });
|
|
226
|
+
win.open(target, '_blank', 'noopener');
|
|
227
|
+
resolve({ ok: true, handledBy: 'standalone' });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
win.addEventListener('message', onMessage);
|
|
231
|
+
doc.addEventListener('keydown', onKeydown);
|
|
232
|
+
|
|
233
|
+
const loaded =
|
|
234
|
+
doc.readyState === 'complete'
|
|
235
|
+
? Promise.resolve()
|
|
236
|
+
: new Promise((r) => win.addEventListener('load', r, { once: true }));
|
|
237
|
+
const config = win
|
|
238
|
+
.fetch('/hub-config')
|
|
239
|
+
.then((r) => r.json())
|
|
240
|
+
.catch(() => ({}))
|
|
241
|
+
.then((cfg) => {
|
|
242
|
+
try {
|
|
243
|
+
if (framed && cfg?.enabled === true) origin = new URL(cfg.url).origin;
|
|
244
|
+
} catch (_) {}
|
|
245
|
+
if (!origin) {
|
|
246
|
+
setVars(null);
|
|
247
|
+
setStatus('standalone');
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
Promise.all([config, loaded]).then(() => {
|
|
252
|
+
if (!origin) return;
|
|
253
|
+
post({ type: 'hub:hello', protocol: [1], subscribes: [...topics.keys()] });
|
|
254
|
+
setStatus('waiting');
|
|
255
|
+
// A hub older than protocol v1 never answers. The app then runs as if alone, with no hub calls.
|
|
256
|
+
win.setTimeout(() => {
|
|
257
|
+
if (welcome) return;
|
|
258
|
+
adoptVars(null);
|
|
259
|
+
setStatus('unanswered');
|
|
260
|
+
}, WELCOME_WAIT_MS);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const topicFns = (topic) => {
|
|
264
|
+
if (!topics.has(topic)) topics.set(topic, new Set());
|
|
265
|
+
return topics.get(topic);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
hub = {
|
|
269
|
+
get status() {
|
|
270
|
+
return status;
|
|
271
|
+
},
|
|
272
|
+
get themes() {
|
|
273
|
+
return welcome ? welcome.themes : [];
|
|
274
|
+
},
|
|
275
|
+
// True once /hub-config says a hub frames this page, whether or not it answers hello.
|
|
276
|
+
get inHub() {
|
|
277
|
+
return !!origin;
|
|
278
|
+
},
|
|
279
|
+
onStatus(fn) {
|
|
280
|
+
statusFns.add(fn);
|
|
281
|
+
return () => statusFns.delete(fn);
|
|
282
|
+
},
|
|
283
|
+
onActive(fn) {
|
|
284
|
+
activeFns.add(fn);
|
|
285
|
+
return () => activeFns.delete(fn);
|
|
286
|
+
},
|
|
287
|
+
// Calls fn once with the hub's themes as [{id, label}], after it paints their picker swatches
|
|
288
|
+
// (protocol section 6, rule 5). Never when standalone, with no welcome, or when the hub has no themes.
|
|
289
|
+
onThemes(fn) {
|
|
290
|
+
if (!welcome) {
|
|
291
|
+
themesFns.add(fn);
|
|
292
|
+
return () => themesFns.delete(fn);
|
|
293
|
+
}
|
|
294
|
+
if (welcome.themes.length) fn(pickerThemes());
|
|
295
|
+
return () => {};
|
|
296
|
+
},
|
|
297
|
+
subscribe(topic, fn) {
|
|
298
|
+
const fns = topicFns(topic);
|
|
299
|
+
fns.add(fn);
|
|
300
|
+
return () => fns.delete(fn);
|
|
301
|
+
},
|
|
302
|
+
bindTheme({ get, set }) {
|
|
303
|
+
topicFns('theme.changed');
|
|
304
|
+
themeBinding = { get, set, last: themeKey(get()) };
|
|
305
|
+
if (lastTheme) applyMode(lastTheme);
|
|
306
|
+
return function report() {
|
|
307
|
+
const cur = get();
|
|
308
|
+
const key = themeKey(cur);
|
|
309
|
+
if (key === themeBinding.last) return;
|
|
310
|
+
themeBinding.last = key;
|
|
311
|
+
// The inline vars are the old pick's and outrank the app's own themes.css. Until the hub
|
|
312
|
+
// echoes the new pick's vars, a script that reads computed colors now would get the old ones.
|
|
313
|
+
setVars(null);
|
|
314
|
+
post({ type: 'hub:theme', theme: cur.theme, colorTheme: cur.colorTheme });
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
handle(action, fn) {
|
|
318
|
+
handlers.set(action, fn);
|
|
319
|
+
},
|
|
320
|
+
// The topic must be in the app's manifest `publishes`. Before welcome, only the latest payload per topic waits.
|
|
321
|
+
publish(topic, payload) {
|
|
322
|
+
if (status === 'live') post({ type: 'hub:publish', topic, payload });
|
|
323
|
+
else if (waiting()) outbox.set(topic, payload);
|
|
324
|
+
},
|
|
325
|
+
invoke(action, params = {}) {
|
|
326
|
+
return new Promise((resolve) => {
|
|
327
|
+
if (waiting()) queued.push(() => run(action, params, resolve));
|
|
328
|
+
else run(action, params, resolve);
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
can(action) {
|
|
332
|
+
if (status === 'live') return welcome.actions.has(action);
|
|
333
|
+
return !!standaloneFn(action);
|
|
334
|
+
},
|
|
335
|
+
// For an element that eats keys before the document sees them, like a terminal.
|
|
336
|
+
forwards,
|
|
337
|
+
closeGuard(on) {
|
|
338
|
+
post({ type: 'hub:closeGuard', on: !!on });
|
|
339
|
+
},
|
|
340
|
+
// In the hub's installed PWA window, a framed page's own _blank open does nothing.
|
|
341
|
+
openExternal(url) {
|
|
342
|
+
if (origin) post({ type: 'hub:openExternal', url });
|
|
343
|
+
else win.open(url, '_blank', 'noopener');
|
|
344
|
+
},
|
|
345
|
+
// Resolves the hub's answer, or null standalone or after 3 s. The hub answers only its terminal provider.
|
|
346
|
+
terminalToken() {
|
|
347
|
+
return config.then(
|
|
348
|
+
() =>
|
|
349
|
+
origin &&
|
|
350
|
+
new Promise((resolve) => {
|
|
351
|
+
const done = (token) => {
|
|
352
|
+
tokenWaiters.delete(done);
|
|
353
|
+
win.clearTimeout(timer);
|
|
354
|
+
resolve(token);
|
|
355
|
+
};
|
|
356
|
+
const timer = win.setTimeout(() => done(null), 3000);
|
|
357
|
+
tokenWaiters.add(done);
|
|
358
|
+
post({ type: 'hub:terminalToken' });
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
return hub;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { connect };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
|
|
370
|
+
else root.ClaudeHub = createClaudeHub(root);
|
|
371
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
// The hub hands this file to each app it spawns (HUB_SDK_SERVER), so it runs the hub's version, not
|
|
6
|
+
// one the app shipped. An app run alone never loads it.
|
|
7
|
+
|
|
8
|
+
// The hub spawns each app with an IPC channel. A loopback connect can fail while the app is fine,
|
|
9
|
+
// so before the hub replaces an app it asks over the channel, which does not go through TCP.
|
|
10
|
+
function answerHub() {
|
|
11
|
+
if (!process.channel) return;
|
|
12
|
+
process.on('message', (m) => {
|
|
13
|
+
if (m?.type === 'hub:ping') process.send({ type: 'hub:pong', id: m.id });
|
|
14
|
+
});
|
|
15
|
+
process.on('disconnect', () => process.exit(0));
|
|
16
|
+
// Listeners ref the channel. The app's own server keeps it alive, not the hub.
|
|
17
|
+
process.channel.unref();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Mount before express.static: the hub's client must win over the app's stub in public/vendor.
|
|
21
|
+
function mount(app) {
|
|
22
|
+
answerHub();
|
|
23
|
+
const client = path.join(__dirname, 'client.js');
|
|
24
|
+
app.get('/vendor/claude-hub-sdk.js', (_req, res) => res.sendFile(client));
|
|
25
|
+
app.get('/hub-config', (_req, res) => {
|
|
26
|
+
res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { mount };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Claude Code Hub SDK stub: what an app serves when no hub runs it. Under a hub, the hub hands the
|
|
2
|
+
// app its real SDK (client.js) in place of this file. The API must match client.js; a test checks it.
|
|
3
|
+
((root) => {
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
function createClaudeHub(win) {
|
|
7
|
+
let hub = null;
|
|
8
|
+
const off = () => {};
|
|
9
|
+
|
|
10
|
+
function connect({ standalone = {} } = {}) {
|
|
11
|
+
if (hub) return hub;
|
|
12
|
+
const standaloneFn = (action) => {
|
|
13
|
+
const fn = (typeof standalone === 'function' ? standalone() : standalone)[action];
|
|
14
|
+
return typeof fn === 'function' ? fn : null;
|
|
15
|
+
};
|
|
16
|
+
hub = {
|
|
17
|
+
status: 'standalone',
|
|
18
|
+
themes: [],
|
|
19
|
+
inHub: false,
|
|
20
|
+
onStatus: () => off,
|
|
21
|
+
onActive: () => off,
|
|
22
|
+
onThemes: () => off,
|
|
23
|
+
subscribe: () => off,
|
|
24
|
+
bindTheme: () => off,
|
|
25
|
+
handle() {},
|
|
26
|
+
publish() {},
|
|
27
|
+
invoke(action, params = {}) {
|
|
28
|
+
const target = standaloneFn(action)?.(params);
|
|
29
|
+
if (!target) return Promise.resolve({ ok: false, reason: 'unhandled' });
|
|
30
|
+
win.open(target, '_blank', 'noopener');
|
|
31
|
+
return Promise.resolve({ ok: true, handledBy: 'standalone' });
|
|
32
|
+
},
|
|
33
|
+
can: (action) => !!standaloneFn(action),
|
|
34
|
+
forwards: () => false,
|
|
35
|
+
closeGuard() {},
|
|
36
|
+
openExternal(url) {
|
|
37
|
+
win.open(url, '_blank', 'noopener');
|
|
38
|
+
},
|
|
39
|
+
terminalToken: () => Promise.resolve(null),
|
|
40
|
+
};
|
|
41
|
+
return hub;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { connect };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub };
|
|
48
|
+
else root.ClaudeHub = createClaudeHub(root);
|
|
49
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-code-hub",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "NikiforovAll"
|
|
5
|
+
},
|
|
6
|
+
"description": "The plugin that ships with Claude Code Hub",
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "claude-code-hub",
|
|
10
|
+
"source": "./plugins/claude-code-hub",
|
|
11
|
+
"description": "Claude Code Hub integration: a skill to build on the hub, with links to its docs and custom themes"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hub-builder
|
|
3
|
+
description: Build on and extend Claude Code Hub. Use when the user wants to patch or fork a built-in tool (Kanban, Cost, Marketplace, Memory), write a new app for a hub tab, connect apps through topics and actions, make a custom color theme, configure the hub (flags, config dirs, token), or fix a hub problem.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Hub builder
|
|
7
|
+
|
|
8
|
+
Claude Code Hub runs Kanban, Cost, Marketplace and Memory as tabs of one window, and apps talk to it through the hub protocol. The docs site is the reference. Find the case below, fetch its page, and work from what the page says.
|
|
9
|
+
|
|
10
|
+
The site documents the latest stable release. When the user's hub behaves differently from a page, trust the installed hub and say which version it is (`npm ls -g claude-code-hub`).
|
|
11
|
+
|
|
12
|
+
## Docs
|
|
13
|
+
|
|
14
|
+
Base URL: `https://nikiforovall.blog/claude-code-hub/`
|
|
15
|
+
|
|
16
|
+
The files under `references/` ship with the installed hub, so they match its version. Read them before the site.
|
|
17
|
+
|
|
18
|
+
| Case | Page |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Extend the hub: patch or fork a built-in tool, write a new app, configure the `apps` entry, the manifest, the SDK, topics, actions, capabilities | [`references/extensibility.md`](references/extensibility.md). It names the docs page for each part |
|
|
21
|
+
| The wire messages of the hub protocol | `reference/protocol/`, then `reference/architecture/` for processes and ports |
|
|
22
|
+
| Start the hub, open it with its token, install it as an app | `getting-started/` |
|
|
23
|
+
| Flags, environment variables, `config.json`, the files the hub writes | `reference/configuration/` |
|
|
24
|
+
| Switch between Claude config dirs | `guides/config-dirs/` |
|
|
25
|
+
| The hub token, or exposing the hub to a network | `reference/security/` |
|
|
26
|
+
| Keys the hub binds and keys the tools keep | `reference/shortcuts/` |
|
|
27
|
+
| The embedded terminal in Kanban | `guides/terminal/` |
|
|
28
|
+
| The project palette | `guides/projects/` |
|
|
29
|
+
| Something is broken | `reference/troubleshooting/` |
|
|
30
|
+
| Make a custom color theme, or change a built-in one | [`references/themes.md`](references/themes.md) |
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Extend the hub
|
|
2
|
+
|
|
3
|
+
The hub runs each tool from a folder. An entry in the `apps` list of `config.json` points the hub at your folder, so you change or add a tool and leave the hub as it is. This file holds the mechanism. Each section names the docs page with the full rules, under `https://nikiforovall.blog/claude-code-hub/`.
|
|
4
|
+
|
|
5
|
+
## Pick the way
|
|
6
|
+
|
|
7
|
+
| Way | Use it when | You keep | Page |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| Patch | A few lines of a published tool change | A wrapper project: the npm package pinned to the hub's exact version, `patch-package`, a `hub-app.json` | `extensibility/patch/`, example `extensibility/examples/compact-rows/` |
|
|
10
|
+
| Fork | A new page, a new route, or changes in many parts of a tool | A clone of the tool's repo at the tag the hub runs, with a new or the same id | `extensibility/fork/`, example `extensibility/examples/task-board/` |
|
|
11
|
+
| New app | The tool does a different job | Your own server, page and manifest | `extensibility/new-app/`, example `extensibility/examples/inspector/` |
|
|
12
|
+
|
|
13
|
+
Start with a patch. Move to a fork when the patch grows or breaks on each upgrade. A patch can stop applying when the pinned version changes, and `npm install` still succeeds unless the `postinstall` script has `--error-on-fail`.
|
|
14
|
+
|
|
15
|
+
## The apps entry
|
|
16
|
+
|
|
17
|
+
`<hub-dir>/config.json` (`--hub-dir`, else `CLAUDE_HUB_DIR`, else `~/.claude-hub`) has `apps: [{id, path?, enabled?, port?}]`. Page: `extensibility/reference/apps-entry/`. The other keys of the file (`configDirs`, `activeConfigDir`, `terminal`) are in `reference/configuration/#the-config-file`; keep them as they are.
|
|
18
|
+
|
|
19
|
+
To configure an extension:
|
|
20
|
+
|
|
21
|
+
1. Open `config.json` and add the `apps` key if it is not there.
|
|
22
|
+
2. Add one entry per change:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"apps": [
|
|
27
|
+
{ "id": "kanban-patched", "path": "C:/dev/kanban-patched" },
|
|
28
|
+
{ "id": "kanban", "enabled": false },
|
|
29
|
+
{ "id": "inspector", "path": "C:/dev/inspector", "port": 3560 }
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The first two entries replace Kanban: the patched copy runs under its own id, and the original is off, so the copy gets Kanban's capabilities. A built-in id with a `path` does the same in one entry (see the rules below). The third adds a new app as a tab, on a fixed port. To run a fork next to the original, keep `{ "id": "kanban" }` enabled.
|
|
35
|
+
3. Make the `id` in the folder's `hub-app.json` the same as the entry's `id`, or the hub skips the app.
|
|
36
|
+
4. Run `npm install` in the app folder.
|
|
37
|
+
5. Restart the hub, because it reads the list at startup only. The startup log names each skipped app and why, and each conflict.
|
|
38
|
+
|
|
39
|
+
Rules:
|
|
40
|
+
|
|
41
|
+
- `path` is the folder with `hub-app.json`: absolute, or relative to the folder of `config.json`. The hub does not expand `~`.
|
|
42
|
+
- A built-in id (`kanban`, `marketplace`, `cost`, `memory`) with a `path` runs that folder in place of the tool, with no second entry. A new id needs a `path` and runs as an extra tab.
|
|
43
|
+
- Listed apps come first in list order, then the unlisted built-ins in default order. Tab order is also the `Alt+1`…`Alt+9` order.
|
|
44
|
+
- `enabled: false` removes the app: no process, tab or port, and its actions and capabilities go to no one.
|
|
45
|
+
|
|
46
|
+
## The launch
|
|
47
|
+
|
|
48
|
+
For each config dir, the hub runs `run.entry` with its own Node and an IPC channel, and restarts it up to 5 times in 60 s. The working dir is the hub's folder, so the app resolves its files from `__dirname`. Page: `extensibility/reference/apps-entry/#launch`.
|
|
49
|
+
|
|
50
|
+
The app server must:
|
|
51
|
+
|
|
52
|
+
1. Call `require(process.env.HUB_SDK_SERVER).mount(app)` before it serves static files, when the variable is set.
|
|
53
|
+
2. Listen on `PORT` (`0`, any free port) and `HOST`, and print one stdout line that matches `/running at http:\/\/localhost:(\d+)/i` with the real port, within 20 s.
|
|
54
|
+
3. Allow framing by the hub origin: no `X-Frame-Options`, no blocking `frame-ancestors`.
|
|
55
|
+
4. Work with no hub.
|
|
56
|
+
|
|
57
|
+
The browser sees the app on a proxy port (`--<id>-port`, else the entry's `port`, else `run.defaultPort`). That port is the app's origin and keys its `localStorage`, so a busy port falls back to a random one and the app starts with empty storage for that run. Give each app its own `defaultPort`.
|
|
58
|
+
|
|
59
|
+
After a server change, restart the app: `Ctrl+Alt+A`, select it, `Ctrl+R`. After a change in `public/`, reload the page. A change to `hub-app.json` or `config.json` needs a hub restart.
|
|
60
|
+
|
|
61
|
+
## The manifest
|
|
62
|
+
|
|
63
|
+
`hub-app.json` declares how to run the app and what it offers: `manifest: 1`, `id`, `name`, `icon` (a Lucide name), `run.entry` (inside the folder; a path into `node_modules` is fine), `run.defaultPort`, `loading.verbs`, `publishes`, `actions.handles`, `provides`. The hub reads it before the app runs and skips the app, with a log line, when it breaks a rule. Page: `extensibility/reference/manifest/`.
|
|
64
|
+
|
|
65
|
+
The npm packages of the built-in tools do not ship `hub-app.json`. A patch or fork starts from the tool's manifest in its GitHub repo and changes `id`, `name` and `run.entry`. Page for what each built-in declares: `extensibility/reference/built-in/`.
|
|
66
|
+
|
|
67
|
+
## The SDK
|
|
68
|
+
|
|
69
|
+
The page loads `/vendor/claude-hub-sdk.js` as the first element in `<body>`, a classic script, and calls `ClaudeHub.connect()`. Under the hub, `mount()` serves the hub's client at that URL, so the app always runs the hub's SDK. Alone, the app serves its own copy of the stub (`packages/claude-hub-sdk/src/stub.js`, copied by `npm run sdk:sync -- <app folder>` in a hub clone), which has the same API and does nothing. Page: `extensibility/reference/sdk/`.
|
|
70
|
+
|
|
71
|
+
The SDK writes the hub's color variables (`--accent`, `--bg-surface`, …) on `document.body`, so the app's CSS reads them and follows every hub theme, the user's own themes too. `bindTheme` keeps light/dark and the color theme in step. `onActive` tells a hidden tab to pause polling. The SDK forwards the hub's keys by itself; an element that eats keys first, such as a terminal, asks `forwards(e)`.
|
|
72
|
+
|
|
73
|
+
## Connect to other tools
|
|
74
|
+
|
|
75
|
+
Apps never talk to each other directly. Each page talks to the hub page through the SDK, and the hub passes the message on. There is no server-to-server channel. Page: `extensibility/reference/connect/`.
|
|
76
|
+
|
|
77
|
+
- **Topics: one to many, sticky.** The hub keeps the last value and replays it when an app connects or comes on screen. The hub publishes `project.changed` (`{project, encoded, name}` or `null`) and `theme.changed`. An app publishes only the topics its manifest lists in `publishes`. Kanban publishes `session.changed`. Subscribe before the page's `load` event, because the SDK sends the topic list with its hello.
|
|
78
|
+
- **Actions: one caller, one handler.** The caller names the action, not the app: `hub.can(name)` decides whether to show the control, `hub.invoke(name, params)` routes it and resolves when routed. The handler declares it in `actions.handles` with a `url` template; with `mode: "message"` a live page gets it through `hub.handle`, else the hub loads the URL, so the handler also reads the params from its query string. The built-in actions are `session.cost` (Cost), `project.plugins` (Marketplace) and `project.memory` (Memory Diagnoser).
|
|
79
|
+
- **Capabilities: one provider per job.** `provides.projects.path` answers the project palette's list; `provides.terminal.liveWork` answers the open terminals and makes the app the terminal provider, which gets the terminal env and token. Kanban provides both today.
|
|
80
|
+
|
|
81
|
+
## First in tab order wins
|
|
82
|
+
|
|
83
|
+
When two enabled apps declare the same capability, action or port, the first in tab order gets it, and the hub logs the conflict at startup. Use this to take over a built-in action or capability: put your app first, or turn the built-in off.
|
|
84
|
+
|
|
85
|
+
A replacement must keep what the original gave, or the other tools lose it: Kanban's `projects`, `terminal` and `session.changed`, and each other tool's action. A tool that runs next to the original can leave these to it. Page: `extensibility/fork/#what-the-built-in-tool-gives`.
|
|
86
|
+
|
|
87
|
+
## Check
|
|
88
|
+
|
|
89
|
+
An extension is done when:
|
|
90
|
+
|
|
91
|
+
- The hub startup log shows `<id> runs from <folder>` and no skip or conflict line for it.
|
|
92
|
+
- Its tab shows at the expected `Alt+N`, and what it publishes, handles or provides reaches the other tools.
|
|
93
|
+
- It still runs alone, with the stub.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Custom themes
|
|
2
|
+
|
|
3
|
+
The hub is the theme source for every app in its tabs. It sends the colors of the current theme to each app, so a custom theme reaches all apps with no app change. Colors the apps own (`--success`, `--warning`, `--error`, chart colors, fonts) stay out of a theme.
|
|
4
|
+
|
|
5
|
+
## Where the file lives
|
|
6
|
+
|
|
7
|
+
`<hub-dir>/themes.json`, where the hub dir is `--hub-dir`, else `CLAUDE_HUB_DIR`, else `~/.claude-hub`. The file is a JSON array of themes. Create it when it is missing. The hub reads it on each page load, so an edit applies when the user reloads the hub page.
|
|
8
|
+
|
|
9
|
+
## Three kinds of entry
|
|
10
|
+
|
|
11
|
+
Each entry has an `id` (`^[a-z0-9-]{1,32}$`), a `label` for the picker, and a `dark` and a `light` object of colors.
|
|
12
|
+
|
|
13
|
+
1. **Tweak a built-in theme.** Use the built-in id. The entry starts from that theme, so it holds only the colors it changes, in either mode or both.
|
|
14
|
+
```json
|
|
15
|
+
{ "id": "ember", "dark": { "ember": "#3b82f6", "emberGlow": "#93c5fd" } }
|
|
16
|
+
```
|
|
17
|
+
2. **Base a new theme on a built-in one.** A new id plus `"extends": "<built-in id>"`. It starts from that theme, and its colors win. A mode it leaves out comes from the base.
|
|
18
|
+
```json
|
|
19
|
+
{ "id": "deep-sea", "label": "Deep Sea", "extends": "nord", "dark": { "field": "#0b1220", "surface": "#111a2e" } }
|
|
20
|
+
```
|
|
21
|
+
3. **New theme from scratch.** A new id with no `extends`. Both `dark` and `light` hold every required color below.
|
|
22
|
+
|
|
23
|
+
Built-in ids: `ember` (the default), `gruvbox`, `catppuccin`, `tokyo-night`, `solarized`, `dracula`, `nord`, `rose-pine`, `everforest`, `kanagawa`, `one-dark`, `night-owl`, `monokai`, `github`, `ayu`, `vitesse`, `synthwave`.
|
|
24
|
+
|
|
25
|
+
## Colors
|
|
26
|
+
|
|
27
|
+
The keys are palette roles. The hub maps each role to the CSS variable the apps read.
|
|
28
|
+
|
|
29
|
+
| Role | CSS variable | Paints |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `ember` | `--accent` | The accent: active items, primary buttons, focus |
|
|
32
|
+
| `emberGlow` | `--accent-text` | Accent used as text, so it must read on `surface` |
|
|
33
|
+
| `emberDim` | `--accent-dim` | A faint accent fill, usually the accent at 0.18–0.25 alpha |
|
|
34
|
+
| `field` | `--bg-deep` | The page background |
|
|
35
|
+
| `surface` | `--bg-surface` | Panels and cards |
|
|
36
|
+
| `elevated` | `--bg-elevated` | Raised surfaces |
|
|
37
|
+
| `hover` | `--bg-hover` | The hover state |
|
|
38
|
+
| `border` | `--border` | Borders and dividers |
|
|
39
|
+
| `ink1` | `--text-primary` | Main text |
|
|
40
|
+
| `ink2` | `--text-secondary` | Secondary text |
|
|
41
|
+
| `ink3` | `--text-tertiary` | Tertiary text |
|
|
42
|
+
| `inkMuted` | `--text-muted` | Muted text |
|
|
43
|
+
| `sidebar` | `--sidebar-bg` | Optional. The sidebar background |
|
|
44
|
+
| `sidebarItem` | `--sidebar-item-bg` | Optional. A sidebar item background |
|
|
45
|
+
|
|
46
|
+
The hub derives `--accent-glow` from `ember`, and the picker swatch from `surface`, `ember`, `ink1` and `border`.
|
|
47
|
+
|
|
48
|
+
A value is a hex color (`#rgb`, `#rrggbb`, `#rrggbbaa`), or `rgb()`, `rgba()`, `hsl()`, `hsla()` or `oklch()`.
|
|
49
|
+
|
|
50
|
+
For a from-scratch theme, copy a built-in palette with the same feel and change it, so both modes keep their contrast. `lib/themes.json` in the hub package holds every built-in palette: `$(npm root -g)/claude-code-hub/lib/themes.json` for a global install.
|
|
51
|
+
|
|
52
|
+
## Check
|
|
53
|
+
|
|
54
|
+
The hub skips an entry that breaks a rule and prints why in its console, naming the theme and the role. A theme is done when:
|
|
55
|
+
|
|
56
|
+
- The hub console shows no skip line for it after a page reload.
|
|
57
|
+
- It shows in the theme picker of each tab, and picking it repaints every tab in both modes.
|