claude-code-hub 1.34.0 → 1.35.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/keymap.js CHANGED
@@ -17,7 +17,7 @@ const PATTERN_ACTIONS = new Set(['hub.appByNumber']);
17
17
  const MODS = ['ctrl', 'alt', 'shift', 'meta'];
18
18
 
19
19
  // A combo name as the SDK's comboOf() gives it (protocol section 8), with one modifier at least.
20
- const COMBO_RE = /^(?=(?:ctrl|alt|shift|meta)\+)(?:ctrl\+)?(?:alt\+)?(?:shift\+)?(?:meta\+)?(?:[a-z1-9,]|[A-Z][A-Za-z0-9]+)$/;
20
+ const COMBO_RE = /^(?=(?:ctrl|alt|shift|meta)\+)(?:ctrl\+)?(?:alt\+)?(?:shift\+)?(?:meta\+)?(?:[a-z0-9,.;'`/\\=\-[\]]|[A-Z][A-Za-z0-9]+)$/;
21
21
 
22
22
  // "Ctrl+Alt+O" → "ctrl+alt+o", the form comboOf() gives. Null when the text cannot name a combo.
23
23
  function normalizeCombo(text) {
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { pluginChecker } = require('./plugin-status');
6
+
7
+ const ONBOARDING = ['done', 'skipped'];
8
+ const SETTLED = new Set(['ok', 'disabled']);
9
+
10
+ function canonical(p) {
11
+ try {
12
+ return fs.realpathSync.native(p);
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ // Claude config dirs in the home folder that the hub does not list yet: a `.claude*` folder with a
19
+ // settings.json or a projects folder, so a cache or a backup folder is left out.
20
+ function findConfigDirs({ home, known, hubDir }) {
21
+ let entries;
22
+ try {
23
+ entries = fs.readdirSync(home, { withFileTypes: true });
24
+ } catch {
25
+ return [];
26
+ }
27
+ const skip = new Set([...known, hubDir].map((d) => canonical(d) ?? d));
28
+ return entries
29
+ .filter((e) => e.isDirectory() && e.name.startsWith('.claude'))
30
+ .map((e) => path.join(home, e.name))
31
+ .filter((d) => fs.existsSync(path.join(d, 'settings.json')) || fs.existsSync(path.join(d, 'projects')))
32
+ .map((d) => canonical(d))
33
+ .filter((d) => d && !skip.has(d))
34
+ .sort();
35
+ }
36
+
37
+ // `plugins` is [{id, name, bundled, install, args}]: args are the hub command-line arguments that install it.
38
+ function dirPlugins(configDir, plugins) {
39
+ const check = pluginChecker(configDir);
40
+ return plugins.map((p) => {
41
+ const { state, installed, bundled } = check(p);
42
+ return { id: p.id, name: p.name, state, installed, bundled };
43
+ });
44
+ }
45
+
46
+ function pluginsToInstall(configDir, plugins) {
47
+ const check = pluginChecker(configDir);
48
+ return plugins.filter((p) => !SETTLED.has(check(p).state));
49
+ }
50
+
51
+ function setupStatus({ tray, configDirs, activeConfigDir, candidates, plugins }) {
52
+ const dirs = configDirs.map((d) => {
53
+ const list = dirPlugins(d, plugins);
54
+ return { path: d, active: d === activeConfigDir, plugins: list, pending: list.some((p) => !SETTLED.has(p.state)) };
55
+ });
56
+ return { tray, dirs, candidates, pluginsDone: dirs.every((d) => !d.pending) };
57
+ }
58
+
59
+ module.exports = { ONBOARDING, findConfigDirs, pluginsToInstall, setupStatus };
package/lib/settings.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { isObject, selectApps, validSettingValue } = require('./apps');
4
+ const { userKeys } = require('./keymap');
4
5
 
5
6
  const CONFIG_DIR_SWITCH = ['palette', 'swap'];
6
7
  const DEFAULT_POOL_SIZE = 3;
@@ -72,9 +73,15 @@ function withSettings(entry, schema, patch) {
72
73
  // `saved` is config.json as on disk. Returns the new config, or {error}. Only the keys the
73
74
  // patch names change; an app entry keeps its other fields (port, path, source). `schemas` maps an
74
75
  // app id to the settings list of its manifest.
75
- function applySettings(saved, patch, base, schemas = {}) {
76
+ function applySettings(saved, patch, base, schemas = {}, apps = []) {
76
77
  if (!patch || typeof patch !== 'object') return { error: 'body must be an object' };
77
78
  const next = { ...saved };
79
+ if (patch.keys !== undefined) {
80
+ const { keys, lines } = userKeys(patch.keys, apps);
81
+ if (lines.length) return { error: lines.join('; ') };
82
+ if (Object.keys(keys).length) next.keys = keys;
83
+ else delete next.keys;
84
+ }
78
85
  if (patch.configDirSwitch !== undefined) {
79
86
  if (!CONFIG_DIR_SWITCH.includes(patch.configDirSwitch))
80
87
  return { error: `configDirSwitch must be one of ${CONFIG_DIR_SWITCH.join(', ')}` };
package/lib/tray.js CHANGED
@@ -1,16 +1,19 @@
1
1
  'use strict';
2
2
 
3
- const { execFileSync, spawn } = require('node:child_process');
3
+ const { execFile, execFileSync, spawn } = require('node:child_process');
4
4
  const crypto = require('node:crypto');
5
5
  const fs = require('node:fs');
6
6
  const os = require('node:os');
7
7
  const path = require('node:path');
8
+ const { promisify } = require('node:util');
8
9
  const { version } = require('../package.json');
9
10
  const { liveHub } = require('./hub-record');
10
11
 
11
12
  const TRAY_SRC = path.join(__dirname, '..', 'tray');
12
13
  const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
13
14
  const LAUNCH_AGENTS = path.join(os.homedir(), 'Library', 'LaunchAgents');
15
+ // The hub page asks for the tray state, so the checks must not block the hub's event loop.
16
+ const run = promisify(execFile);
14
17
 
15
18
  function dirHash(hubDir) {
16
19
  return crypto.createHash('sha1').update(path.resolve(hubDir).toLowerCase()).digest('hex').slice(0, 8);
@@ -68,13 +71,13 @@ function windows(hubDir) {
68
71
  return {
69
72
  area: 'notification area',
70
73
  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
+ async trayAlive(pid) {
75
+ const { stdout } = await run('tasklist', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { windowsHide: true });
76
+ return stdout.toLowerCase().startsWith('"powershell.exe"');
74
77
  },
75
- autostartName() {
78
+ async autostartName() {
76
79
  try {
77
- execFileSync('reg', ['query', RUN_KEY, '/v', value], { stdio: 'ignore' });
80
+ await run('reg', ['query', RUN_KEY, '/v', value], { windowsHide: true });
78
81
  return value;
79
82
  } catch {
80
83
  return null;
@@ -106,14 +109,14 @@ function macos(hubDir, launchAgentsDir) {
106
109
  fs.writeFileSync(trayPlist, launchAgentPlist({ label, script: launcher }));
107
110
  return { launchAgent: plist, path: process.env.PATH };
108
111
  },
109
- trayAlive(pid) {
112
+ async trayAlive(pid) {
110
113
  try {
111
- return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).includes('hub-tray.js');
114
+ return (await run('ps', ['-p', String(pid), '-o', 'command='])).stdout.includes('hub-tray.js');
112
115
  } catch {
113
116
  return false;
114
117
  }
115
118
  },
116
- autostartName: () => (fs.existsSync(plist) ? label : null),
119
+ autostartName: async () => (fs.existsSync(plist) ? label : null),
117
120
  addAutostart() {
118
121
  fs.mkdirSync(launchAgentsDir, { recursive: true });
119
122
  fs.copyFileSync(trayPlist, plist);
@@ -144,12 +147,29 @@ function copyTray(plat, { hubDir, port, appId, defaultPort }) {
144
147
  return dest;
145
148
  }
146
149
 
147
- async function printStatus(plat, { hubDir, configDir }) {
148
- const autostart = plat.autostartName();
149
- let trayPid = null;
150
+ async function trayPid(plat, hubDir) {
151
+ let pid = null;
150
152
  try {
151
- trayPid = Number.parseInt(fs.readFileSync(path.join(hubDir, 'tray.pid'), 'utf8'), 10);
153
+ pid = Number.parseInt(fs.readFileSync(path.join(hubDir, 'tray.pid'), 'utf8'), 10);
152
154
  } catch {}
155
+ return Number.isInteger(pid) && (await plat.trayAlive(pid)) ? pid : null;
156
+ }
157
+
158
+ function platformOf(platform, hubDir, launchAgentsDir) {
159
+ if (platform === 'darwin') return macos(hubDir, launchAgentsDir);
160
+ return platform === 'win32' ? windows(hubDir) : null;
161
+ }
162
+
163
+ // {supported, autostart, running} for the hub page's setup checklist.
164
+ async function trayState({ hubDir, platform = process.platform, launchAgentsDir = LAUNCH_AGENTS }) {
165
+ const plat = platformOf(platform, hubDir, launchAgentsDir);
166
+ if (!plat) return { supported: false, autostart: false, running: false };
167
+ const [autostart, pid] = await Promise.all([plat.autostartName(), trayPid(plat, hubDir)]);
168
+ return { supported: true, autostart: !!autostart, running: pid !== null };
169
+ }
170
+
171
+ async function printStatus(plat, { hubDir, configDir }) {
172
+ const [autostart, pid] = await Promise.all([plat.autostartName(), trayPid(plat, hubDir)]);
153
173
  let hub = null;
154
174
  try {
155
175
  hub = await liveHub(hubDir, fs.readFileSync(path.join(hubDir, 'token'), 'utf8').trim());
@@ -157,16 +177,16 @@ async function printStatus(plat, { hubDir, configDir }) {
157
177
  console.log(` Hub dir: ${hubDir}`);
158
178
  console.log(` Config dir: ${configDir}`);
159
179
  console.log(` Autostart: ${autostart ? `on (${autostart})` : 'off'}`);
160
- console.log(` Tray: ${Number.isInteger(trayPid) && plat.trayAlive(trayPid) ? `running (pid ${trayPid})` : 'not running'}`);
180
+ console.log(` Tray: ${pid !== null ? `running (pid ${pid})` : 'not running'}`);
161
181
  console.log(` Hub: ${hub ? `running on port ${hub.port} (pid ${hub.pid})` : 'stopped'}`);
162
182
  }
163
183
 
164
184
  async function runTrayCommand({ flag, platform = process.platform, launchAgentsDir = LAUNCH_AGENTS, ...opts }) {
165
- if (platform !== 'win32' && platform !== 'darwin') {
185
+ const plat = platformOf(platform, opts.hubDir, launchAgentsDir);
186
+ if (!plat) {
166
187
  console.error(` ${flag} works only on Windows and macOS.`);
167
188
  return false;
168
189
  }
169
- const plat = platform === 'darwin' ? macos(opts.hubDir, launchAgentsDir) : windows(opts.hubDir);
170
190
  if (flag === '--tray-status') {
171
191
  await printStatus(plat, opts);
172
192
  return true;
@@ -185,4 +205,4 @@ async function runTrayCommand({ flag, platform = process.platform, launchAgentsD
185
205
  return true;
186
206
  }
187
207
 
188
- module.exports = { runTrayCommand, runValue, launchAgentLabel, launchAgentPlist, appConfig };
208
+ module.exports = { runTrayCommand, trayState, runValue, launchAgentLabel, launchAgentPlist, appConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.34.0",
3
+ "version": "1.35.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.6.1",
50
- "claude-code-kanban": "7.31.0",
51
- "claude-code-marketplace": "1.7.2",
52
- "claude-code-memory-explorer": "1.3.5",
49
+ "claude-code-cost": "1.6.2",
50
+ "claude-code-kanban": "7.32.0",
51
+ "claude-code-marketplace": "1.7.3",
52
+ "claude-code-memory-explorer": "1.3.6",
53
53
  "express": "^4.21.0",
54
54
  "open": "^10.1.0"
55
55
  },
@@ -111,6 +111,7 @@
111
111
  const tokenWaiters = new Set();
112
112
  const themesFns = new Set();
113
113
  const settingsFns = new Set();
114
+ const keysFns = new Set();
114
115
  const queued = [];
115
116
  // Topic → payload: the latest publish before welcome.
116
117
  const outbox = new Map();
@@ -253,6 +254,12 @@
253
254
  case 'hub:settings':
254
255
  if (welcome) setSettings(m.settings);
255
256
  return;
257
+ case 'hub:keys':
258
+ if (!welcome) return;
259
+ welcome.keys = comboMap(m.keys);
260
+ forward = strings(m.forward);
261
+ for (const fn of keysFns) fn();
262
+ return;
256
263
  }
257
264
  }
258
265
 
@@ -472,6 +479,12 @@
472
479
  forwards,
473
480
  // The combos forwards() matches, for a frame that tests keys with ClaudeHub.comboOf on its own.
474
481
  forwardCombos: () => (forward ? [...forward] : []),
482
+ // Calls fn after the user changes the hub's keys, so a page that copied forwardCombos() or
483
+ // shows keyLabel() can read them again.
484
+ onKeys(fn) {
485
+ keysFns.add(fn);
486
+ return () => keysFns.delete(fn);
487
+ },
475
488
  // The keys of a hub action for a help row, in this system's names. A list of actions shares one
476
489
  // row: ['Ctrl', 'Alt', '←/→']. [] when no key runs them; null before welcome, standalone, or
477
490
  // for an action the hub did not list.
@@ -9,19 +9,62 @@ var ClaudeHubKeys = (() => {
9
9
  // Modifiers in ctrl, alt, shift, meta order, joined by '+' to the key. macOS composes
10
10
  // Option+<key> into a character (Option+1 is '¡', Option+P is 'π') and holding Control does not
11
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.
12
+ // wrong for non-US layouts, hence only as a fallback. A letter or punctuation key falls back to its
13
+ // US character only when e.key is not ASCII (a non-Latin layout, or Option on macOS), so AZERTY's
14
+ // ',' on the M key stays ','. The digit row always falls back, so AZERTY's '&' key is 1, as Windows
15
+ // and VS Code treat it. No fallback under AltGraph: Chromium sets it when AltGr types a character, and that
16
+ // press is text. Takes a real KeyboardEvent or a forwarded {key, code} payload; a payload without
17
+ // code degrades to key. A payload needs no AltGraph: the SDK forwards only a press whose key
18
+ // already names a bound combo.
19
+ // Self-contained on purpose, the table too: cck puts its source text into a sandboxed frame.
20
+ // codeOf() lists the same keys the other way round.
17
21
  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 === ' ' ? 'Space' : e.key;
22
+ const raw = typeof e.key === 'string' ? e.key : '';
23
+ const lower = raw.toLowerCase();
24
+ const code = e.getModifierState?.('AltGraph') ? '' : e.code || '';
25
+ const latin = !/[^\x20-\x7e]/.test(raw);
26
+ const digit = /^Digit([0-9])$/.exec(code)?.[1];
27
+ const us =
28
+ !latin &&
29
+ (/^Key([A-Z])$/.exec(code)?.[1].toLowerCase() ||
30
+ {
31
+ Minus: '-',
32
+ Equal: '=',
33
+ BracketLeft: '[',
34
+ BracketRight: ']',
35
+ Backslash: '\\',
36
+ Semicolon: ';',
37
+ Quote: "'",
38
+ Backquote: '`',
39
+ Comma: ',',
40
+ Period: '.',
41
+ Slash: '/',
42
+ }[code]);
43
+ const key = /^[a-z0-9]$/.test(lower) ? lower : digit || us || (e.key === ' ' ? 'Space' : e.key);
21
44
  const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
22
45
  return [...mods, key].filter(Boolean).join('+');
23
46
  }
24
47
 
48
+ // The physical key (e.code) that types a combo's key on a US layout, or null for a named key.
49
+ function codeOf(key) {
50
+ if (/^[a-z]$/.test(key)) return `Key${key.toUpperCase()}`;
51
+ if (/^[0-9]$/.test(key)) return `Digit${key}`;
52
+ const codes = {
53
+ '-': 'Minus',
54
+ '=': 'Equal',
55
+ '[': 'BracketLeft',
56
+ ']': 'BracketRight',
57
+ '\\': 'Backslash',
58
+ ';': 'Semicolon',
59
+ "'": 'Quote',
60
+ '`': 'Backquote',
61
+ ',': 'Comma',
62
+ '.': 'Period',
63
+ '/': 'Slash',
64
+ };
65
+ return codes[key] ?? null;
66
+ }
67
+
25
68
  // The keys of a combo as a help row shows them: ['Ctrl', 'Alt', 'P'], or ['⌃', '⌥', 'P'] on macOS.
26
69
  // The {n} of a numbered combo reads 1…9.
27
70
  function keyParts(combo, mac) {
@@ -46,7 +89,7 @@ var ClaudeHubKeys = (() => {
46
89
  ].filter(Boolean);
47
90
  }
48
91
 
49
- return { comboOf, keyParts, keySigs };
92
+ return { comboOf, codeOf, keyParts, keySigs };
50
93
  })();
51
94
 
52
95
  if (typeof module === 'object' && module.exports) module.exports = ClaudeHubKeys;
@@ -39,6 +39,7 @@
39
39
  can: (action) => !!standaloneFn(action),
40
40
  forwards: () => false,
41
41
  forwardCombos: () => [],
42
+ onKeys: () => off,
42
43
  keyLabel: () => null,
43
44
  voiceCommands: () => [],
44
45
  closeGuard() {},
@@ -2,11 +2,13 @@
2
2
 
3
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
4
 
5
+ The user can change them without you: hub settings (`Ctrl+,`), **Keyboard** page, **Customize…**. The window applies a change at once, with no restart. Point the user there first. Edit the file only when the user asks you to.
6
+
5
7
  ## Steps
6
8
 
7
9
  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
10
  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.
11
+ 3. Restart the hub: it reads the file's `keys` once, at startup. When the tray runs it, the user picks Quit in the tray menu and starts the hub again.
10
12
  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
13
  5. Read `GET http://localhost:<port>/api/config?token=<token>` (the token is in `<hub-dir>/token`).
12
14
 
@@ -42,7 +44,7 @@ Ctrl+Alt+O opens the project palette, Ctrl+Alt+H and Ctrl+Alt+L move between too
42
44
 
43
45
  ## Rules
44
46
 
45
- - **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`).
47
+ - **Form.** One or more of `ctrl`, `alt`, `shift`, `meta`, then one key, joined by `+`. The key is a letter, a digit, one of `` , . ; ' ` / \ = - [ ] ``, 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`).
46
48
  - **`{n}`.** `hub.appByNumber` ends in `+{n}`, and the hub binds it for 1 to 9. It is the only action with `{n}`.
47
49
  - **`null`** leaves the action with no key.
48
50
  - **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.