claude-code-hub 1.22.0 → 1.23.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 +39 -3
- package/lib/settings.js +40 -4
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +65 -16
- package/packages/claude-hub-sdk/src/keys.js +15 -1
- package/packages/claude-hub-sdk/src/stub.js +4 -1
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +2 -0
- package/public/app.js +49 -11
- package/server.js +25 -2
package/lib/apps.js
CHANGED
|
@@ -85,10 +85,45 @@ function manifestError(m, id, root) {
|
|
|
85
85
|
keysError(m.keys) ??
|
|
86
86
|
pluginError(m.plugin, root) ??
|
|
87
87
|
requiresError(m.requires) ??
|
|
88
|
-
listingError(m.listing, root)
|
|
88
|
+
listingError(m.listing, root) ??
|
|
89
|
+
settingsError(m.settings)
|
|
89
90
|
);
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/;
|
|
94
|
+
const MAX_SETTINGS = 20;
|
|
95
|
+
const plainText = (v, max) => typeof v === 'string' && v.length > 0 && v.length <= max && !/[<>]/.test(v);
|
|
96
|
+
const validSettingValue = (s, v) => (s.type === 'boolean' ? typeof v === 'boolean' : s.options.some((o) => o.value === v));
|
|
97
|
+
|
|
98
|
+
function settingError(s) {
|
|
99
|
+
if (!isObject(s) || typeof s.key !== 'string' || !SETTING_KEY_RE.test(s.key)) return `settings entry has no key matching ${SETTING_KEY_RE}`;
|
|
100
|
+
const at = `settings "${s.key}"`;
|
|
101
|
+
if (!plainText(s.label, 60)) return `${at}: label is not plain text of 60 characters or fewer`;
|
|
102
|
+
if (s.help !== undefined && !plainText(s.help, 200)) return `${at}: help is not plain text of 200 characters or fewer`;
|
|
103
|
+
if (s.type === 'boolean') return validSettingValue(s, s.default) ? null : `${at}: default is not a boolean`;
|
|
104
|
+
if (s.type !== 'choice') return `${at}: type is not boolean or choice`;
|
|
105
|
+
const { options } = s;
|
|
106
|
+
if (!Array.isArray(options) || options.length < 2 || options.length > 6) return `${at}: options is not a list of 2 to 6`;
|
|
107
|
+
for (const o of options) {
|
|
108
|
+
if (!isObject(o) || typeof o.value !== 'string' || !o.value || !plainText(o.label, 30))
|
|
109
|
+
return `${at}: an option has no string value or plain-text label`;
|
|
110
|
+
}
|
|
111
|
+
if (new Set(options.map((o) => o.value)).size !== options.length) return `${at}: two options share a value`;
|
|
112
|
+
if (!validSettingValue(s, s.default)) return `${at}: default is not one of the option values`;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function settingsError(settings) {
|
|
117
|
+
if (settings === undefined) return null;
|
|
118
|
+
if (!Array.isArray(settings) || settings.length > MAX_SETTINGS) return `settings is not a list of up to ${MAX_SETTINGS}`;
|
|
119
|
+
for (const s of settings) {
|
|
120
|
+
const err = settingError(s);
|
|
121
|
+
if (err) return err;
|
|
122
|
+
}
|
|
123
|
+
if (new Set(settings.map((s) => s.key)).size !== settings.length) return 'two settings share a key';
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
92
127
|
const PLUGIN_NAME_RE = /^[\w.-]+$/;
|
|
93
128
|
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
94
129
|
|
|
@@ -121,7 +156,7 @@ const IMAGE_RE = /\.(png|jpe?g|webp)$/i;
|
|
|
121
156
|
const SPDX_RE = /^[A-Za-z0-9.+-]+$/;
|
|
122
157
|
const isHttps = (u) => typeof u === 'string' && /^https:\/\/[^\s/]+/.test(u);
|
|
123
158
|
const textError = (key, v, max) =>
|
|
124
|
-
v === undefined || (
|
|
159
|
+
v === undefined || plainText(v, max)
|
|
125
160
|
? null
|
|
126
161
|
: `listing.${key} is not plain text of ${max} characters or fewer`;
|
|
127
162
|
|
|
@@ -283,7 +318,8 @@ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve,
|
|
|
283
318
|
provides: manifest.provides ?? {},
|
|
284
319
|
publishes: manifest.publishes ?? [],
|
|
285
320
|
keeps: manifest.keys?.keeps ?? [],
|
|
321
|
+
settings: manifest.settings ?? [],
|
|
286
322
|
};
|
|
287
323
|
}
|
|
288
324
|
|
|
289
|
-
module.exports = { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, loadApp, manifestError, readApp, resolvePlugin, conflicts };
|
|
325
|
+
module.exports = { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, loadApp, manifestError, readApp, resolvePlugin, conflicts, isObject, validSettingValue };
|
package/lib/settings.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { selectApps } = require('./apps');
|
|
3
|
+
const { isObject, selectApps, validSettingValue } = require('./apps');
|
|
4
4
|
|
|
5
5
|
const CONFIG_DIR_SWITCH = ['palette', 'swap'];
|
|
6
6
|
const DEFAULT_POOL_SIZE = 3;
|
|
@@ -38,9 +38,41 @@ function appsShape(apps) {
|
|
|
38
38
|
return JSON.stringify(apps.filter((a) => a.state !== 'off').map((a) => [a.id, a.state, a.deferredStart]));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const objectOr = (v) => (isObject(v) ? v : {});
|
|
42
|
+
|
|
43
|
+
// An app's settings from its manifest list and the `settings` of its `apps` entry. A saved value
|
|
44
|
+
// that the manifest no longer allows gives the default.
|
|
45
|
+
function settingValues(schema, saved) {
|
|
46
|
+
const own = objectOr(saved);
|
|
47
|
+
return Object.fromEntries(schema.map((s) => [s.key, Object.hasOwn(own, s.key) && validSettingValue(s, own[s.key]) ? own[s.key] : s.default]));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function settingsPatchError(schema = [], settings) {
|
|
51
|
+
if (!isObject(settings)) return 'settings must be an object';
|
|
52
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
53
|
+
const s = schema.find((x) => x.key === key);
|
|
54
|
+
if (!s) return `This app has no setting "${key}"`;
|
|
55
|
+
if (!validSettingValue(s, value))
|
|
56
|
+
return s.type === 'boolean' ? `${key} must be a boolean` : `${key} must be one of ${s.options.map((o) => o.value).join(', ')}`;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Only a value that differs from the default is written.
|
|
62
|
+
function withSettings(entry, schema, patch) {
|
|
63
|
+
const values = { ...objectOr(entry.settings) };
|
|
64
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
65
|
+
if (value === schema.find((s) => s.key === key).default) delete values[key];
|
|
66
|
+
else values[key] = value;
|
|
67
|
+
}
|
|
68
|
+
if (Object.keys(values).length) entry.settings = values;
|
|
69
|
+
else delete entry.settings;
|
|
70
|
+
}
|
|
71
|
+
|
|
41
72
|
// `saved` is config.json as on disk. Returns the new config, or {error}. Only the keys the
|
|
42
|
-
// patch names change; an app entry keeps its other fields (port, path, source).
|
|
43
|
-
|
|
73
|
+
// patch names change; an app entry keeps its other fields (port, path, source). `schemas` maps an
|
|
74
|
+
// app id to the settings list of its manifest.
|
|
75
|
+
function applySettings(saved, patch, base, schemas = {}) {
|
|
44
76
|
if (!patch || typeof patch !== 'object') return { error: 'body must be an object' };
|
|
45
77
|
const next = { ...saved };
|
|
46
78
|
if (patch.configDirSwitch !== undefined) {
|
|
@@ -53,7 +85,7 @@ function applySettings(saved, patch, base) {
|
|
|
53
85
|
next.poolSize = patch.poolSize;
|
|
54
86
|
}
|
|
55
87
|
if (patch.app !== undefined) {
|
|
56
|
-
const { id, state, deferredStart } = patch.app ?? {};
|
|
88
|
+
const { id, state, deferredStart, settings } = patch.app ?? {};
|
|
57
89
|
const current = appSettings(saved.apps, base);
|
|
58
90
|
if (!current.some((a) => a.id === id)) return { error: `Unknown app "${id}"` };
|
|
59
91
|
if (state !== undefined && !APP_STATES.includes(state))
|
|
@@ -62,6 +94,8 @@ function applySettings(saved, patch, base) {
|
|
|
62
94
|
return { error: 'deferredStart must be a boolean' };
|
|
63
95
|
if (state === 'off' && current.every((a) => a.id === id || a.state === 'off'))
|
|
64
96
|
return { error: 'At least one app must stay on' };
|
|
97
|
+
const settingsErr = settings === undefined ? null : settingsPatchError(schemas[id], settings);
|
|
98
|
+
if (settingsErr) return { error: settingsErr };
|
|
65
99
|
let entries = Array.isArray(saved.apps) ? [...saved.apps] : [];
|
|
66
100
|
// Listed apps come first, so a new entry appended at the end would move its tab. Listing
|
|
67
101
|
// every app in its current order keeps the order.
|
|
@@ -76,6 +110,7 @@ function applySettings(saved, patch, base) {
|
|
|
76
110
|
entry.hidden = state === 'hidden';
|
|
77
111
|
}
|
|
78
112
|
if (deferredStart !== undefined) entry.deferredStart = deferredStart;
|
|
113
|
+
if (settings !== undefined) withSettings(entry, schemas[id], settings);
|
|
79
114
|
entries[i] = entry;
|
|
80
115
|
next.apps = entries;
|
|
81
116
|
}
|
|
@@ -90,4 +125,5 @@ module.exports = {
|
|
|
90
125
|
applySettings,
|
|
91
126
|
configDirSwitch,
|
|
92
127
|
poolSize,
|
|
128
|
+
settingValues,
|
|
93
129
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"claude-code-cost": "1.5.
|
|
50
|
-
"claude-code-kanban": "7.
|
|
51
|
-
"claude-code-marketplace": "1.
|
|
52
|
-
"claude-code-memory-explorer": "1.3.
|
|
49
|
+
"claude-code-cost": "1.5.1",
|
|
50
|
+
"claude-code-kanban": "7.19.0",
|
|
51
|
+
"claude-code-marketplace": "1.7.0",
|
|
52
|
+
"claude-code-memory-explorer": "1.3.3",
|
|
53
53
|
"express": "^4.21.0",
|
|
54
54
|
"open": "^10.1.0"
|
|
55
55
|
},
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
// hub's proxy port, so a page framed by something other than the hub would otherwise paint the hub's last theme.
|
|
9
9
|
const VARS_KEY = 'claude-hub:vars';
|
|
10
10
|
const WELCOME_WAIT_MS = 2000;
|
|
11
|
+
// A press in one of these types text, so the host never gets it.
|
|
12
|
+
const FIELDS =
|
|
13
|
+
'input:not([type=checkbox], [type=radio], [type=button], [type=submit], [type=reset], [type=range], [type=color], [type=file], [type=image]), textarea, select, [contenteditable]';
|
|
11
14
|
|
|
12
15
|
// keys.js comes first in the served file. Required as a module, client.js loads it itself.
|
|
13
|
-
const { comboOf, keyParts } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
|
|
16
|
+
const { comboOf, keyParts, keySigs } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
|
|
14
17
|
|
|
15
18
|
function isVars(v) {
|
|
16
19
|
return (
|
|
@@ -90,6 +93,7 @@
|
|
|
90
93
|
const pending = new Map();
|
|
91
94
|
const tokenWaiters = new Set();
|
|
92
95
|
const themesFns = new Set();
|
|
96
|
+
const settingsFns = new Set();
|
|
93
97
|
const queued = [];
|
|
94
98
|
// Topic → payload: the latest publish before welcome.
|
|
95
99
|
const outbox = new Map();
|
|
@@ -101,6 +105,7 @@
|
|
|
101
105
|
let linkOpened = null;
|
|
102
106
|
let themeBinding = null;
|
|
103
107
|
let lastTheme = null;
|
|
108
|
+
let settings = null;
|
|
104
109
|
let swatchesPainted = false;
|
|
105
110
|
let nextId = 0;
|
|
106
111
|
|
|
@@ -172,6 +177,11 @@
|
|
|
172
177
|
return welcome.themes.map(({ id, label }) => ({ id, label }));
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
function setSettings(s) {
|
|
181
|
+
settings = Object.freeze(s && typeof s === 'object' && !Array.isArray(s) ? { ...s } : {});
|
|
182
|
+
for (const fn of settingsFns) fn(settings);
|
|
183
|
+
}
|
|
184
|
+
|
|
175
185
|
function onWelcome(m) {
|
|
176
186
|
if (m.protocol !== 1 || welcome) return;
|
|
177
187
|
welcome = {
|
|
@@ -181,6 +191,7 @@
|
|
|
181
191
|
};
|
|
182
192
|
forward = strings(m.forward);
|
|
183
193
|
appOrigins = strings(m.origins);
|
|
194
|
+
setSettings(m.settings);
|
|
184
195
|
setStatus('live');
|
|
185
196
|
if (welcome.themes.length) for (const fn of themesFns) fn(pickerThemes());
|
|
186
197
|
themesFns.clear();
|
|
@@ -216,6 +227,9 @@
|
|
|
216
227
|
case 'hub:terminalToken':
|
|
217
228
|
for (const done of [...tokenWaiters]) done(typeof m.token === 'string' ? m.token : null);
|
|
218
229
|
return;
|
|
230
|
+
case 'hub:settings':
|
|
231
|
+
if (welcome) setSettings(m.settings);
|
|
232
|
+
return;
|
|
219
233
|
}
|
|
220
234
|
}
|
|
221
235
|
|
|
@@ -223,28 +237,50 @@
|
|
|
223
237
|
return !!forward && forward.has(comboOf(e));
|
|
224
238
|
}
|
|
225
239
|
|
|
240
|
+
// Protocol §5.2. The app page that frames this one, such as a cck pane, claims the presses it acts on.
|
|
241
|
+
let hostKeys = null;
|
|
242
|
+
let hostOrigin = null;
|
|
243
|
+
|
|
244
|
+
function onHostMessage(e) {
|
|
245
|
+
const m = e.data;
|
|
246
|
+
if (!framed || e.source !== win.parent || win.parent === win.top) return;
|
|
247
|
+
if (m?.type !== 'hub:hostKeys' || !Array.isArray(m.keys)) return;
|
|
248
|
+
if (!hostKeys) win.parent.postMessage({ type: 'hub:hostReady' }, e.origin);
|
|
249
|
+
hostOrigin = e.origin;
|
|
250
|
+
hostKeys = new Set(m.keys.map((k) => keySigs(k)[0]));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const keyMessage = (type, e) => ({
|
|
254
|
+
type,
|
|
255
|
+
key: e.key,
|
|
256
|
+
code: e.code,
|
|
257
|
+
ctrl: e.ctrlKey,
|
|
258
|
+
alt: e.altKey,
|
|
259
|
+
shift: e.shiftKey,
|
|
260
|
+
meta: e.metaKey,
|
|
261
|
+
});
|
|
262
|
+
|
|
226
263
|
function onKeydown(e) {
|
|
227
|
-
if (
|
|
264
|
+
if (forwards(e)) {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
return post(keyMessage('hub:keydown', e));
|
|
267
|
+
}
|
|
268
|
+
if (!hostKeys || e.defaultPrevented || !keySigs(e).some((s) => hostKeys.has(s))) return;
|
|
269
|
+
if (e.target?.closest?.(FIELDS)) return;
|
|
228
270
|
e.preventDefault();
|
|
229
|
-
|
|
230
|
-
type: 'hub:keydown',
|
|
231
|
-
key: e.key,
|
|
232
|
-
code: e.code,
|
|
233
|
-
ctrl: e.ctrlKey,
|
|
234
|
-
alt: e.altKey,
|
|
235
|
-
shift: e.shiftKey,
|
|
236
|
-
meta: e.metaKey,
|
|
237
|
-
});
|
|
271
|
+
win.parent.postMessage(keyMessage('hub:hostKey', e), hostOrigin);
|
|
238
272
|
}
|
|
239
273
|
|
|
240
|
-
function run(action, params, resolve) {
|
|
274
|
+
function run(action, params, opts, resolve) {
|
|
275
|
+
const pane = opts?.open === 'pane';
|
|
241
276
|
if (status === 'live') {
|
|
242
277
|
const id = String(++nextId);
|
|
243
278
|
pending.set(id, resolve);
|
|
244
|
-
return post({ type: 'hub:invoke', id, action, params });
|
|
279
|
+
return post({ type: 'hub:invoke', id, action, params, ...(pane && { open: 'pane' }) });
|
|
245
280
|
}
|
|
246
281
|
const target = standaloneFn(action)?.(params);
|
|
247
282
|
if (!target) return resolve({ ok: false, reason: 'unhandled' });
|
|
283
|
+
if (pane) return resolve({ ok: true, handledBy: 'standalone', url: target });
|
|
248
284
|
win.open(target, '_blank', 'noopener');
|
|
249
285
|
resolve({ ok: true, handledBy: 'standalone' });
|
|
250
286
|
}
|
|
@@ -286,6 +322,7 @@
|
|
|
286
322
|
}
|
|
287
323
|
|
|
288
324
|
win.addEventListener('message', onMessage);
|
|
325
|
+
win.addEventListener('message', onHostMessage);
|
|
289
326
|
doc.addEventListener('keydown', onKeydown);
|
|
290
327
|
win.addEventListener('click', onLinkClick, true);
|
|
291
328
|
win.addEventListener('auxclick', onLinkClick, true);
|
|
@@ -357,6 +394,18 @@
|
|
|
357
394
|
if (welcome.themes.length) fn(pickerThemes());
|
|
358
395
|
return () => {};
|
|
359
396
|
},
|
|
397
|
+
// The values of the settings in the app's manifest `settings`, as {key: value}. Null before
|
|
398
|
+
// welcome, standalone, or with no welcome: the app then uses its own defaults.
|
|
399
|
+
get settings() {
|
|
400
|
+
return settings;
|
|
401
|
+
},
|
|
402
|
+
// Calls fn with the values at welcome and after each change in the hub's settings. A call after
|
|
403
|
+
// welcome gets the current values at once.
|
|
404
|
+
onSettings(fn) {
|
|
405
|
+
settingsFns.add(fn);
|
|
406
|
+
if (settings) fn(settings);
|
|
407
|
+
return () => settingsFns.delete(fn);
|
|
408
|
+
},
|
|
360
409
|
subscribe(topic, fn) {
|
|
361
410
|
const fns = topicFns(topic);
|
|
362
411
|
fns.add(fn);
|
|
@@ -385,10 +434,10 @@
|
|
|
385
434
|
if (status === 'live') post({ type: 'hub:publish', topic, payload });
|
|
386
435
|
else if (waiting()) outbox.set(topic, payload);
|
|
387
436
|
},
|
|
388
|
-
invoke(action, params = {}) {
|
|
437
|
+
invoke(action, params = {}, opts = {}) {
|
|
389
438
|
return new Promise((resolve) => {
|
|
390
|
-
if (waiting()) queued.push(() => run(action, params, resolve));
|
|
391
|
-
else run(action, params, resolve);
|
|
439
|
+
if (waiting()) queued.push(() => run(action, params, opts, resolve));
|
|
440
|
+
else run(action, params, opts, resolve);
|
|
392
441
|
});
|
|
393
442
|
},
|
|
394
443
|
can(action) {
|
|
@@ -32,7 +32,21 @@ var ClaudeHubKeys = (() => {
|
|
|
32
32
|
return combo.split('+').map((k) => mods[k] ?? named[k] ?? (k.length === 1 ? k.toUpperCase() : k));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// A host claim (protocol §5.2) names a press by its physical code, or by its character when the layout
|
|
36
|
+
// moves the key: {ctrlKey?, altKey?, shiftKey?, metaKey?, code, key: 'Unidentified'} or {…, key, code: ''}.
|
|
37
|
+
// A press matches a claim when one of its two signatures is the claim's first. AltGr types a
|
|
38
|
+
// character, so its press matches by character only. cck's frames use the same format.
|
|
39
|
+
function keySigs(e) {
|
|
40
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta']
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.join('+');
|
|
43
|
+
return [
|
|
44
|
+
e.code && !e.getModifierState?.('AltGraph') && `${mods}|c:${e.code}`,
|
|
45
|
+
typeof e.key === 'string' && e.key !== 'Unidentified' && `${mods}|k:${e.key.toLowerCase()}`,
|
|
46
|
+
].filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { comboOf, keyParts, keySigs };
|
|
36
50
|
})();
|
|
37
51
|
|
|
38
52
|
if (typeof module === 'object' && module.exports) module.exports = ClaudeHubKeys;
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
status: 'standalone',
|
|
21
21
|
themes: [],
|
|
22
22
|
inHub: false,
|
|
23
|
+
settings: null,
|
|
24
|
+
onSettings: () => off,
|
|
23
25
|
onStatus: () => off,
|
|
24
26
|
onActive: () => off,
|
|
25
27
|
onThemes: () => off,
|
|
@@ -27,9 +29,10 @@
|
|
|
27
29
|
bindTheme: () => off,
|
|
28
30
|
handle() {},
|
|
29
31
|
publish() {},
|
|
30
|
-
invoke(action, params = {}) {
|
|
32
|
+
invoke(action, params = {}, opts = {}) {
|
|
31
33
|
const target = standaloneFn(action)?.(params);
|
|
32
34
|
if (!target) return Promise.resolve({ ok: false, reason: 'unhandled' });
|
|
35
|
+
if (opts.open === 'pane') return Promise.resolve({ ok: true, handledBy: 'standalone', url: target });
|
|
33
36
|
win.open(target, '_blank', 'noopener');
|
|
34
37
|
return Promise.resolve({ ok: true, handledBy: 'standalone' });
|
|
35
38
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "Claude Code Hub integration: a skill to build on and extend the hub, with links to its docs, the extension mechanism and custom themes",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NikiforovAll"
|
|
@@ -70,6 +70,8 @@ The page loads `/vendor/claude-hub-sdk.js` as the first element in `<body>`, a c
|
|
|
70
70
|
|
|
71
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)`. An app that uses a `Ctrl+Alt` combo of its own lists it in `keys.keeps` in `hub-app.json`, for example `{"keeps": ["ctrl+alt+n"]}`, so the hub does not bind it, and the user cannot give it to a hub action in `config.json` `keys`. The user can change the hub's own keys there, so an app takes them from `welcome.forward`, never from a fixed list, and a help dialog shows each hub key with `hub.keyLabel(action)`, for example `hub.keyLabel('hub.projectPicker')` → `['Ctrl', 'Alt', 'P']`. Page: `extensibility/reference/manifest/#kept-keys`.
|
|
72
72
|
|
|
73
|
+
An app that has user preferences lists them in `settings` of `hub-app.json` (`boolean` or `choice`, each with `key`, `label` and `default`). The hub shows them in its settings modal (`Ctrl+,`), stores them in `config.json` once for every config dir, and sends them to the page: read `hub.settings`, or `hub.onSettings(fn)` for the values at welcome and on each change. Alone, the app uses its own defaults. Page: `extensibility/reference/manifest/#settings`.
|
|
74
|
+
|
|
73
75
|
## Connect to other tools
|
|
74
76
|
|
|
75
77
|
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/`.
|
package/public/app.js
CHANGED
|
@@ -549,7 +549,7 @@ function onIframeLoad(appId) {
|
|
|
549
549
|
if (appId === activeApp) hideLoading();
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
-
function welcomeFor(data) {
|
|
552
|
+
function welcomeFor(data, appId) {
|
|
553
553
|
const common = Array.isArray(data.protocol) ? HUB_PROTOCOLS.filter((v) => data.protocol.includes(v)) : [];
|
|
554
554
|
if (!common.length) return null;
|
|
555
555
|
return {
|
|
@@ -560,13 +560,24 @@ function welcomeFor(data) {
|
|
|
560
560
|
themes: hubThemes,
|
|
561
561
|
actions: Object.keys(actions),
|
|
562
562
|
origins: Object.keys(apps).map(originOf),
|
|
563
|
+
settings: apps[appId]?.settings ?? {},
|
|
563
564
|
};
|
|
564
565
|
}
|
|
565
566
|
|
|
567
|
+
// Protocol §12.2. An app's settings go to its tab and the pages nested in it, no other app.
|
|
568
|
+
function sendSettings(appId, list) {
|
|
569
|
+
if (!apps[appId]) return;
|
|
570
|
+
const settings = Object.fromEntries(list.map((s) => [s.key, s.value]));
|
|
571
|
+
apps[appId].settings = settings;
|
|
572
|
+
const message = { type: 'hub:settings', settings };
|
|
573
|
+
if (liveApps.has(appId)) postTo(appId, message);
|
|
574
|
+
for (const [win, nested] of nestedApps) if (nested.appId === appId) postNested(win, message);
|
|
575
|
+
}
|
|
576
|
+
|
|
566
577
|
const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
|
|
567
578
|
|
|
568
579
|
function onHello(appId, data) {
|
|
569
|
-
const welcome = welcomeFor(data);
|
|
580
|
+
const welcome = welcomeFor(data, appId);
|
|
570
581
|
if (!welcome) return;
|
|
571
582
|
liveApps.set(appId, helloTopics(data));
|
|
572
583
|
postTo(appId, welcome);
|
|
@@ -579,10 +590,13 @@ function onHello(appId, data) {
|
|
|
579
590
|
}
|
|
580
591
|
|
|
581
592
|
// A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
|
|
593
|
+
function srcOnOrigin(appId, src) {
|
|
594
|
+
return URL.parse(src)?.origin === originOf(appId) ? src : null;
|
|
595
|
+
}
|
|
596
|
+
|
|
582
597
|
function navigateApp(appId, url) {
|
|
583
|
-
const src = appSrc(appId, url);
|
|
584
|
-
if (
|
|
585
|
-
loadApp(appId, src, { link: true });
|
|
598
|
+
const src = srcOnOrigin(appId, appSrc(appId, url));
|
|
599
|
+
if (src) loadApp(appId, src, { link: true });
|
|
586
600
|
}
|
|
587
601
|
|
|
588
602
|
function isPlainObject(v) {
|
|
@@ -604,20 +618,22 @@ function fillUrl(template, params) {
|
|
|
604
618
|
}
|
|
605
619
|
|
|
606
620
|
// Protocol §7. The result does not wait for the handler: the hub queues nothing.
|
|
607
|
-
function onInvoke({ id, action, params = {} }, send) {
|
|
621
|
+
function onInvoke({ id, action, params = {}, open }, send) {
|
|
608
622
|
if (typeof id !== 'string') return;
|
|
609
623
|
const reply = (result) => send({ type: 'hub:result', id, ...result });
|
|
610
624
|
const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
|
|
611
625
|
if (!spec) return reply({ ok: false, reason: 'unhandled' });
|
|
612
626
|
if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
|
|
613
627
|
const { app: handler, mode, url: template } = spec;
|
|
628
|
+
const url = template && fillUrl(template, params);
|
|
629
|
+
const paneSrc = open === 'pane' && url && srcOnOrigin(handler, apps[handler].url + url);
|
|
630
|
+
if (paneSrc) return reply({ ok: true, handledBy: handler, url: paneSrc });
|
|
614
631
|
switchTab(handler);
|
|
615
632
|
if (mode === 'message' && liveApps.has(handler)) {
|
|
616
633
|
postTo(handler, { type: 'hub:action', id, action, params });
|
|
617
|
-
} else {
|
|
634
|
+
} else if (url && !guardedApps.has(handler)) {
|
|
618
635
|
// Setting src reloads the app, so a guarded app only gets the switch.
|
|
619
|
-
|
|
620
|
-
if (url && !guardedApps.has(handler)) navigateApp(handler, url);
|
|
636
|
+
navigateApp(handler, url);
|
|
621
637
|
}
|
|
622
638
|
reply({ ok: true, handledBy: handler });
|
|
623
639
|
}
|
|
@@ -669,7 +685,7 @@ function onNestedMessage(e) {
|
|
|
669
685
|
const appId = data.type === 'hub:hello' && underTab(e.source) ? appIdByOrigin(e.origin) : known?.appId;
|
|
670
686
|
if (!appId || e.origin !== originOf(appId)) return;
|
|
671
687
|
if (data.type === 'hub:hello') {
|
|
672
|
-
const welcome = welcomeFor(data);
|
|
688
|
+
const welcome = welcomeFor(data, appId);
|
|
673
689
|
if (!welcome) return;
|
|
674
690
|
const topics = helloTopics(data);
|
|
675
691
|
nestedApps.set(e.source, { appId, topics });
|
|
@@ -1337,6 +1353,8 @@ async function saveSetting(patch) {
|
|
|
1337
1353
|
settingsModal.data = await sendJson('POST', '/api/settings', patch);
|
|
1338
1354
|
settingsModal.error = null;
|
|
1339
1355
|
configDirSwitch = settingsModal.data.configDirSwitch;
|
|
1356
|
+
const appId = patch.app?.settings && patch.app.id;
|
|
1357
|
+
if (appId) sendSettings(appId, settingsModal.data.apps.find((a) => a.id === appId)?.settings ?? []);
|
|
1340
1358
|
} catch (err) {
|
|
1341
1359
|
settingsModal.error = err.message;
|
|
1342
1360
|
} finally {
|
|
@@ -1463,7 +1481,7 @@ function renderSettings() {
|
|
|
1463
1481
|
'“Launcher only” keeps the app running but gives it no tab or number key; open it from the app launcher. “On demand” starts it the first time you open it.',
|
|
1464
1482
|
),
|
|
1465
1483
|
);
|
|
1466
|
-
body.replaceChildren(dirs, appsSec);
|
|
1484
|
+
body.replaceChildren(dirs, appsSec, ...s.apps.filter((a) => a.settings?.length).map(appSettingsSection));
|
|
1467
1485
|
|
|
1468
1486
|
let status = el('span', '', 'Saved as you change it');
|
|
1469
1487
|
if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
|
|
@@ -1484,6 +1502,26 @@ function renderSettings() {
|
|
|
1484
1502
|
}
|
|
1485
1503
|
}
|
|
1486
1504
|
|
|
1505
|
+
const BOOLEAN_CHOICES = [
|
|
1506
|
+
[false, 'Off'],
|
|
1507
|
+
[true, 'On'],
|
|
1508
|
+
];
|
|
1509
|
+
|
|
1510
|
+
// The settings an app lists in its manifest. They apply at once, with no restart.
|
|
1511
|
+
function appSettingsSection(a) {
|
|
1512
|
+
const sec = el('section', 'set-sec');
|
|
1513
|
+
sec.append(el('h4', '', a.name));
|
|
1514
|
+
for (const setting of a.settings) {
|
|
1515
|
+
const choices = setting.type === 'boolean' ? BOOLEAN_CHOICES : setting.options.map((o) => [o.value, o.label]);
|
|
1516
|
+
const seg = segmented(setting.label, choices, setting.value, (v) =>
|
|
1517
|
+
saveSetting({ app: { id: a.id, settings: { [setting.key]: v } } }),
|
|
1518
|
+
);
|
|
1519
|
+
seg.dataset.focus = `app:${a.id}:${setting.key}`;
|
|
1520
|
+
sec.append(settingRow(setting.label, seg, setting.help));
|
|
1521
|
+
}
|
|
1522
|
+
return sec;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1487
1525
|
function kbd(text) {
|
|
1488
1526
|
return el('kbd', '', text);
|
|
1489
1527
|
}
|
package/server.js
CHANGED
|
@@ -30,6 +30,7 @@ const {
|
|
|
30
30
|
poolSize,
|
|
31
31
|
copyLive,
|
|
32
32
|
MAX_POOL_SIZE,
|
|
33
|
+
settingValues,
|
|
33
34
|
} = require('./lib/settings');
|
|
34
35
|
const { version: HUB_VERSION } = require('./package.json');
|
|
35
36
|
|
|
@@ -929,6 +930,7 @@ function appsConfig() {
|
|
|
929
930
|
publishes: a.publishes,
|
|
930
931
|
};
|
|
931
932
|
if (a.hidden) entry.hidden = true;
|
|
933
|
+
if (a.settings.length) entry.settings = appValues.get(a.id);
|
|
932
934
|
if (TERMINAL_TOKEN && a.id === TERMINAL_APP.id) entry.terminalToken = TERMINAL_TOKEN;
|
|
933
935
|
return [a.id, entry];
|
|
934
936
|
}),
|
|
@@ -1066,14 +1068,34 @@ function appName(a) {
|
|
|
1066
1068
|
return appNames.get(a.id);
|
|
1067
1069
|
}
|
|
1068
1070
|
|
|
1071
|
+
// Only a running app has its manifest loaded, so only it lists settings.
|
|
1072
|
+
const SETTING_SCHEMAS = Object.fromEntries(
|
|
1073
|
+
ENABLED_APPS.filter((a) => a.settings.length).map((a) => [a.id, a.settings]),
|
|
1074
|
+
);
|
|
1075
|
+
const savedEntry = (saved, id) => (Array.isArray(saved.apps) ? saved.apps.find((e) => e?.id === id) : undefined);
|
|
1076
|
+
// App settings apply at once, so the hub keeps their values here and in each page's welcome.
|
|
1077
|
+
const appValues = new Map();
|
|
1078
|
+
function loadAppValues(saved) {
|
|
1079
|
+
for (const [id, schema] of Object.entries(SETTING_SCHEMAS))
|
|
1080
|
+
appValues.set(id, settingValues(schema, savedEntry(saved, id)?.settings));
|
|
1081
|
+
}
|
|
1082
|
+
loadAppValues(hubConfig);
|
|
1083
|
+
|
|
1069
1084
|
function settingsView(saved) {
|
|
1070
1085
|
const apps = appSettings(Array.isArray(saved.apps) ? saved.apps : [], CONFIG_BASE);
|
|
1086
|
+
const settingsOf = (id) => SETTING_SCHEMAS[id].map((s) => ({ ...s, value: appValues.get(id)[s.key] }));
|
|
1071
1087
|
return {
|
|
1072
1088
|
configDirSwitch: configDirSwitch(hubConfig),
|
|
1073
1089
|
poolSize: currentPoolSize(),
|
|
1074
1090
|
poolSizeFlag: POOL_SIZE_FLAG !== null,
|
|
1075
1091
|
maxPoolSize: MAX_POOL_SIZE,
|
|
1076
|
-
apps: apps.map((a) => ({
|
|
1092
|
+
apps: apps.map((a) => ({
|
|
1093
|
+
id: a.id,
|
|
1094
|
+
state: a.state,
|
|
1095
|
+
deferredStart: a.deferredStart,
|
|
1096
|
+
name: appName(a),
|
|
1097
|
+
...(SETTING_SCHEMAS[a.id] && { settings: settingsOf(a.id) }),
|
|
1098
|
+
})),
|
|
1077
1099
|
restartNeeded: appsShape(apps) !== RUNNING_APPS,
|
|
1078
1100
|
};
|
|
1079
1101
|
}
|
|
@@ -1102,13 +1124,14 @@ app.post('/api/settings', (req, res) => {
|
|
|
1102
1124
|
}
|
|
1103
1125
|
const saved = readSettingsConfig(res);
|
|
1104
1126
|
if (!saved) return;
|
|
1105
|
-
const r = applySettings(saved, req.body, CONFIG_BASE);
|
|
1127
|
+
const r = applySettings(saved, req.body, CONFIG_BASE, SETTING_SCHEMAS);
|
|
1106
1128
|
if (r.error) {
|
|
1107
1129
|
res.status(400).json({ error: r.error });
|
|
1108
1130
|
return;
|
|
1109
1131
|
}
|
|
1110
1132
|
writeHubConfig(r.config);
|
|
1111
1133
|
copyLive(r.config, hubConfig);
|
|
1134
|
+
loadAppValues(r.config);
|
|
1112
1135
|
res.json(settingsView(r.config));
|
|
1113
1136
|
});
|
|
1114
1137
|
|