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 +11 -6
- package/lib/apps.js +14 -1
- package/lib/keymap.js +78 -0
- package/lib/tray.js +125 -37
- 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 +8 -1
- package/tray/mac/hub-tray.js +495 -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:
|
|
@@ -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
|
-
--
|
|
184
|
-
--
|
|
185
|
-
--
|
|
186
|
-
--
|
|
187
|
-
--
|
|
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
|
-
|
|
16
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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
|
-
|
|
141
|
+
...plat.prepare(),
|
|
37
142
|
};
|
|
38
143
|
fs.writeFileSync(path.join(dest, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
|
|
39
|
-
return
|
|
144
|
+
return dest;
|
|
40
145
|
}
|
|
41
146
|
|
|
42
|
-
|
|
43
|
-
|
|
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: ${
|
|
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,
|
|
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
|
|
169
|
+
const plat = platform === 'darwin' ? macos(opts.hubDir, launchAgentsDir) : windows(opts.hubDir);
|
|
76
170
|
if (flag === '--tray-status') {
|
|
77
|
-
await printStatus(
|
|
171
|
+
await printStatus(plat, opts);
|
|
78
172
|
return true;
|
|
79
173
|
}
|
|
80
174
|
if (flag === '--no-autostart') {
|
|
81
|
-
|
|
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
|
-
|
|
90
|
-
console.log(` Tray copy: ${path.dirname(vbs)}`);
|
|
178
|
+
console.log(` Tray copy: ${copyTray(plat, opts)}`);
|
|
91
179
|
if (flag === '--autostart') {
|
|
92
|
-
|
|
180
|
+
plat.addAutostart();
|
|
93
181
|
console.log(' Autostart: on (starts at logon)');
|
|
94
182
|
}
|
|
95
|
-
|
|
96
|
-
console.log(
|
|
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.
|
|
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.
|