claude-code-hub 1.22.0 → 1.24.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 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 || (typeof v === 'string' && v.length > 0 && v.length <= max && !/[<>]/.test(v))
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
- function applySettings(saved, patch, base) {
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/lib/themes.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "ink1": "#f0f1f3",
15
15
  "ink2": "#c2c4c9",
16
16
  "ink3": "#9a9da5",
17
- "inkMuted": "#7d808a"
17
+ "inkMuted": "#858891"
18
18
  },
19
19
  "light": {
20
20
  "ember": "#e86f33",
@@ -28,7 +28,7 @@
28
28
  "ink1": "#0a0a0a",
29
29
  "ink2": "#2c2c2c",
30
30
  "ink3": "#565656",
31
- "inkMuted": "#767676"
31
+ "inkMuted": "#666666"
32
32
  }
33
33
  },
34
34
  {
@@ -46,7 +46,7 @@
46
46
  "ink1": "#ebdbb2",
47
47
  "ink2": "#d5c4a1",
48
48
  "ink3": "#bdae93",
49
- "inkMuted": "#928374"
49
+ "inkMuted": "#a69782"
50
50
  },
51
51
  "light": {
52
52
  "ember": "#d65d0e",
@@ -59,8 +59,8 @@
59
59
  "border": "#c9bc9d",
60
60
  "ink1": "#3c3836",
61
61
  "ink2": "#504945",
62
- "ink3": "#665c54",
63
- "inkMuted": "#7c6f64"
62
+ "ink3": "#5f564f",
63
+ "inkMuted": "#70645b"
64
64
  }
65
65
  },
66
66
  {
@@ -78,7 +78,7 @@
78
78
  "ink1": "#cdd6f4",
79
79
  "ink2": "#bac2de",
80
80
  "ink3": "#a6adc8",
81
- "inkMuted": "#7f849c"
81
+ "inkMuted": "#82879f"
82
82
  },
83
83
  "light": {
84
84
  "ember": "#8839ef",
@@ -91,8 +91,8 @@
91
91
  "border": "#bcc0cc",
92
92
  "ink1": "#4c4f69",
93
93
  "ink2": "#5c5f77",
94
- "ink3": "#6c6f85",
95
- "inkMuted": "#8c8fa1"
94
+ "ink3": "#545770",
95
+ "inkMuted": "#63667d"
96
96
  }
97
97
  },
98
98
  {
@@ -109,8 +109,8 @@
109
109
  "border": "#3b4261",
110
110
  "ink1": "#c0caf5",
111
111
  "ink2": "#a9b1d6",
112
- "ink3": "#787c99",
113
- "inkMuted": "#565f89"
112
+ "ink3": "#959cbe",
113
+ "inkMuted": "#838aaf"
114
114
  },
115
115
  "light": {
116
116
  "ember": "#2e7de9",
@@ -123,8 +123,8 @@
123
123
  "border": "#a8aecb",
124
124
  "ink1": "#343b58",
125
125
  "ink2": "#565a6e",
126
- "ink3": "#6c6e75",
127
- "inkMuted": "#848cb5"
126
+ "ink3": "#505466",
127
+ "inkMuted": "#5d627a"
128
128
  }
129
129
  },
130
130
  {
@@ -141,8 +141,8 @@
141
141
  "border": "#586e75",
142
142
  "ink1": "#e2dfd0",
143
143
  "ink2": "#b7c0bf",
144
- "ink3": "#93a1a1",
145
- "inkMuted": "#839496"
144
+ "ink3": "#b7beb7",
145
+ "inkMuted": "#9faaa7"
146
146
  },
147
147
  "light": {
148
148
  "ember": "#2280c2",
@@ -157,8 +157,8 @@
157
157
  "border": "#cdc4ab",
158
158
  "ink1": "#073642",
159
159
  "ink2": "#4f6369",
160
- "ink3": "#586e75",
161
- "inkMuted": "#728589"
160
+ "ink3": "#385861",
161
+ "inkMuted": "#4a666e"
162
162
  }
163
163
  },
164
164
  {
@@ -175,8 +175,8 @@
175
175
  "border": "#565a75",
176
176
  "ink1": "#f8f8f2",
177
177
  "ink2": "#d8d8d2",
178
- "ink3": "#b0b3c5",
179
- "inkMuted": "#6272a4"
178
+ "ink3": "#b2b5c6",
179
+ "inkMuted": "#9aa1bc"
180
180
  },
181
181
  "light": {
182
182
  "ember": "#7c3aed",
@@ -189,8 +189,8 @@
189
189
  "border": "#c9c9d6",
190
190
  "ink1": "#16161e",
191
191
  "ink2": "#34343f",
192
- "ink3": "#5a5a68",
193
- "inkMuted": "#787885"
192
+ "ink3": "#575765",
193
+ "inkMuted": "#666674"
194
194
  }
195
195
  },
196
196
  {
@@ -207,8 +207,8 @@
207
207
  "border": "#4c566a",
208
208
  "ink1": "#eceff4",
209
209
  "ink2": "#d8dee9",
210
- "ink3": "#aeb6c5",
211
- "inkMuted": "#7b88a1"
210
+ "ink3": "#bec4d1",
211
+ "inkMuted": "#a7b0c1"
212
212
  },
213
213
  "light": {
214
214
  "ember": "#5e81ac",
@@ -222,7 +222,7 @@
222
222
  "ink1": "#2e3440",
223
223
  "ink2": "#3b4252",
224
224
  "ink3": "#4c566a",
225
- "inkMuted": "#6b7589"
225
+ "inkMuted": "#5e687c"
226
226
  }
227
227
  },
228
228
  {
@@ -239,8 +239,8 @@
239
239
  "border": "#403d52",
240
240
  "ink1": "#e0def4",
241
241
  "ink2": "#c5c2dd",
242
- "ink3": "#908caa",
243
- "inkMuted": "#6e6a86"
242
+ "ink3": "#a09db9",
243
+ "inkMuted": "#8e8ba7"
244
244
  },
245
245
  "light": {
246
246
  "ember": "#907aa9",
@@ -253,8 +253,8 @@
253
253
  "border": "#cecacd",
254
254
  "ink1": "#575279",
255
255
  "ink2": "#635e87",
256
- "ink3": "#6e6a88",
257
- "inkMuted": "#857f96"
256
+ "ink3": "#5a557b",
257
+ "inkMuted": "#6a6485"
258
258
  }
259
259
  },
260
260
  {
@@ -271,8 +271,8 @@
271
271
  "border": "#475258",
272
272
  "ink1": "#d3c6aa",
273
273
  "ink2": "#bfb6a3",
274
- "ink3": "#9da9a0",
275
- "inkMuted": "#859289"
274
+ "ink3": "#c3bea7",
275
+ "inkMuted": "#a8ab9a"
276
276
  },
277
277
  "light": {
278
278
  "ember": "#6f8352",
@@ -285,8 +285,8 @@
285
285
  "border": "#d3d2bf",
286
286
  "ink1": "#3d484e",
287
287
  "ink2": "#55636b",
288
- "ink3": "#66705f",
289
- "inkMuted": "#7a8577"
288
+ "ink3": "#515b56",
289
+ "inkMuted": "#606a62"
290
290
  }
291
291
  },
292
292
  {
@@ -304,7 +304,7 @@
304
304
  "ink1": "#dcd7ba",
305
305
  "ink2": "#c8c093",
306
306
  "ink3": "#a6a69c",
307
- "inkMuted": "#727169"
307
+ "inkMuted": "#94948a"
308
308
  },
309
309
  "light": {
310
310
  "ember": "#4d699b",
@@ -317,8 +317,8 @@
317
317
  "border": "#d2ccb6",
318
318
  "ink1": "#545464",
319
319
  "ink2": "#66667a",
320
- "ink3": "#716e61",
321
- "inkMuted": "#8a8775"
320
+ "ink3": "#565564",
321
+ "inkMuted": "#666469"
322
322
  }
323
323
  },
324
324
  {
@@ -335,8 +335,8 @@
335
335
  "border": "#4b5263",
336
336
  "ink1": "#d7dae0",
337
337
  "ink2": "#abb2bf",
338
- "ink3": "#8b919e",
339
- "inkMuted": "#5c6370"
338
+ "ink3": "#acb0ba",
339
+ "inkMuted": "#999ea8"
340
340
  },
341
341
  "light": {
342
342
  "ember": "#4078f2",
@@ -349,8 +349,8 @@
349
349
  "border": "#d4d4d6",
350
350
  "ink1": "#383a42",
351
351
  "ink2": "#50525a",
352
- "ink3": "#696c77",
353
- "inkMuted": "#a0a1a7"
352
+ "ink3": "#575963",
353
+ "inkMuted": "#666871"
354
354
  }
355
355
  },
356
356
  {
@@ -367,8 +367,8 @@
367
367
  "border": "#5f7e97",
368
368
  "ink1": "#d6deeb",
369
369
  "ink2": "#b8c5d6",
370
- "ink3": "#8ba3b8",
371
- "inkMuted": "#637777"
370
+ "ink3": "#9bb0c3",
371
+ "inkMuted": "#899eab"
372
372
  },
373
373
  "light": {
374
374
  "ember": "#0c969b",
@@ -381,8 +381,8 @@
381
381
  "border": "#d0d0d0",
382
382
  "ink1": "#403f53",
383
383
  "ink2": "#545167",
384
- "ink3": "#6f6e85",
385
- "inkMuted": "#989fb1"
384
+ "ink3": "#5a596f",
385
+ "inkMuted": "#68687e"
386
386
  }
387
387
  },
388
388
  {
@@ -399,8 +399,8 @@
399
399
  "border": "#5b595c",
400
400
  "ink1": "#fcfcfa",
401
401
  "ink2": "#d9d8d6",
402
- "ink3": "#939293",
403
- "inkMuted": "#727072"
402
+ "ink3": "#bfbfbe",
403
+ "inkMuted": "#ababab"
404
404
  },
405
405
  "light": {
406
406
  "ember": "#c08a00",
@@ -413,8 +413,8 @@
413
413
  "border": "#cdc8c5",
414
414
  "ink1": "#2c292d",
415
415
  "ink2": "#46434a",
416
- "ink3": "#6b686d",
417
- "inkMuted": "#8d8a8d"
416
+ "ink3": "#59565b",
417
+ "inkMuted": "#68656a"
418
418
  }
419
419
  },
420
420
  {
@@ -431,8 +431,8 @@
431
431
  "border": "#444d56",
432
432
  "ink1": "#e1e4e8",
433
433
  "ink2": "#d1d5da",
434
- "ink3": "#959da5",
435
- "inkMuted": "#6a737d"
434
+ "ink3": "#a6adb4",
435
+ "inkMuted": "#929aa2"
436
436
  },
437
437
  "light": {
438
438
  "ember": "#0366d6",
@@ -445,8 +445,8 @@
445
445
  "border": "#d1d5da",
446
446
  "ink1": "#24292e",
447
447
  "ink2": "#444d56",
448
- "ink3": "#586069",
449
- "inkMuted": "#6a737d"
448
+ "ink3": "#575f68",
449
+ "inkMuted": "#666e78"
450
450
  }
451
451
  },
452
452
  {
@@ -463,8 +463,8 @@
463
463
  "border": "#2d3343",
464
464
  "ink1": "#bfbdb6",
465
465
  "ink2": "#a8a6a0",
466
- "ink3": "#8a9199",
467
- "inkMuted": "#6c7380"
466
+ "ink3": "#8d949b",
467
+ "inkMuted": "#7c838d"
468
468
  },
469
469
  "light": {
470
470
  "ember": "#c97612",
@@ -477,8 +477,8 @@
477
477
  "border": "#d8dde2",
478
478
  "ink1": "#3d4149",
479
479
  "ink2": "#5c6166",
480
- "ink3": "#787b80",
481
- "inkMuted": "#8a9199"
480
+ "ink3": "#5a5e64",
481
+ "inkMuted": "#686d74"
482
482
  }
483
483
  },
484
484
  {
@@ -495,8 +495,8 @@
495
495
  "border": "#333333",
496
496
  "ink1": "#dbd7ca",
497
497
  "ink2": "#bfbaaa",
498
- "ink3": "#8f8f85",
499
- "inkMuted": "#758575"
498
+ "ink3": "#99998e",
499
+ "inkMuted": "#7e8a7b"
500
500
  },
501
501
  "light": {
502
502
  "ember": "#1c6b48",
@@ -509,8 +509,8 @@
509
509
  "border": "#d6d6d6",
510
510
  "ink1": "#393a34",
511
511
  "ink2": "#4e4f47",
512
- "ink3": "#6b6d63",
513
- "inkMuted": "#a0ada0"
512
+ "ink3": "#5d5e56",
513
+ "inkMuted": "#6a6d64"
514
514
  }
515
515
  },
516
516
  {
@@ -528,7 +528,7 @@
528
528
  "ink1": "#ffffff",
529
529
  "ink2": "#d8d6e8",
530
530
  "ink3": "#b6b1d8",
531
- "inkMuted": "#848bbd"
531
+ "inkMuted": "#8f94c3"
532
532
  },
533
533
  "light": {
534
534
  "ember": "#c936a6",
@@ -541,8 +541,8 @@
541
541
  "border": "#c5bcdd",
542
542
  "ink1": "#241b2f",
543
543
  "ink2": "#3d3455",
544
- "ink3": "#5c5380",
545
- "inkMuted": "#7d76a3"
544
+ "ink3": "#59507c",
545
+ "inkMuted": "#675f8b"
546
546
  }
547
547
  }
548
548
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.22.0",
3
+ "version": "1.24.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.0",
50
- "claude-code-kanban": "7.17.0",
51
- "claude-code-marketplace": "1.6.0",
52
- "claude-code-memory-explorer": "1.3.2",
49
+ "claude-code-cost": "1.6.0",
50
+ "claude-code-kanban": "7.21.0",
51
+ "claude-code-marketplace": "1.7.1",
52
+ "claude-code-memory-explorer": "1.3.4",
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 (!forwards(e)) return;
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
- post({
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
- return { comboOf, keyParts };
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.11",
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 (!URL.canParse(src) || new URL(src).origin !== originOf(appId)) return;
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
- const url = template && fillUrl(template, params);
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) => ({ id: a.id, state: a.state, deferredStart: a.deferredStart, name: appName(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