claude-code-hub 1.5.1 → 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 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:
@@ -180,16 +182,17 @@ The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd
180
182
  --host <addr> Bind address (default: 127.0.0.1)
181
183
  --allowed-hosts <list> Extra host names to accept, comma-separated
182
184
  --open Open the browser on start
183
- --tray Windows: run the hub from a tray icon
184
- --autostart Windows: same as --tray, and start the tray at logon
185
- --no-autostart Windows: stop starting the tray at logon
186
- --tray-status Windows: show whether autostart is on and the tray and hub are running
187
- --app-id <id> Windows, with --tray or --autostart: the installed app that Open Hub starts
185
+ --detached Keep running when stdin closes (the macOS tray uses it)
186
+ --tray Windows, macOS: run the hub from a tray icon
187
+ --autostart Windows, macOS: same as --tray, and start the tray at logon
188
+ --no-autostart Windows, macOS: stop starting the tray at logon
189
+ --tray-status Windows, macOS: show whether autostart is on and the tray and hub are running
190
+ --app-id <id> Windows, macOS, with --tray or --autostart: the installed app that Open Hub starts
188
191
  ```
189
192
 
190
193
  If a port is busy, the hub uses a free port and reports it.
191
194
 
192
- ## Tray icon (Windows)
195
+ ## Tray icon (Windows, macOS)
193
196
 
194
197
  ```
195
198
  claude-code-hub --autostart
@@ -197,6 +200,8 @@ claude-code-hub --autostart
197
200
 
198
201
  The hub runs in the background with an icon in the notification area. Click the icon to open the hub. Right-click it to stop, start or restart the hub, open its log, turn **Start with Windows** on or off, or quit. The dot on the icon shows the state: green is running, amber is starting, red is stopped. If the hub stops unexpectedly, the tray shows a notice and turns red; start the hub again from the menu.
199
202
 
203
+ On macOS (experimental), the icon is in the menu bar.
204
+
200
205
  To turn off autostart, untick **Start with Windows** or run `claude-code-hub --no-autostart`. To check the state, run `claude-code-hub --tray-status`. See [Run the hub from the tray](https://nikiforovall.blog/claude-code-hub/guides/tray/).
201
206
 
202
207
  ## Included tools
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/lib/tray.js CHANGED
@@ -3,17 +3,55 @@
3
3
  const { execFileSync, spawn } = require('node:child_process');
4
4
  const crypto = require('node:crypto');
5
5
  const fs = require('node:fs');
6
+ const os = require('node:os');
6
7
  const path = require('node:path');
7
8
  const { version } = require('../package.json');
8
9
  const { liveHub } = require('./hub-record');
9
10
 
10
11
  const TRAY_SRC = path.join(__dirname, '..', 'tray');
11
12
  const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
13
+ const LAUNCH_AGENTS = path.join(os.homedir(), 'Library', 'LaunchAgents');
14
+
15
+ function dirHash(hubDir) {
16
+ return crypto.createHash('sha1').update(path.resolve(hubDir).toLowerCase()).digest('hex').slice(0, 8);
17
+ }
12
18
 
13
19
  // One Run value and one tray per hub dir, so a test hub never replaces the real autostart.
14
20
  function runValue(hubDir) {
15
- const hash = crypto.createHash('sha1').update(path.resolve(hubDir).toLowerCase()).digest('hex').slice(0, 8);
16
- return `ClaudeCodeHub-${hash}`;
21
+ return `ClaudeCodeHub-${dirHash(hubDir)}`;
22
+ }
23
+
24
+ function launchAgentLabel(hubDir) {
25
+ return `com.claude-code-hub.tray.${dirHash(hubDir)}`;
26
+ }
27
+
28
+ const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
29
+
30
+ // AbandonProcessGroup: launchd kills what is left in a job's process group when the job exits,
31
+ // and the hub that the tray started is in it.
32
+ function launchAgentPlist({ label, script }) {
33
+ return `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key>
38
+ <string>${xml(label)}</string>
39
+ <key>ProgramArguments</key>
40
+ <array>
41
+ <string>/usr/bin/osascript</string>
42
+ <string>-l</string>
43
+ <string>JavaScript</string>
44
+ <string>${xml(script)}</string>
45
+ </array>
46
+ <key>RunAtLoad</key>
47
+ <true/>
48
+ <key>ProcessType</key>
49
+ <string>Interactive</string>
50
+ <key>AbandonProcessGroup</key>
51
+ <true/>
52
+ </dict>
53
+ </plist>
54
+ `;
17
55
  }
18
56
 
19
57
  // An installed app is bound to the origin it was installed from, and its shortcut does not record
@@ -22,8 +60,75 @@ function appConfig({ appId, port, defaultPort }) {
22
60
  return appId ? { id: appId, port } : { name: 'Claude Code Hub', port: defaultPort };
23
61
  }
24
62
 
63
+ // A pid file outlives its process, and pids are reused, so the process must still be the tray.
64
+ // tray/mac/hub-tray.js makes the same check before it starts.
65
+ function windows(hubDir) {
66
+ const value = runValue(hubDir);
67
+ const launcher = path.join(hubDir, 'tray', 'launch.vbs');
68
+ return {
69
+ area: 'notification area',
70
+ prepare: () => ({ runValue: value }),
71
+ trayAlive(pid) {
72
+ const out = execFileSync('tasklist', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { encoding: 'utf8' });
73
+ return out.toLowerCase().startsWith('"powershell.exe"');
74
+ },
75
+ autostartName() {
76
+ try {
77
+ execFileSync('reg', ['query', RUN_KEY, '/v', value], { stdio: 'ignore' });
78
+ return value;
79
+ } catch {
80
+ return null;
81
+ }
82
+ },
83
+ addAutostart() {
84
+ execFileSync('reg', ['add', RUN_KEY, '/v', value, '/t', 'REG_SZ', '/d', `wscript.exe "${launcher}"`, '/f'], { stdio: 'ignore' });
85
+ },
86
+ removeAutostart() {
87
+ try {
88
+ execFileSync('reg', ['delete', RUN_KEY, '/v', value, '/f'], { stdio: 'ignore' });
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ },
94
+ start: () => spawn('wscript.exe', [launcher], { detached: true, stdio: 'ignore' }).unref(),
95
+ };
96
+ }
97
+
98
+ function macos(hubDir, launchAgentsDir) {
99
+ const label = launchAgentLabel(hubDir);
100
+ const plist = path.join(launchAgentsDir, `${label}.plist`);
101
+ const launcher = path.join(hubDir, 'tray', 'mac', 'hub-tray.js');
102
+ const trayPlist = path.join(hubDir, 'tray', 'mac', 'autostart.plist');
103
+ return {
104
+ area: 'menu bar',
105
+ prepare() {
106
+ fs.writeFileSync(trayPlist, launchAgentPlist({ label, script: launcher }));
107
+ return { launchAgent: plist, path: process.env.PATH };
108
+ },
109
+ trayAlive(pid) {
110
+ try {
111
+ return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).includes('hub-tray.js');
112
+ } catch {
113
+ return false;
114
+ }
115
+ },
116
+ autostartName: () => (fs.existsSync(plist) ? label : null),
117
+ addAutostart() {
118
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
119
+ fs.copyFileSync(trayPlist, plist);
120
+ },
121
+ removeAutostart() {
122
+ if (!fs.existsSync(plist)) return false;
123
+ fs.rmSync(plist);
124
+ return true;
125
+ },
126
+ start: () => spawn('/usr/bin/osascript', ['-l', 'JavaScript', launcher], { detached: true, stdio: 'ignore' }).unref(),
127
+ };
128
+ }
129
+
25
130
  // The Run key and the tray start from this copy, never from the package: an npx cache path can vanish.
26
- function copyTray({ hubDir, port, appId, defaultPort, value }) {
131
+ function copyTray(plat, { hubDir, port, appId, defaultPort }) {
27
132
  const dest = path.join(hubDir, 'tray');
28
133
  fs.rmSync(dest, { recursive: true, force: true });
29
134
  fs.cpSync(TRAY_SRC, dest, { recursive: true });
@@ -33,25 +138,14 @@ function copyTray({ hubDir, port, appId, defaultPort, value }) {
33
138
  version,
34
139
  serverJs: path.join(__dirname, '..', 'server.js'),
35
140
  node: process.execPath,
36
- runValue: value,
141
+ ...plat.prepare(),
37
142
  };
38
143
  fs.writeFileSync(path.join(dest, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
39
- return path.join(dest, 'launch.vbs');
144
+ return dest;
40
145
  }
41
146
 
42
- // A pid file outlives its process, and Windows reuses pids, so the image name must match too.
43
- function pidAlive(pid, image) {
44
- if (!Number.isInteger(pid)) return false;
45
- const out = execFileSync('tasklist', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { encoding: 'utf8' });
46
- return out.toLowerCase().startsWith(`"${image}"`);
47
- }
48
-
49
- async function printStatus({ hubDir, configDir, value }) {
50
- let autostart = 'off';
51
- try {
52
- execFileSync('reg', ['query', RUN_KEY, '/v', value], { stdio: 'ignore' });
53
- autostart = `on (${value})`;
54
- } catch {}
147
+ async function printStatus(plat, { hubDir, configDir }) {
148
+ const autostart = plat.autostartName();
55
149
  let trayPid = null;
56
150
  try {
57
151
  trayPid = Number.parseInt(fs.readFileSync(path.join(hubDir, 'tray.pid'), 'utf8'), 10);
@@ -62,39 +156,33 @@ async function printStatus({ hubDir, configDir, value }) {
62
156
  } catch {}
63
157
  console.log(` Hub dir: ${hubDir}`);
64
158
  console.log(` Config dir: ${configDir}`);
65
- console.log(` Autostart: ${autostart}`);
66
- console.log(` Tray: ${pidAlive(trayPid, 'powershell.exe') ? `running (pid ${trayPid})` : 'not running'}`);
159
+ console.log(` Autostart: ${autostart ? `on (${autostart})` : 'off'}`);
160
+ console.log(` Tray: ${Number.isInteger(trayPid) && plat.trayAlive(trayPid) ? `running (pid ${trayPid})` : 'not running'}`);
67
161
  console.log(` Hub: ${hub ? `running on port ${hub.port} (pid ${hub.pid})` : 'stopped'}`);
68
162
  }
69
163
 
70
- async function runTrayCommand({ flag, hubDir, port, defaultPort, appId, configDir, platform = process.platform }) {
71
- if (platform !== 'win32') {
72
- console.error(` ${flag} works only on Windows.`);
164
+ async function runTrayCommand({ flag, platform = process.platform, launchAgentsDir = LAUNCH_AGENTS, ...opts }) {
165
+ if (platform !== 'win32' && platform !== 'darwin') {
166
+ console.error(` ${flag} works only on Windows and macOS.`);
73
167
  return false;
74
168
  }
75
- const value = runValue(hubDir);
169
+ const plat = platform === 'darwin' ? macos(opts.hubDir, launchAgentsDir) : windows(opts.hubDir);
76
170
  if (flag === '--tray-status') {
77
- await printStatus({ hubDir, configDir, value });
171
+ await printStatus(plat, opts);
78
172
  return true;
79
173
  }
80
174
  if (flag === '--no-autostart') {
81
- try {
82
- execFileSync('reg', ['delete', RUN_KEY, '/v', value, '/f'], { stdio: 'ignore' });
83
- console.log(' Autostart: removed');
84
- } catch {
85
- console.log(' Autostart: not set');
86
- }
175
+ console.log(plat.removeAutostart() ? ' Autostart: removed' : ' Autostart: not set');
87
176
  return true;
88
177
  }
89
- const vbs = copyTray({ hubDir, port, appId, defaultPort, value });
90
- console.log(` Tray copy: ${path.dirname(vbs)}`);
178
+ console.log(` Tray copy: ${copyTray(plat, opts)}`);
91
179
  if (flag === '--autostart') {
92
- execFileSync('reg', ['add', RUN_KEY, '/v', value, '/t', 'REG_SZ', '/d', `wscript.exe "${vbs}"`, '/f'], { stdio: 'ignore' });
180
+ plat.addAutostart();
93
181
  console.log(' Autostart: on (starts at logon)');
94
182
  }
95
- spawn('wscript.exe', [vbs], { detached: true, stdio: 'ignore' }).unref();
96
- console.log(' Tray: started. Look for the hub icon in the notification area.');
183
+ plat.start();
184
+ console.log(` Tray: started. Look for the hub icon in the ${plat.area}.`);
97
185
  return true;
98
186
  }
99
187
 
100
- module.exports = { runTrayCommand, runValue, appConfig };
188
+ module.exports = { runTrayCommand, runValue, launchAgentLabel, launchAgentPlist, appConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.5.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.2.1",
49
- "claude-code-kanban": "7.1.2",
50
- "claude-code-marketplace": "1.3.1",
51
- "claude-code-memory-explorer": "1.1.2",
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
- // The hub's comboOf(). macOS turns Option+<key> into another character, so e.code is the fallback.
11
- function comboOf(e) {
12
- const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
13
- const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
14
- const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
15
- const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
16
- return [...mods, key].filter(Boolean).join('+');
17
- }
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 = { actions: strings(m.actions), themes: Array.isArray(m.themes) ? m.themes : [] };
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 = path.join(__dirname, 'client.js');
46
- app.get('/vendor/claude-hub-sdk.js', (_req, res) => res.sendFile(client));
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
- // A copy of client.js comboOf(); a test checks they match.
7
- function comboOf(e) {
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.0",
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
- | Keys the hub binds and keys the tools keep | `reference/shortcuts/` |
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`; keep them as they are.
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`, and what it publishes, handles or provides reaches the other tools.
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.