claude-code-hub 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/lib/apps.js +14 -1
- package/lib/keymap.js +78 -0
- package/package.json +8 -6
- package/packages/claude-hub-sdk/src/client.js +31 -9
- package/packages/claude-hub-sdk/src/keys.js +38 -0
- package/packages/claude-hub-sdk/src/server.js +10 -3
- package/packages/claude-hub-sdk/src/stub.js +3 -8
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/SKILL.md +3 -2
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +4 -4
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/keys.md +50 -0
- package/public/app.js +93 -37
- package/public/index.html +14 -1
- package/public/sw.js +2 -2
- package/server.js +7 -0
package/README.md
CHANGED
|
@@ -157,6 +157,8 @@ The hub has no visible UI of its own. Use these shortcuts:
|
|
|
157
157
|
|
|
158
158
|
<kbd>Alt</kbd>+<kbd>1</kbd> to <kbd>Alt</kbd>+<kbd>4</kbd> follow the tab order, so they change when you [choose the tools](#choose-the-tools).
|
|
159
159
|
|
|
160
|
+
On macOS, every hub key is <kbd>Control</kbd>+<kbd>Option</kbd> with a key, the tool numbers too: <kbd>⌃⌥1</kbd> to <kbd>⌃⌥4</kbd>. <kbd>Option</kbd> with a digit types a character there, so the hub leaves it to the tools.
|
|
161
|
+
|
|
160
162
|
In a palette, use <kbd>↑</kbd>/<kbd>↓</kbd> or <kbd>Tab</kbd> to move, <kbd>Enter</kbd> to select, and <kbd>Esc</kbd> to close.
|
|
161
163
|
|
|
162
164
|
The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd> (new session), <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> (resume session), and <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> (previous session) for itself. From Kanban you can also jump to another tool for the selected session:
|
package/lib/apps.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { isContained } = require('./contain');
|
|
6
|
+
const { COMBO_RE } = require('./keymap');
|
|
6
7
|
|
|
7
8
|
// Built-in apps in their default tab order. The rest of each app comes from its manifest.
|
|
8
9
|
const APPS = [
|
|
@@ -64,7 +65,18 @@ function manifestError(m, id, root) {
|
|
|
64
65
|
if (typeof m.run?.entry !== 'string') return 'run.entry is missing';
|
|
65
66
|
const entry = path.resolve(root, m.run.entry);
|
|
66
67
|
if (entry === path.resolve(root) || !isContained(entry, root)) return 'run.entry is outside the app directory';
|
|
67
|
-
return providesError(m.provides) ?? publishesError(m.publishes);
|
|
68
|
+
return providesError(m.provides) ?? publishesError(m.publishes) ?? keysError(m.keys);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function keysError(keys) {
|
|
72
|
+
if (keys === undefined) return null;
|
|
73
|
+
if (typeof keys !== 'object' || keys === null || Array.isArray(keys)) return 'keys is not an object';
|
|
74
|
+
if (keys.keeps === undefined) return null;
|
|
75
|
+
if (!Array.isArray(keys.keeps)) return 'keys.keeps is not an array';
|
|
76
|
+
for (const c of keys.keeps) {
|
|
77
|
+
if (typeof c !== 'string' || !COMBO_RE.test(c)) return `keys.keeps combo ${JSON.stringify(c)} is not a combo name`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
const TOPIC_RE = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
@@ -144,6 +156,7 @@ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve,
|
|
|
144
156
|
actions: manifest.actions ?? {},
|
|
145
157
|
provides: manifest.provides ?? {},
|
|
146
158
|
publishes: manifest.publishes ?? [],
|
|
159
|
+
keeps: manifest.keys?.keeps ?? [],
|
|
147
160
|
};
|
|
148
161
|
}
|
|
149
162
|
|
package/lib/keymap.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The actions a user can rebind in config.json `keys`. Their defaults are in hubActions() in
|
|
4
|
+
// public/app.js; test/keys.test.js checks that the two lists match.
|
|
5
|
+
const HUB_ACTIONS = [
|
|
6
|
+
'hub.projectPicker',
|
|
7
|
+
'hub.configDirPicker',
|
|
8
|
+
'hub.appLauncher',
|
|
9
|
+
'hub.prevApp',
|
|
10
|
+
'hub.nextApp',
|
|
11
|
+
'hub.appByNumber',
|
|
12
|
+
];
|
|
13
|
+
// {n} stands for the tab number, 1 to 9.
|
|
14
|
+
const PATTERN_ACTIONS = new Set(['hub.appByNumber']);
|
|
15
|
+
const MODS = ['ctrl', 'alt', 'shift', 'meta'];
|
|
16
|
+
|
|
17
|
+
// A combo name as the SDK's comboOf() gives it (protocol section 8), with one modifier at least.
|
|
18
|
+
const COMBO_RE = /^(?=(?:ctrl|alt|shift|meta)\+)(?:ctrl\+)?(?:alt\+)?(?:shift\+)?(?:meta\+)?(?:[a-z1-9]|[A-Z][A-Za-z0-9]+)$/;
|
|
19
|
+
|
|
20
|
+
// "Ctrl+Alt+O" → "ctrl+alt+o", the form comboOf() gives. Null when the text cannot name a combo.
|
|
21
|
+
function normalizeCombo(text) {
|
|
22
|
+
if (typeof text !== 'string') return null;
|
|
23
|
+
const parts = text.split('+').map((p) => p.trim());
|
|
24
|
+
let key = parts.pop();
|
|
25
|
+
const mods = parts.map((p) => p.toLowerCase());
|
|
26
|
+
if (mods.some((m) => !MODS.includes(m)) || new Set(mods).size !== mods.length) return null;
|
|
27
|
+
if (key.length === 1) key = key.toLowerCase();
|
|
28
|
+
const combo = [...MODS.filter((m) => mods.includes(m)), key].join('+');
|
|
29
|
+
return COMBO_RE.test(combo.replace(/\+\{n\}$/, '+1')) ? combo : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The `keys` of config.json, checked, as {actionId: combo | null}; null unbinds the action. An
|
|
33
|
+
// entry that is not valid, takes a combo an app keeps or repeats an earlier entry's combo is left
|
|
34
|
+
// out with a line that says why, so that action keeps its default.
|
|
35
|
+
function userKeys(saved, apps) {
|
|
36
|
+
const keys = {};
|
|
37
|
+
const lines = [];
|
|
38
|
+
if (saved === undefined) return { keys, lines };
|
|
39
|
+
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) {
|
|
40
|
+
lines.push('keys is not an object, ignored');
|
|
41
|
+
return { keys, lines };
|
|
42
|
+
}
|
|
43
|
+
const kept = new Map(apps.flatMap((a) => a.keeps.map((c) => [c, a.id])));
|
|
44
|
+
const owner = new Map();
|
|
45
|
+
for (const [id, value] of Object.entries(saved)) {
|
|
46
|
+
const drop = (why) => lines.push(`keys "${id}": ${why}. It keeps its default`);
|
|
47
|
+
if (!HUB_ACTIONS.includes(id)) {
|
|
48
|
+
lines.push(`keys "${id}" is not a hub action, ignored`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (value === null) {
|
|
52
|
+
keys[id] = null;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const combo = normalizeCombo(value);
|
|
56
|
+
const pattern = PATTERN_ACTIONS.has(id);
|
|
57
|
+
if (!combo || pattern !== combo.endsWith('+{n}')) {
|
|
58
|
+
drop(`${JSON.stringify(value)} is not a combo${pattern ? ' that ends in +{n}' : ''}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const combos = pattern ? Array.from({ length: 9 }, (_, i) => combo.replace('{n}', i + 1)) : [combo];
|
|
62
|
+
const keptCombo = combos.find((c) => kept.has(c));
|
|
63
|
+
if (keptCombo) {
|
|
64
|
+
drop(`${keptCombo} is kept by ${kept.get(keptCombo)}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const taken = combos.find((c) => owner.has(c));
|
|
68
|
+
if (taken) {
|
|
69
|
+
drop(`${taken} is already set for "${owner.get(taken)}"`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
for (const c of combos) owner.set(c, id);
|
|
73
|
+
keys[id] = combo;
|
|
74
|
+
}
|
|
75
|
+
return { keys, lines };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { HUB_ACTIONS, COMBO_RE, normalizeCombo, userKeys };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"start": "node server.js",
|
|
11
11
|
"dev": "node server.js --open",
|
|
12
12
|
"test": "node --test test/*.test.js packages/claude-hub-sdk/test/*.test.js",
|
|
13
|
+
"test:e2e": "node --test --test-concurrency=1 test/e2e/keys.e2e.mjs",
|
|
13
14
|
"update": "bash scripts/update-submodules.sh",
|
|
14
15
|
"update:check": "bash scripts/update-submodules.sh --check",
|
|
15
16
|
"lint": "npx @biomejs/biome check public/app.js server.js packages/claude-hub-sdk && npm run check:escaping && npm run check:security-lib",
|
|
@@ -45,10 +46,10 @@
|
|
|
45
46
|
},
|
|
46
47
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
47
48
|
"dependencies": {
|
|
48
|
-
"claude-code-cost": "1.
|
|
49
|
-
"claude-code-kanban": "7.
|
|
50
|
-
"claude-code-marketplace": "1.
|
|
51
|
-
"claude-code-memory-explorer": "1.
|
|
49
|
+
"claude-code-cost": "1.3.0",
|
|
50
|
+
"claude-code-kanban": "7.3.0",
|
|
51
|
+
"claude-code-marketplace": "1.4.0",
|
|
52
|
+
"claude-code-memory-explorer": "1.2.0",
|
|
52
53
|
"express": "^4.21.0",
|
|
53
54
|
"open": "^10.1.0"
|
|
54
55
|
},
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
],
|
|
66
67
|
"devDependencies": {
|
|
67
68
|
"@biomejs/biome": "^2.4.10",
|
|
68
|
-
"husky": "^9.1.7"
|
|
69
|
+
"husky": "^9.1.7",
|
|
70
|
+
"playwright-core": "^1.63.0"
|
|
69
71
|
}
|
|
70
72
|
}
|
|
@@ -7,14 +7,8 @@
|
|
|
7
7
|
const VARS_KEY = 'claude-hub:vars';
|
|
8
8
|
const WELCOME_WAIT_MS = 2000;
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
13
|
-
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
14
|
-
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
15
|
-
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
16
|
-
return [...mods, key].filter(Boolean).join('+');
|
|
17
|
-
}
|
|
10
|
+
// keys.js comes first in the served file. Required as a module, client.js loads it itself.
|
|
11
|
+
const { comboOf, keyParts } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
|
|
18
12
|
|
|
19
13
|
function isVars(v) {
|
|
20
14
|
return (
|
|
@@ -33,6 +27,15 @@
|
|
|
33
27
|
|
|
34
28
|
const strings = (list) => new Set(Array.isArray(list) ? list.filter((s) => typeof s === 'string') : []);
|
|
35
29
|
|
|
30
|
+
const isMac = (win) => /^Mac/i.test(win.navigator?.userAgentData?.platform || win.navigator?.platform || '');
|
|
31
|
+
|
|
32
|
+
const comboMap = (keys) =>
|
|
33
|
+
new Map(
|
|
34
|
+
keys && typeof keys === 'object' && !Array.isArray(keys)
|
|
35
|
+
? Object.entries(keys).filter(([, c]) => c === null || (typeof c === 'string' && c))
|
|
36
|
+
: [],
|
|
37
|
+
);
|
|
38
|
+
|
|
36
39
|
function createClaudeHub(win) {
|
|
37
40
|
const doc = win.document;
|
|
38
41
|
const framed = win.top !== win;
|
|
@@ -157,7 +160,11 @@
|
|
|
157
160
|
|
|
158
161
|
function onWelcome(m) {
|
|
159
162
|
if (m.protocol !== 1 || welcome) return;
|
|
160
|
-
welcome = {
|
|
163
|
+
welcome = {
|
|
164
|
+
actions: strings(m.actions),
|
|
165
|
+
keys: comboMap(m.keys),
|
|
166
|
+
themes: Array.isArray(m.themes) ? m.themes : [],
|
|
167
|
+
};
|
|
161
168
|
forward = strings(m.forward);
|
|
162
169
|
setStatus('live');
|
|
163
170
|
if (welcome.themes.length) for (const fn of themesFns) fn(pickerThemes());
|
|
@@ -336,6 +343,21 @@
|
|
|
336
343
|
forwards,
|
|
337
344
|
// The combos forwards() matches, for a frame that tests keys with ClaudeHub.comboOf on its own.
|
|
338
345
|
forwardCombos: () => (forward ? [...forward] : []),
|
|
346
|
+
// The keys of a hub action for a help row, in this system's names. A list of actions shares one
|
|
347
|
+
// row: ['Ctrl', 'Alt', '←/→']. [] when no key runs them; null before welcome, standalone, or
|
|
348
|
+
// for an action the hub did not list.
|
|
349
|
+
keyLabel(action) {
|
|
350
|
+
const ids = Array.isArray(action) ? action : [action];
|
|
351
|
+
if (!welcome || !ids.every((id) => welcome.keys.has(id))) return null;
|
|
352
|
+
const all = ids
|
|
353
|
+
.map((id) => welcome.keys.get(id))
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.map((c) => keyParts(c, isMac(win)));
|
|
356
|
+
if (all.length < 2) return all[0] ?? [];
|
|
357
|
+
let n = 0;
|
|
358
|
+
while (n < all[0].length - 1 && all.every((p) => p.length - 1 > n && p[n] === all[0][n])) n++;
|
|
359
|
+
return [...all[0].slice(0, n), all.map((p) => p.slice(n).join('+')).join('/')];
|
|
360
|
+
},
|
|
339
361
|
closeGuard(on) {
|
|
340
362
|
post({ type: 'hub:closeGuard', on: !!on });
|
|
341
363
|
},
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Claude Code Hub SDK: the one rule that names a key press, shared by the hub page, client.js and
|
|
2
|
+
// stub.js. The hub serves it to its page as /sdk/keys.js; mount() and sdk:sync put it in front of
|
|
3
|
+
// client.js and stub.js, so an app still loads one file.
|
|
4
|
+
// var, not const: the hub page and an app page each load it as a classic script, and a second
|
|
5
|
+
// const declaration in the same page would throw.
|
|
6
|
+
var ClaudeHubKeys = (() => {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
// Modifiers in ctrl, alt, shift, meta order, joined by '+' to the key. macOS composes
|
|
10
|
+
// Option+<key> into a character (Option+1 is '¡', Option+P is 'π') and holding Control does not
|
|
11
|
+
// undo it, so e.key alone cannot name these presses there. e.code is the physical key, which is
|
|
12
|
+
// wrong for non-US layouts, hence only as a fallback. No fallback under AltGraph: Chromium sets it
|
|
13
|
+
// when AltGr types a character, and that press is text. Takes a real KeyboardEvent or a forwarded
|
|
14
|
+
// {key, code} payload; a payload without code degrades to key. A payload needs no AltGraph: the
|
|
15
|
+
// SDK forwards only a press whose key already names a bound combo.
|
|
16
|
+
// Self-contained on purpose: cck puts its source text into a sandboxed frame.
|
|
17
|
+
function comboOf(e) {
|
|
18
|
+
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
19
|
+
const m = !e.getModifierState?.('AltGraph') && /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
20
|
+
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
21
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
22
|
+
return [...mods, key].filter(Boolean).join('+');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The keys of a combo as a help row shows them: ['Ctrl', 'Alt', 'P'], or ['⌃', '⌥', 'P'] on macOS.
|
|
26
|
+
// The {n} of a numbered combo reads 1…9.
|
|
27
|
+
function keyParts(combo, mac) {
|
|
28
|
+
const mods = mac
|
|
29
|
+
? { ctrl: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' }
|
|
30
|
+
: { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Win' };
|
|
31
|
+
const named = { '{n}': '1…9', ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓' };
|
|
32
|
+
return combo.split('+').map((k) => mods[k] ?? named[k] ?? (k.length === 1 ? k.toUpperCase() : k));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { comboOf, keyParts };
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
if (typeof module === 'object' && module.exports) module.exports = ClaudeHubKeys;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
3
4
|
const path = require('node:path');
|
|
4
5
|
|
|
5
6
|
// The hub hands this file to each app it spawns (HUB_SDK_SERVER), so it runs the hub's version, not
|
|
@@ -39,14 +40,20 @@ function answerHub() {
|
|
|
39
40
|
process.channel.unref();
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// What an app page loads as /vendor/claude-hub-sdk.js: keys.js, then client.js or stub.js.
|
|
44
|
+
function bundle(file) {
|
|
45
|
+
const read = (f) => fs.readFileSync(path.join(__dirname, f), 'utf8');
|
|
46
|
+
return `${read('keys.js')}\n${read(file)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
// Mount before express.static: the hub's client must win over the app's stub in public/vendor.
|
|
43
50
|
function mount(app) {
|
|
44
51
|
answerHub();
|
|
45
|
-
const client =
|
|
46
|
-
app.get('/vendor/claude-hub-sdk.js', (_req, res) => res.
|
|
52
|
+
const client = bundle('client.js');
|
|
53
|
+
app.get('/vendor/claude-hub-sdk.js', (_req, res) => res.type('js').send(client));
|
|
47
54
|
app.get('/hub-config', (_req, res) => {
|
|
48
55
|
res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
|
|
49
56
|
});
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
module.exports = { mount };
|
|
59
|
+
module.exports = { mount, bundle };
|
|
@@ -3,14 +3,8 @@
|
|
|
3
3
|
((root) => {
|
|
4
4
|
'use strict';
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
9
|
-
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
10
|
-
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
11
|
-
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
12
|
-
return [...mods, key].filter(Boolean).join('+');
|
|
13
|
-
}
|
|
6
|
+
// keys.js comes first in the synced file. Required as a module, stub.js loads it itself.
|
|
7
|
+
const { comboOf } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
|
|
14
8
|
|
|
15
9
|
function createClaudeHub(win) {
|
|
16
10
|
let hub = null;
|
|
@@ -42,6 +36,7 @@
|
|
|
42
36
|
can: (action) => !!standaloneFn(action),
|
|
43
37
|
forwards: () => false,
|
|
44
38
|
forwardCombos: () => [],
|
|
39
|
+
keyLabel: () => null,
|
|
45
40
|
closeGuard() {},
|
|
46
41
|
openExternal(url) {
|
|
47
42
|
win.open(url, '_blank', 'noopener');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hub-builder
|
|
3
|
-
description: Build on and extend Claude Code Hub. Use when the user wants to patch or fork a built-in tool (Kanban, Cost, Marketplace, Memory), write a new app for a hub tab, connect apps through topics and actions, make a custom color theme, configure the hub (flags, config dirs, token), or fix a hub problem.
|
|
3
|
+
description: Build on and extend Claude Code Hub. Use when the user wants to patch or fork a built-in tool (Kanban, Cost, Marketplace, Memory), write a new app for a hub tab, connect apps through topics and actions, make a custom color theme, configure the hub (flags, config dirs, token, hub keyboard shortcuts), or fix a hub problem.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Hub builder
|
|
@@ -23,7 +23,8 @@ The files under `references/` ship with the installed hub, so they match its ver
|
|
|
23
23
|
| Flags, environment variables, `config.json`, the files the hub writes | `reference/configuration/` |
|
|
24
24
|
| Switch between Claude config dirs | `guides/config-dirs/` |
|
|
25
25
|
| The hub token, or exposing the hub to a network | `reference/security/` |
|
|
26
|
-
|
|
|
26
|
+
| Rebind or turn off a hub key (`keys` in `config.json`) | [`references/keys.md`](references/keys.md) |
|
|
27
|
+
| Look up a key: the hub's, a palette's, or a tool's own | `reference/shortcuts/` |
|
|
27
28
|
| The embedded terminal in Kanban | `guides/terminal/` |
|
|
28
29
|
| The project palette | `guides/projects/` |
|
|
29
30
|
| Something is broken | `reference/troubleshooting/` |
|
|
@@ -14,7 +14,7 @@ Start with a patch. Move to a fork when the patch grows or breaks on each upgrad
|
|
|
14
14
|
|
|
15
15
|
## The apps entry
|
|
16
16
|
|
|
17
|
-
`<hub-dir>/config.json` (`--hub-dir`, else `CLAUDE_HUB_DIR`, else `~/.claude-hub`) has `apps: [{id, path?, enabled?, port?}]`. Page: `extensibility/reference/apps-entry/`. The other keys of the file (`configDirs`, `activeConfigDir`, `terminal`) are in `reference/configuration/#the-config-file
|
|
17
|
+
`<hub-dir>/config.json` (`--hub-dir`, else `CLAUDE_HUB_DIR`, else `~/.claude-hub`) has `apps: [{id, path?, enabled?, port?}]`. Page: `extensibility/reference/apps-entry/`. The other keys of the file (`configDirs`, `activeConfigDir`, `terminal`, `keys`) are in `reference/configuration/#the-config-file`, and `keys` in [`keys.md`](keys.md); keep them as they are.
|
|
18
18
|
|
|
19
19
|
To configure an extension:
|
|
20
20
|
|
|
@@ -40,7 +40,7 @@ Rules:
|
|
|
40
40
|
|
|
41
41
|
- `path` is the folder with `hub-app.json`: absolute, or relative to the folder of `config.json`. The hub does not expand `~`.
|
|
42
42
|
- A built-in id (`kanban`, `marketplace`, `cost`, `memory`) with a `path` runs that folder in place of the tool, with no second entry. A new id needs a `path` and runs as an extra tab.
|
|
43
|
-
- Listed apps come first in list order, then the unlisted built-ins in default order. Tab order is also the `Alt+1`…`Alt+9` order.
|
|
43
|
+
- Listed apps come first in list order, then the unlisted built-ins in default order. Tab order is also the `Alt+1`…`Alt+9` order (`⌃⌥1`…`⌃⌥9` on macOS).
|
|
44
44
|
- `enabled: false` removes the app: no process, tab or port, and its actions and capabilities go to no one.
|
|
45
45
|
|
|
46
46
|
## The launch
|
|
@@ -68,7 +68,7 @@ The npm packages of the built-in tools do not ship `hub-app.json`. A patch or fo
|
|
|
68
68
|
|
|
69
69
|
The page loads `/vendor/claude-hub-sdk.js` as the first element in `<body>`, a classic script, and calls `ClaudeHub.connect()`. Under the hub, `mount()` serves the hub's client at that URL, so the app always runs the hub's SDK. Alone, the app serves its own copy of the stub (`packages/claude-hub-sdk/src/stub.js`, copied by `npm run sdk:sync -- <app folder>` in a hub clone), which has the same API and does nothing. Page: `extensibility/reference/sdk/`.
|
|
70
70
|
|
|
71
|
-
The SDK writes the hub's color variables (`--accent`, `--bg-surface`, …) on `document.body`, so the app's CSS reads them and follows every hub theme, the user's own themes too. `bindTheme` keeps light/dark and the color theme in step. `onActive` tells a hidden tab to pause polling. The SDK forwards the hub's keys by itself; an element that eats keys first, such as a terminal, asks `forwards(e)`.
|
|
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
73
|
## Connect to other tools
|
|
74
74
|
|
|
@@ -89,5 +89,5 @@ A replacement must keep what the original gave, or the other tools lose it: Kanb
|
|
|
89
89
|
An extension is done when:
|
|
90
90
|
|
|
91
91
|
- The hub startup log shows `<id> runs from <folder>` and no skip or conflict line for it.
|
|
92
|
-
- Its tab shows at the expected `Alt+N
|
|
92
|
+
- Its tab shows at the expected `Alt+N` (`⌃⌥N` on macOS), and what it publishes, handles or provides reaches the other tools.
|
|
93
93
|
- It still runs alone, with the stub.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Change the hub keys
|
|
2
|
+
|
|
3
|
+
The hub's own keys (switch tools, open the palettes) live in the `keys` block of `<hub-dir>/config.json` (`--hub-dir`, else `CLAUDE_HUB_DIR`, else `~/.claude-hub`). Each tool's own keys are outside it. Page: `reference/shortcuts/#change-the-keys`.
|
|
4
|
+
|
|
5
|
+
## Steps
|
|
6
|
+
|
|
7
|
+
1. Map each key the user asks for to an action in [Actions](#actions), and check its combo against [Rules](#rules). When a combo breaks a rule, tell the user why and offer one that passes.
|
|
8
|
+
2. Read `config.json` and merge the entries into its `keys` block. Every other field stays byte for byte: the hub owns `configDirs`, `activeConfigDir` and `apps`.
|
|
9
|
+
3. Restart the hub: it reads `keys` once, at startup. When the tray runs it, the user picks Quit in the tray menu and starts the hub again.
|
|
10
|
+
4. Read the startup output, or `<hub-dir>/hub.log` when the tray started the hub. The hub logs each entry it drops as `<config.json path>: keys "<action>": … It keeps its default`. The reason is one of: not a combo, not a combo that ends in `+{n}`, `<combo> is kept by <app>`, `<combo> is already set for "<action>"`. An id that is not an action logs `keys "<id>" is not a hub action, ignored`.
|
|
11
|
+
5. Read `GET http://localhost:<port>/api/config?token=<token>` (the token is in `<hub-dir>/token`).
|
|
12
|
+
|
|
13
|
+
Done when the `keys` of `/api/config` holds every entry the user asked for, `null` ones too, and the startup output has no `config.json: keys` line. Then tell the user where to see it: each tool's help (`?`) shows the new hub keys and hides a hub row with no key, and the app launcher shows the tool-number keys.
|
|
14
|
+
|
|
15
|
+
To go back to the defaults, remove the `keys` block and restart.
|
|
16
|
+
|
|
17
|
+
## Example
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
"keys": {
|
|
21
|
+
"hub.projectPicker": "ctrl+alt+o",
|
|
22
|
+
"hub.nextApp": "ctrl+alt+l",
|
|
23
|
+
"hub.prevApp": "ctrl+alt+h",
|
|
24
|
+
"hub.appByNumber": "ctrl+shift+{n}",
|
|
25
|
+
"hub.configDirPicker": null
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Ctrl+Alt+O opens the project palette, Ctrl+Alt+H and Ctrl+Alt+L move between tools, Ctrl+Shift+1 … 9 jumps to a tool by number, and the config-dir palette has no key. The app launcher keeps Ctrl+Alt+A.
|
|
30
|
+
|
|
31
|
+
## Actions
|
|
32
|
+
|
|
33
|
+
| Action | Default | Default on macOS |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `hub.projectPicker` | `ctrl+alt+p` | `ctrl+alt+p` |
|
|
36
|
+
| `hub.configDirPicker` | `ctrl+alt+w` | `ctrl+alt+w` |
|
|
37
|
+
| `hub.appLauncher` | `ctrl+alt+a` | `ctrl+alt+a` |
|
|
38
|
+
| `hub.prevApp` | `ctrl+alt+ArrowLeft` | `ctrl+alt+ArrowLeft` |
|
|
39
|
+
| `hub.nextApp` | `ctrl+alt+ArrowRight` | `ctrl+alt+ArrowRight` |
|
|
40
|
+
| `hub.appByNumber` | `alt+{n}` | `ctrl+alt+{n}` |
|
|
41
|
+
|
|
42
|
+
## Rules
|
|
43
|
+
|
|
44
|
+
- **Form.** One or more of `ctrl`, `alt`, `shift`, `meta`, then one key, joined by `+`. The key is a letter, a digit 1 to 9, or a `KeyboardEvent.key` name (`ArrowUp`, `F2`, `PageDown`). On macOS `alt` is Option and `meta` is Command. Modifiers and letters take any case and order (`"Alt+Ctrl+O"` reads as `ctrl+alt+o`); a key name keeps its case (`PageDown`).
|
|
45
|
+
- **`{n}`.** `hub.appByNumber` ends in `+{n}`, and the hub binds it for 1 to 9. It is the only action with `{n}`.
|
|
46
|
+
- **`null`** leaves the action with no key.
|
|
47
|
+
- **Taking a default.** A combo that is another action's default moves to the new action, and the other action has no key until it gets one: `"hub.nextApp": "ctrl+alt+a"` leaves the app launcher with no key. Say so to the user.
|
|
48
|
+
- **Kept keys.** An app keeps the combos in `keys.keeps` of its `hub-app.json`, and the hub refuses them. Kanban keeps `ctrl+alt+n`, `ctrl+alt+r` and `ctrl+alt+s`.
|
|
49
|
+
- **Tool keys.** A combo the hub binds never reaches the tool. Plain `ctrl+<key>` and `alt+<key>` combos belong to the tools and Claude Code's terminal (`ctrl+r`, `ctrl+c`, `alt+[`), so give the hub `ctrl+alt` or `ctrl+shift` combos.
|
|
50
|
+
- **AltGr.** On a layout with AltGr (Polish, German), a `ctrl+alt` letter that types a character stays text, and that hub key does nothing. Offer another letter or a `ctrl+shift` combo.
|
package/public/app.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
let apps = {};
|
|
2
|
+
// bindings() builds it from apps and userKeys; setApps clears it.
|
|
3
|
+
let keymap = null;
|
|
4
|
+
// config.json `keys`, {actionId: combo | null}, from /api/config.
|
|
5
|
+
let userKeys = {};
|
|
6
|
+
const { comboOf, keyParts } = ClaudeHubKeys;
|
|
2
7
|
let activeApp = null;
|
|
3
8
|
// The app launcher's cursor starts here, so Ctrl+Alt+A then Enter goes back to the last tab.
|
|
4
9
|
let previousApp = null;
|
|
@@ -11,6 +16,9 @@ const liveApps = new Map();
|
|
|
11
16
|
// Apps whose next document the hub loads from an action URL.
|
|
12
17
|
const linkLoads = new Set();
|
|
13
18
|
const HUB_PROTOCOLS = [1];
|
|
19
|
+
// Option+digit types a character on macOS (Option+5 is '[' on German layouts), so the tab numbers
|
|
20
|
+
// there are Control+Option+digit.
|
|
21
|
+
const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platform || '');
|
|
14
22
|
// {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
|
|
15
23
|
// reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
|
|
16
24
|
// global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
|
|
@@ -220,7 +228,8 @@ async function init() {
|
|
|
220
228
|
showLoading();
|
|
221
229
|
const res = await fetch('/api/config');
|
|
222
230
|
const config = await res.json();
|
|
223
|
-
|
|
231
|
+
userKeys = config.keys ?? {};
|
|
232
|
+
setApps(config.apps);
|
|
224
233
|
defaultConfigDir = config.defaultConfigDir ?? null;
|
|
225
234
|
activeConfigDir = config.activeConfigDir ?? null;
|
|
226
235
|
applyTitle(config.activeConfigDir);
|
|
@@ -471,6 +480,7 @@ function onHello(appId, data) {
|
|
|
471
480
|
type: 'hub:welcome',
|
|
472
481
|
protocol: Math.max(...common),
|
|
473
482
|
forward: Object.keys(bindings()),
|
|
483
|
+
keys: actionKeys(),
|
|
474
484
|
themes: hubThemes,
|
|
475
485
|
actions: Object.keys(actions),
|
|
476
486
|
});
|
|
@@ -597,40 +607,70 @@ function handleForwardedKey(d) {
|
|
|
597
607
|
bindings()[comboOf(e)]?.run();
|
|
598
608
|
}
|
|
599
609
|
|
|
600
|
-
// The
|
|
601
|
-
// inPalette: the binding still fires while the palette is open.
|
|
610
|
+
// The hub's actions and their default combos. hub.appByNumber is a pattern: {n} is the tab number,
|
|
611
|
+
// for the first nine. inPalette: the binding still fires while the palette is open. The ids are the
|
|
612
|
+
// keys of config.json `keys`, listed again in lib/keymap.js.
|
|
613
|
+
function hubActions() {
|
|
614
|
+
return [
|
|
615
|
+
{ id: 'hub.projectPicker', combo: 'ctrl+alt+p', run: () => togglePalette('project'), inPalette: true },
|
|
616
|
+
{ id: 'hub.configDirPicker', combo: 'ctrl+alt+w', run: () => togglePalette('configDir'), inPalette: true },
|
|
617
|
+
{ id: 'hub.appLauncher', combo: 'ctrl+alt+a', run: () => togglePalette('app'), inPalette: true },
|
|
618
|
+
{ id: 'hub.prevApp', combo: 'ctrl+alt+ArrowLeft', run: () => cycleTab(-1) },
|
|
619
|
+
{ id: 'hub.nextApp', combo: 'ctrl+alt+ArrowRight', run: () => cycleTab(1) },
|
|
620
|
+
{
|
|
621
|
+
id: 'hub.appByNumber',
|
|
622
|
+
combo: `${IS_MAC ? 'ctrl+alt' : 'alt'}+{n}`,
|
|
623
|
+
run: (i) => switchTab(Object.keys(apps)[i]),
|
|
624
|
+
count: 9,
|
|
625
|
+
},
|
|
626
|
+
];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// The one keymap, combo → binding. Its combos are what welcome.forward lists for the apps.
|
|
630
|
+
// Built on first use after setApps, so a key press only looks a combo up. userKeys comes checked
|
|
631
|
+
// from the server: null unbinds an action, and a combo the user sets is taken from the action
|
|
632
|
+
// that has it by default.
|
|
602
633
|
function bindings() {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
map[`alt+${i + 1}`] = { run: () => switchTab(id) };
|
|
634
|
+
if (keymap) return keymap;
|
|
635
|
+
const tabs = Object.keys(apps).length;
|
|
636
|
+
const own = (b) => Object.hasOwn(userKeys, b.id);
|
|
637
|
+
const expand = (action) => {
|
|
638
|
+
const combo = comboFor(action);
|
|
639
|
+
if (!combo) return [];
|
|
640
|
+
if (!action.count) return [[combo, { ...action, combo }]];
|
|
641
|
+
return Array.from({ length: Math.min(action.count, tabs) }, (_, i) => {
|
|
642
|
+
const c = combo.replace('{n}', i + 1);
|
|
643
|
+
return [c, { ...action, combo: c, index: i, run: () => action.run(i) }];
|
|
614
644
|
});
|
|
615
|
-
|
|
645
|
+
};
|
|
646
|
+
const entries = hubActions().flatMap(expand);
|
|
647
|
+
const userCombos = new Set(entries.filter(([, b]) => own(b)).map(([c]) => c));
|
|
648
|
+
keymap = Object.fromEntries(entries.filter(([c, b]) => own(b) || !userCombos.has(c)));
|
|
649
|
+
return keymap;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function comboFor(action) {
|
|
653
|
+
return Object.hasOwn(userKeys, action.id) ? userKeys[action.id] : action.combo;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Action id → its combo, hub.appByNumber as its {n} pattern, or null when no key runs it. What
|
|
657
|
+
// welcome.keys lists for the apps' help.
|
|
658
|
+
function actionKeys() {
|
|
659
|
+
const bound = new Set(Object.values(bindings()).map((b) => b.id));
|
|
660
|
+
return Object.fromEntries(hubActions().map((a) => [a.id, bound.has(a.id) ? comboFor(a) : null]));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function setApps(list) {
|
|
664
|
+
apps = list;
|
|
665
|
+
keymap = null;
|
|
616
666
|
}
|
|
617
667
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
function comboOf(e) {
|
|
621
|
-
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
622
|
-
return [...mods, bindingKey(e)].filter(Boolean).join('+');
|
|
668
|
+
function appNumberCombo(i) {
|
|
669
|
+
return Object.values(bindings()).find((b) => b.id === 'hub.appByNumber' && b.index === i)?.combo;
|
|
623
670
|
}
|
|
624
671
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
// physical key, which is wrong for non-US layouts, hence only as a fallback. Takes a real
|
|
628
|
-
// KeyboardEvent or a forwarded {key, code} payload; a payload without code degrades to key.
|
|
629
|
-
function bindingKey(e) {
|
|
630
|
-
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
631
|
-
if (/^[a-z1-9]$/.test(lower)) return lower;
|
|
632
|
-
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
633
|
-
return m ? m[1].toLowerCase() : e.key;
|
|
672
|
+
function comboLabel(combo) {
|
|
673
|
+
return keyParts(combo, IS_MAC).join(IS_MAC ? '' : '+');
|
|
634
674
|
}
|
|
635
675
|
|
|
636
676
|
function cycleTab(delta) {
|
|
@@ -904,6 +944,16 @@ async function restartFromPalette(id) {
|
|
|
904
944
|
switchTab(id);
|
|
905
945
|
}
|
|
906
946
|
|
|
947
|
+
async function copyAppUrl(id) {
|
|
948
|
+
const url = apps[id].url;
|
|
949
|
+
try {
|
|
950
|
+
await navigator.clipboard.writeText(url);
|
|
951
|
+
setPaletteHint(`Copied ${url}`);
|
|
952
|
+
} catch (err) {
|
|
953
|
+
setPaletteHint(`Could not copy ${url}: ${err.message}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
907
957
|
// `typed` is the trimmed query, `q` its lowercase form for matching.
|
|
908
958
|
const PALETTE_MODES = {
|
|
909
959
|
project: {
|
|
@@ -969,7 +1019,7 @@ const PALETTE_MODES = {
|
|
|
969
1019
|
app: {
|
|
970
1020
|
placeholder: 'Search apps...',
|
|
971
1021
|
label: 'Switch app',
|
|
972
|
-
hint: 'Enter switch · Ctrl+R restart the app',
|
|
1022
|
+
hint: 'Enter switch · Ctrl+C copy the URL · Ctrl+R restart the app',
|
|
973
1023
|
empty: 'No matching app',
|
|
974
1024
|
rows: (_typed, q) => appRows(q),
|
|
975
1025
|
load: loadAppStats,
|
|
@@ -978,7 +1028,7 @@ const PALETTE_MODES = {
|
|
|
978
1028
|
return prev < 0 ? 0 : prev;
|
|
979
1029
|
},
|
|
980
1030
|
fillRow(li, row, q) {
|
|
981
|
-
li.append(appIcon(row.app), markedSpan('name', row.app.name, q));
|
|
1031
|
+
li.append(appIcon(row.app), markedSpan('name', row.app.name, q), el('span', 'url', new URL(row.app.url).host));
|
|
982
1032
|
const s = palette.appStats?.[row.id];
|
|
983
1033
|
if (s?.slowMs) {
|
|
984
1034
|
const badge = el('span', 'slow', 'slow port');
|
|
@@ -988,11 +1038,15 @@ const PALETTE_MODES = {
|
|
|
988
1038
|
const stats = el('span', 'stats', s?.rss ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '');
|
|
989
1039
|
stats.title = 'Memory and CPU of the app process. Child processes are not counted. 100% is one core.';
|
|
990
1040
|
li.append(stats);
|
|
991
|
-
const
|
|
1041
|
+
const combo = appNumberCombo(row.index);
|
|
1042
|
+
const key = row.id === activeApp ? 'active' : combo ? comboLabel(combo) : '';
|
|
992
1043
|
li.append(el('span', 'age', key));
|
|
993
|
-
li.append(
|
|
1044
|
+
li.append(
|
|
1045
|
+
rowButton('palette-copy', '⧉', 'c', 'Copy the URL (Ctrl+C)', `Copy the URL of ${row.app.name}`),
|
|
1046
|
+
rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`),
|
|
1047
|
+
);
|
|
994
1048
|
},
|
|
995
|
-
rowKeys: { r: (row) => restartFromPalette(row.id) },
|
|
1049
|
+
rowKeys: { c: (row) => copyAppUrl(row.id), r: (row) => restartFromPalette(row.id) },
|
|
996
1050
|
commit(row) {
|
|
997
1051
|
closePalette();
|
|
998
1052
|
switchTab(row.id);
|
|
@@ -1143,7 +1197,7 @@ async function commitConfigDir(row) {
|
|
|
1143
1197
|
if (target === palette.configDirs.active) return;
|
|
1144
1198
|
showLoading();
|
|
1145
1199
|
try {
|
|
1146
|
-
|
|
1200
|
+
setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
|
|
1147
1201
|
// The palette renders from this cache before its refetch lands, and initialSel keys off it.
|
|
1148
1202
|
palette.configDirs.active = target;
|
|
1149
1203
|
activeConfigDir = target;
|
|
@@ -1190,11 +1244,13 @@ function bindPalette() {
|
|
|
1190
1244
|
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
|
1191
1245
|
e.preventDefault();
|
|
1192
1246
|
movePaletteSel(-1);
|
|
1193
|
-
} else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && ['d', 'r'].includes(e.key.toLowerCase())) {
|
|
1247
|
+
} else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && ['c', 'd', 'r'].includes(e.key.toLowerCase())) {
|
|
1248
|
+
const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
|
|
1249
|
+
// Ctrl+C with text selected in the input keeps the browser's copy.
|
|
1250
|
+
if (e.key.toLowerCase() === 'c' && (!run || input.selectionStart !== input.selectionEnd)) return;
|
|
1194
1251
|
// Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
|
|
1195
1252
|
// Ctrl+R would otherwise reload the hub page.
|
|
1196
1253
|
e.preventDefault();
|
|
1197
|
-
const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
|
|
1198
1254
|
const row = palette.rows[palette.sel];
|
|
1199
1255
|
if (run && row && row.kind !== 'literal') run(row);
|
|
1200
1256
|
}
|
package/public/index.html
CHANGED
|
@@ -272,6 +272,13 @@
|
|
|
272
272
|
|
|
273
273
|
.palette-row .stats + .age { margin-left: 0; }
|
|
274
274
|
|
|
275
|
+
.palette-row .url {
|
|
276
|
+
color: var(--text-dim);
|
|
277
|
+
font-family: ui-monospace, monospace;
|
|
278
|
+
font-size: 12px;
|
|
279
|
+
align-self: center;
|
|
280
|
+
}
|
|
281
|
+
|
|
275
282
|
.palette-row .slow {
|
|
276
283
|
margin-left: 10px;
|
|
277
284
|
padding: 0 6px;
|
|
@@ -307,6 +314,7 @@
|
|
|
307
314
|
.palette-row.selected .palette-icon { color: var(--accent); }
|
|
308
315
|
|
|
309
316
|
.palette-row .palette-remove,
|
|
317
|
+
.palette-row .palette-copy,
|
|
310
318
|
.palette-row .palette-restart {
|
|
311
319
|
margin-left: auto;
|
|
312
320
|
padding: 0 6px;
|
|
@@ -322,13 +330,17 @@
|
|
|
322
330
|
|
|
323
331
|
.palette-row:hover .palette-remove,
|
|
324
332
|
.palette-row.selected .palette-remove,
|
|
333
|
+
.palette-row:hover .palette-copy,
|
|
334
|
+
.palette-row.selected .palette-copy,
|
|
325
335
|
.palette-row:hover .palette-restart,
|
|
326
336
|
.palette-row.selected .palette-restart { opacity: 1; }
|
|
327
337
|
|
|
328
338
|
.palette-row .palette-remove:hover { color: #e5484d; }
|
|
329
339
|
|
|
330
|
-
.palette-row .age + .palette-
|
|
340
|
+
.palette-row .age + .palette-copy,
|
|
341
|
+
.palette-row .palette-copy + .palette-restart { margin-left: 0; }
|
|
331
342
|
|
|
343
|
+
.palette-row .palette-copy:hover,
|
|
332
344
|
.palette-row .palette-restart:hover { color: var(--accent); }
|
|
333
345
|
|
|
334
346
|
.palette-hint {
|
|
@@ -477,6 +489,7 @@
|
|
|
477
489
|
<button class="update-toast-close" id="error-close" type="button" aria-label="Close">×</button>
|
|
478
490
|
</div>
|
|
479
491
|
|
|
492
|
+
<script src="/sdk/keys.js"></script>
|
|
480
493
|
<script src="/app.js"></script>
|
|
481
494
|
</body>
|
|
482
495
|
</html>
|
package/public/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const CACHE_NAME = 'hub-shell-
|
|
2
|
-
const SHELL_ASSETS = ['/', '/index.html', '/app.js', '/manifest.json'];
|
|
1
|
+
const CACHE_NAME = 'hub-shell-v3';
|
|
2
|
+
const SHELL_ASSETS = ['/', '/index.html', '/sdk/keys.js', '/app.js', '/manifest.json'];
|
|
3
3
|
|
|
4
4
|
self.addEventListener('install', (e) => {
|
|
5
5
|
e.waitUntil(
|
package/server.js
CHANGED
|
@@ -10,6 +10,7 @@ const os = require('os');
|
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { actionTable } = require('./lib/actions');
|
|
12
12
|
const { selectApps, loadApp, conflicts } = require('./lib/apps');
|
|
13
|
+
const { userKeys } = require('./lib/keymap');
|
|
13
14
|
const { stripCookie } = require('./lib/cookies');
|
|
14
15
|
const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
|
|
15
16
|
const { ping, stats } = require('./lib/ipc');
|
|
@@ -131,6 +132,7 @@ function resolveHubConfig() {
|
|
|
131
132
|
// Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
|
|
132
133
|
if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
|
|
133
134
|
if (Array.isArray(saved.apps)) config.apps = saved.apps;
|
|
135
|
+
if (saved.keys !== undefined) config.keys = saved.keys;
|
|
134
136
|
return { config, raw, fallback };
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -164,6 +166,8 @@ if (!ENABLED_APPS.length) {
|
|
|
164
166
|
process.exit(1);
|
|
165
167
|
}
|
|
166
168
|
const ACTIONS = actionTable(ENABLED_APPS);
|
|
169
|
+
const { keys: USER_KEYS, lines: keyLines } = userKeys(hubConfig.keys, ENABLED_APPS);
|
|
170
|
+
for (const line of keyLines) console.log(`${HUB_CONFIG_FILE}: ${line}`);
|
|
167
171
|
// The first enabled app in tab order that declares the capability in its manifest.
|
|
168
172
|
const provider = (cap) => ENABLED_APPS.find((a) => a.provides[cap]);
|
|
169
173
|
const PROJECTS_APP = provider('projects');
|
|
@@ -886,6 +890,7 @@ app.get('/api/config', (_req, res) => {
|
|
|
886
890
|
...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE }),
|
|
887
891
|
apps: appsConfig(),
|
|
888
892
|
actions: ACTIONS,
|
|
893
|
+
keys: USER_KEYS,
|
|
889
894
|
activeConfigDir: hubConfig.activeConfigDir,
|
|
890
895
|
defaultConfigDir,
|
|
891
896
|
});
|
|
@@ -1083,6 +1088,8 @@ app.get('/api/resolve-path', (req, res) => {
|
|
|
1083
1088
|
else res.json({ path: r.path });
|
|
1084
1089
|
});
|
|
1085
1090
|
|
|
1091
|
+
const HUB_KEYS_JS = path.join(__dirname, 'packages', 'claude-hub-sdk', 'src', 'keys.js');
|
|
1092
|
+
app.get('/sdk/keys.js', (_req, res) => res.sendFile(HUB_KEYS_JS));
|
|
1086
1093
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
1087
1094
|
|
|
1088
1095
|
// Children get HUB_URL at spawn and trust only that origin, so the first pool waits for the port
|