claude-code-hub 1.21.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 +107 -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 +50 -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();
|
|
@@ -97,8 +101,11 @@
|
|
|
97
101
|
let origin = null;
|
|
98
102
|
let welcome = null;
|
|
99
103
|
let forward = null;
|
|
104
|
+
let appOrigins = new Set();
|
|
105
|
+
let linkOpened = null;
|
|
100
106
|
let themeBinding = null;
|
|
101
107
|
let lastTheme = null;
|
|
108
|
+
let settings = null;
|
|
102
109
|
let swatchesPainted = false;
|
|
103
110
|
let nextId = 0;
|
|
104
111
|
|
|
@@ -170,6 +177,11 @@
|
|
|
170
177
|
return welcome.themes.map(({ id, label }) => ({ id, label }));
|
|
171
178
|
}
|
|
172
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
|
+
|
|
173
185
|
function onWelcome(m) {
|
|
174
186
|
if (m.protocol !== 1 || welcome) return;
|
|
175
187
|
welcome = {
|
|
@@ -178,6 +190,8 @@
|
|
|
178
190
|
themes: Array.isArray(m.themes) ? m.themes : [],
|
|
179
191
|
};
|
|
180
192
|
forward = strings(m.forward);
|
|
193
|
+
appOrigins = strings(m.origins);
|
|
194
|
+
setSettings(m.settings);
|
|
181
195
|
setStatus('live');
|
|
182
196
|
if (welcome.themes.length) for (const fn of themesFns) fn(pickerThemes());
|
|
183
197
|
themesFns.clear();
|
|
@@ -213,6 +227,9 @@
|
|
|
213
227
|
case 'hub:terminalToken':
|
|
214
228
|
for (const done of [...tokenWaiters]) done(typeof m.token === 'string' ? m.token : null);
|
|
215
229
|
return;
|
|
230
|
+
case 'hub:settings':
|
|
231
|
+
if (welcome) setSettings(m.settings);
|
|
232
|
+
return;
|
|
216
233
|
}
|
|
217
234
|
}
|
|
218
235
|
|
|
@@ -220,34 +237,95 @@
|
|
|
220
237
|
return !!forward && forward.has(comboOf(e));
|
|
221
238
|
}
|
|
222
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
|
+
|
|
223
263
|
function onKeydown(e) {
|
|
224
|
-
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;
|
|
225
270
|
e.preventDefault();
|
|
226
|
-
|
|
227
|
-
type: 'hub:keydown',
|
|
228
|
-
key: e.key,
|
|
229
|
-
code: e.code,
|
|
230
|
-
ctrl: e.ctrlKey,
|
|
231
|
-
alt: e.altKey,
|
|
232
|
-
shift: e.shiftKey,
|
|
233
|
-
meta: e.metaKey,
|
|
234
|
-
});
|
|
271
|
+
win.parent.postMessage(keyMessage('hub:hostKey', e), hostOrigin);
|
|
235
272
|
}
|
|
236
273
|
|
|
237
|
-
function run(action, params, resolve) {
|
|
274
|
+
function run(action, params, opts, resolve) {
|
|
275
|
+
const pane = opts?.open === 'pane';
|
|
238
276
|
if (status === 'live') {
|
|
239
277
|
const id = String(++nextId);
|
|
240
278
|
pending.set(id, resolve);
|
|
241
|
-
return post({ type: 'hub:invoke', id, action, params });
|
|
279
|
+
return post({ type: 'hub:invoke', id, action, params, ...(pane && { open: 'pane' }) });
|
|
242
280
|
}
|
|
243
281
|
const target = standaloneFn(action)?.(params);
|
|
244
282
|
if (!target) return resolve({ ok: false, reason: 'unhandled' });
|
|
283
|
+
if (pane) return resolve({ ok: true, handledBy: 'standalone', url: target });
|
|
245
284
|
win.open(target, '_blank', 'noopener');
|
|
246
285
|
resolve({ ok: true, handledBy: 'standalone' });
|
|
247
286
|
}
|
|
248
287
|
|
|
288
|
+
// A link with no target loads in the app's frame, and most sites refuse framing; in the installed
|
|
289
|
+
// window a _blank one opens nothing. Capture on the window runs before a modal's stopPropagation
|
|
290
|
+
// can hide the click. The hub page refuses framing, so a link to it opens outside too.
|
|
291
|
+
function onLinkClick(e) {
|
|
292
|
+
if (!welcome || e.defaultPrevented || (e.type === 'auxclick') !== (e.button === 1)) return;
|
|
293
|
+
const a = e.target?.closest?.('a[href]');
|
|
294
|
+
if (!a || a.hasAttribute?.('download')) return;
|
|
295
|
+
const target = a.getAttribute?.('target') || '';
|
|
296
|
+
if (target && !target.startsWith('_')) return;
|
|
297
|
+
let url;
|
|
298
|
+
try {
|
|
299
|
+
// An SVG <a> has an SVGAnimatedString href, so the attribute is read instead.
|
|
300
|
+
url = new URL(a.getAttribute?.('href') ?? a.href, doc.baseURI);
|
|
301
|
+
} catch (_) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
305
|
+
const inApp = url.origin === win.location.origin || appOrigins.has(url.origin);
|
|
306
|
+
if (inApp && target !== '_blank') return;
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
hub.openExternal(url.href);
|
|
309
|
+
// Apps written before this listener open the same link from their own click handler.
|
|
310
|
+
linkOpened = url.href;
|
|
311
|
+
win.setTimeout(() => {
|
|
312
|
+
linkOpened = null;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function sameUrl(url, href) {
|
|
317
|
+
try {
|
|
318
|
+
return new URL(String(url), doc.baseURI).href === href;
|
|
319
|
+
} catch (_) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
249
324
|
win.addEventListener('message', onMessage);
|
|
325
|
+
win.addEventListener('message', onHostMessage);
|
|
250
326
|
doc.addEventListener('keydown', onKeydown);
|
|
327
|
+
win.addEventListener('click', onLinkClick, true);
|
|
328
|
+
win.addEventListener('auxclick', onLinkClick, true);
|
|
251
329
|
|
|
252
330
|
const loaded =
|
|
253
331
|
doc.readyState === 'complete'
|
|
@@ -316,6 +394,18 @@
|
|
|
316
394
|
if (welcome.themes.length) fn(pickerThemes());
|
|
317
395
|
return () => {};
|
|
318
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
|
+
},
|
|
319
409
|
subscribe(topic, fn) {
|
|
320
410
|
const fns = topicFns(topic);
|
|
321
411
|
fns.add(fn);
|
|
@@ -344,10 +434,10 @@
|
|
|
344
434
|
if (status === 'live') post({ type: 'hub:publish', topic, payload });
|
|
345
435
|
else if (waiting()) outbox.set(topic, payload);
|
|
346
436
|
},
|
|
347
|
-
invoke(action, params = {}) {
|
|
437
|
+
invoke(action, params = {}, opts = {}) {
|
|
348
438
|
return new Promise((resolve) => {
|
|
349
|
-
if (waiting()) queued.push(() => run(action, params, resolve));
|
|
350
|
-
else run(action, params, resolve);
|
|
439
|
+
if (waiting()) queued.push(() => run(action, params, opts, resolve));
|
|
440
|
+
else run(action, params, opts, resolve);
|
|
351
441
|
});
|
|
352
442
|
},
|
|
353
443
|
can(action) {
|
|
@@ -378,6 +468,7 @@
|
|
|
378
468
|
},
|
|
379
469
|
// In the hub's installed PWA window, a framed page's own _blank open does nothing.
|
|
380
470
|
openExternal(url) {
|
|
471
|
+
if (linkOpened !== null && sameUrl(url, linkOpened)) return;
|
|
381
472
|
if (origin) post({ type: 'hub:openExternal', url });
|
|
382
473
|
else win.open(url, '_blank', 'noopener');
|
|
383
474
|
},
|
|
@@ -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 {
|
|
@@ -559,13 +559,25 @@ function welcomeFor(data) {
|
|
|
559
559
|
keys: actionKeys(),
|
|
560
560
|
themes: hubThemes,
|
|
561
561
|
actions: Object.keys(actions),
|
|
562
|
+
origins: Object.keys(apps).map(originOf),
|
|
563
|
+
settings: apps[appId]?.settings ?? {},
|
|
562
564
|
};
|
|
563
565
|
}
|
|
564
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
|
+
|
|
565
577
|
const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
|
|
566
578
|
|
|
567
579
|
function onHello(appId, data) {
|
|
568
|
-
const welcome = welcomeFor(data);
|
|
580
|
+
const welcome = welcomeFor(data, appId);
|
|
569
581
|
if (!welcome) return;
|
|
570
582
|
liveApps.set(appId, helloTopics(data));
|
|
571
583
|
postTo(appId, welcome);
|
|
@@ -578,10 +590,13 @@ function onHello(appId, data) {
|
|
|
578
590
|
}
|
|
579
591
|
|
|
580
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
|
+
|
|
581
597
|
function navigateApp(appId, url) {
|
|
582
|
-
const src = appSrc(appId, url);
|
|
583
|
-
if (
|
|
584
|
-
loadApp(appId, src, { link: true });
|
|
598
|
+
const src = srcOnOrigin(appId, appSrc(appId, url));
|
|
599
|
+
if (src) loadApp(appId, src, { link: true });
|
|
585
600
|
}
|
|
586
601
|
|
|
587
602
|
function isPlainObject(v) {
|
|
@@ -603,20 +618,22 @@ function fillUrl(template, params) {
|
|
|
603
618
|
}
|
|
604
619
|
|
|
605
620
|
// Protocol §7. The result does not wait for the handler: the hub queues nothing.
|
|
606
|
-
function onInvoke({ id, action, params = {} }, send) {
|
|
621
|
+
function onInvoke({ id, action, params = {}, open }, send) {
|
|
607
622
|
if (typeof id !== 'string') return;
|
|
608
623
|
const reply = (result) => send({ type: 'hub:result', id, ...result });
|
|
609
624
|
const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
|
|
610
625
|
if (!spec) return reply({ ok: false, reason: 'unhandled' });
|
|
611
626
|
if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
|
|
612
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 });
|
|
613
631
|
switchTab(handler);
|
|
614
632
|
if (mode === 'message' && liveApps.has(handler)) {
|
|
615
633
|
postTo(handler, { type: 'hub:action', id, action, params });
|
|
616
|
-
} else {
|
|
634
|
+
} else if (url && !guardedApps.has(handler)) {
|
|
617
635
|
// Setting src reloads the app, so a guarded app only gets the switch.
|
|
618
|
-
|
|
619
|
-
if (url && !guardedApps.has(handler)) navigateApp(handler, url);
|
|
636
|
+
navigateApp(handler, url);
|
|
620
637
|
}
|
|
621
638
|
reply({ ok: true, handledBy: handler });
|
|
622
639
|
}
|
|
@@ -668,7 +685,7 @@ function onNestedMessage(e) {
|
|
|
668
685
|
const appId = data.type === 'hub:hello' && underTab(e.source) ? appIdByOrigin(e.origin) : known?.appId;
|
|
669
686
|
if (!appId || e.origin !== originOf(appId)) return;
|
|
670
687
|
if (data.type === 'hub:hello') {
|
|
671
|
-
const welcome = welcomeFor(data);
|
|
688
|
+
const welcome = welcomeFor(data, appId);
|
|
672
689
|
if (!welcome) return;
|
|
673
690
|
const topics = helloTopics(data);
|
|
674
691
|
nestedApps.set(e.source, { appId, topics });
|
|
@@ -1336,6 +1353,8 @@ async function saveSetting(patch) {
|
|
|
1336
1353
|
settingsModal.data = await sendJson('POST', '/api/settings', patch);
|
|
1337
1354
|
settingsModal.error = null;
|
|
1338
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 ?? []);
|
|
1339
1358
|
} catch (err) {
|
|
1340
1359
|
settingsModal.error = err.message;
|
|
1341
1360
|
} finally {
|
|
@@ -1462,7 +1481,7 @@ function renderSettings() {
|
|
|
1462
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.',
|
|
1463
1482
|
),
|
|
1464
1483
|
);
|
|
1465
|
-
body.replaceChildren(dirs, appsSec);
|
|
1484
|
+
body.replaceChildren(dirs, appsSec, ...s.apps.filter((a) => a.settings?.length).map(appSettingsSection));
|
|
1466
1485
|
|
|
1467
1486
|
let status = el('span', '', 'Saved as you change it');
|
|
1468
1487
|
if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
|
|
@@ -1483,6 +1502,26 @@ function renderSettings() {
|
|
|
1483
1502
|
}
|
|
1484
1503
|
}
|
|
1485
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
|
+
|
|
1486
1525
|
function kbd(text) {
|
|
1487
1526
|
return el('kbd', '', text);
|
|
1488
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
|
|