claude-code-hub 1.2.1 → 1.3.1
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 +20 -2
- package/lib/hub-record.js +46 -0
- package/lib/tray.js +100 -0
- package/package.json +3 -2
- package/server.js +81 -30
- package/tray/hub-tray.ps1 +230 -0
- package/tray/icon.ps1 +49 -0
- package/tray/launch.vbs +4 -0
package/README.md
CHANGED
|
@@ -58,10 +58,13 @@ Explore the memory files that Claude Code loads for a project.
|
|
|
58
58
|
## Quick start
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
|
-
|
|
61
|
+
npm i -g claude-code-hub
|
|
62
|
+
claude-code-hub --open
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
The hub starts on `http://localhost:3540` and opens the browser. To get a window with no browser UI, install the page as an app from the browser menu.
|
|
65
|
+
The hub starts on `http://localhost:3540` and opens the browser. To get a window with no browser UI, install the page as an app from the browser menu. To update, run `npm i -g claude-code-hub@latest`.
|
|
66
|
+
|
|
67
|
+
To try the hub without installing it, run `npx claude-code-hub --open`. Each npx version runs from a different cache folder, so use a global install to keep the hub.
|
|
65
68
|
|
|
66
69
|
### From source
|
|
67
70
|
|
|
@@ -177,10 +180,25 @@ The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd
|
|
|
177
180
|
--host <addr> Bind address (default: 127.0.0.1)
|
|
178
181
|
--allowed-hosts <list> Extra host names to accept, comma-separated
|
|
179
182
|
--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
|
|
180
188
|
```
|
|
181
189
|
|
|
182
190
|
If a port is busy, the hub uses a free port and reports it.
|
|
183
191
|
|
|
192
|
+
## Tray icon (Windows)
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
claude-code-hub --autostart
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
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
|
+
|
|
200
|
+
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
|
+
|
|
184
202
|
## Included tools
|
|
185
203
|
|
|
186
204
|
| Tool | Docs | Repo | Submodule | Default port |
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function recordFile(hubDir) {
|
|
8
|
+
return path.join(hubDir, 'hub.json');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readRecord(hubDir) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(recordFile(hubDir), 'utf8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeRecord(hubDir, port) {
|
|
20
|
+
fs.writeFileSync(recordFile(hubDir), `${JSON.stringify({ pid: process.pid, port })}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function removeRecord(hubDir) {
|
|
24
|
+
if (readRecord(hubDir)?.pid === process.pid) fs.rmSync(recordFile(hubDir), { force: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A killed hub leaves its record behind, and its pid and port can be reused, so only a hub that
|
|
28
|
+
// accepts this hub dir's token counts as live. http.get with no agent, not fetch: the callers exit
|
|
29
|
+
// right after, and on Windows process.exit with an undici socket still closing trips a libuv assertion.
|
|
30
|
+
function liveHub(hubDir, token) {
|
|
31
|
+
const record = readRecord(hubDir);
|
|
32
|
+
if (!Number.isInteger(record?.port)) return Promise.resolve(null);
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const req = http.get(
|
|
35
|
+
{ host: '127.0.0.1', port: record.port, path: `/api/config?token=${token}`, agent: false, timeout: 2000 },
|
|
36
|
+
(res) => {
|
|
37
|
+
res.resume();
|
|
38
|
+
resolve(res.statusCode === 200 ? record : null);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
req.on('timeout', () => req.destroy());
|
|
42
|
+
req.on('error', () => resolve(null));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { writeRecord, removeRecord, liveHub };
|
package/lib/tray.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync, spawn } = require('node:child_process');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { version } = require('../package.json');
|
|
8
|
+
const { liveHub } = require('./hub-record');
|
|
9
|
+
|
|
10
|
+
const TRAY_SRC = path.join(__dirname, '..', 'tray');
|
|
11
|
+
const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
12
|
+
|
|
13
|
+
// One Run value and one tray per hub dir, so a test hub never replaces the real autostart.
|
|
14
|
+
function runValue(hubDir) {
|
|
15
|
+
const hash = crypto.createHash('sha1').update(path.resolve(hubDir).toLowerCase()).digest('hex').slice(0, 8);
|
|
16
|
+
return `ClaudeCodeHub-${hash}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// An installed app is bound to the origin it was installed from, and its shortcut does not record
|
|
20
|
+
// it, so the tray opens the app only when the hub runs on the port the app is assumed to serve.
|
|
21
|
+
function appConfig({ appId, port, defaultPort }) {
|
|
22
|
+
return appId ? { id: appId, port } : { name: 'Claude Code Hub', port: defaultPort };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 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 }) {
|
|
27
|
+
const dest = path.join(hubDir, 'tray');
|
|
28
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
29
|
+
fs.cpSync(TRAY_SRC, dest, { recursive: true });
|
|
30
|
+
const config = {
|
|
31
|
+
port,
|
|
32
|
+
app: appConfig({ appId, port, defaultPort }),
|
|
33
|
+
version,
|
|
34
|
+
serverJs: path.join(__dirname, '..', 'server.js'),
|
|
35
|
+
node: process.execPath,
|
|
36
|
+
runValue: value,
|
|
37
|
+
};
|
|
38
|
+
fs.writeFileSync(path.join(dest, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
|
|
39
|
+
return path.join(dest, 'launch.vbs');
|
|
40
|
+
}
|
|
41
|
+
|
|
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 {}
|
|
55
|
+
let trayPid = null;
|
|
56
|
+
try {
|
|
57
|
+
trayPid = Number.parseInt(fs.readFileSync(path.join(hubDir, 'tray.pid'), 'utf8'), 10);
|
|
58
|
+
} catch {}
|
|
59
|
+
let hub = null;
|
|
60
|
+
try {
|
|
61
|
+
hub = await liveHub(hubDir, fs.readFileSync(path.join(hubDir, 'token'), 'utf8').trim());
|
|
62
|
+
} catch {}
|
|
63
|
+
console.log(` Hub dir: ${hubDir}`);
|
|
64
|
+
console.log(` Config dir: ${configDir}`);
|
|
65
|
+
console.log(` Autostart: ${autostart}`);
|
|
66
|
+
console.log(` Tray: ${pidAlive(trayPid, 'powershell.exe') ? `running (pid ${trayPid})` : 'not running'}`);
|
|
67
|
+
console.log(` Hub: ${hub ? `running on port ${hub.port} (pid ${hub.pid})` : 'stopped'}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
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.`);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const value = runValue(hubDir);
|
|
76
|
+
if (flag === '--tray-status') {
|
|
77
|
+
await printStatus({ hubDir, configDir, value });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
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
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
const vbs = copyTray({ hubDir, port, appId, defaultPort, value });
|
|
90
|
+
console.log(` Tray copy: ${path.dirname(vbs)}`);
|
|
91
|
+
if (flag === '--autostart') {
|
|
92
|
+
execFileSync('reg', ['add', RUN_KEY, '/v', value, '/t', 'REG_SZ', '/d', `wscript.exe "${vbs}"`, '/f'], { stdio: 'ignore' });
|
|
93
|
+
console.log(' Autostart: on (starts at logon)');
|
|
94
|
+
}
|
|
95
|
+
spawn('wscript.exe', [vbs], { detached: true, stdio: 'ignore' }).unref();
|
|
96
|
+
console.log(' Tray: started. Look for the hub icon in the notification area.');
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { runTrayCommand, runValue, appConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"claude-code-cost": "1.2.0",
|
|
49
|
-
"claude-code-kanban": "
|
|
49
|
+
"claude-code-kanban": "6.1.1",
|
|
50
50
|
"claude-code-marketplace": "1.3.0",
|
|
51
51
|
"claude-code-memory-explorer": "1.1.1",
|
|
52
52
|
"express": "^4.21.0",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"public/**/*",
|
|
61
61
|
"lib/**/*",
|
|
62
62
|
"plugin/**/*",
|
|
63
|
+
"tray/**/*",
|
|
63
64
|
"packages/claude-hub-sdk/src/**/*"
|
|
64
65
|
],
|
|
65
66
|
"devDependencies": {
|
package/server.js
CHANGED
|
@@ -16,6 +16,7 @@ const { ping, stats } = require('./lib/ipc');
|
|
|
16
16
|
const { killChild } = require('./lib/kill-child');
|
|
17
17
|
const { readRegistry, themeConfig } = require('./lib/themes');
|
|
18
18
|
const { createUpdateCheck } = require('./lib/update-check');
|
|
19
|
+
const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
|
|
19
20
|
const { version: HUB_VERSION } = require('./package.json');
|
|
20
21
|
|
|
21
22
|
function getArg(name) {
|
|
@@ -26,7 +27,8 @@ function getArg(name) {
|
|
|
26
27
|
return process.argv[idx + 1] || null;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
const DEFAULT_HUB_PORT = 3540;
|
|
31
|
+
const HUB_PORT = parseInt(getArg('port') || process.env.PORT || DEFAULT_HUB_PORT, 10);
|
|
30
32
|
let hubPort = HUB_PORT;
|
|
31
33
|
|
|
32
34
|
const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
|
|
@@ -51,6 +53,19 @@ function activePool() {
|
|
|
51
53
|
const HUB_DIR = path.resolve(expandHome(getArg('hub-dir') || process.env.CLAUDE_HUB_DIR || '~/.claude-hub'));
|
|
52
54
|
const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
|
|
53
55
|
|
|
56
|
+
const TRAY_FLAG = ['--tray', '--autostart', '--no-autostart', '--tray-status'].find((f) => process.argv.includes(f));
|
|
57
|
+
if (TRAY_FLAG) {
|
|
58
|
+
const { runTrayCommand } = require('./lib/tray');
|
|
59
|
+
runTrayCommand({
|
|
60
|
+
flag: TRAY_FLAG,
|
|
61
|
+
hubDir: HUB_DIR,
|
|
62
|
+
port: HUB_PORT,
|
|
63
|
+
defaultPort: DEFAULT_HUB_PORT,
|
|
64
|
+
appId: getArg('app-id'),
|
|
65
|
+
configDir: resolveHubConfig().config.activeConfigDir,
|
|
66
|
+
}).then((ok) => process.exit(ok ? 0 : 1));
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
if (process.argv.includes('--install') || process.argv.includes('--uninstall')) {
|
|
55
70
|
const { runInstall, runUninstall } = require('./lib/install');
|
|
56
71
|
const dir = getArg('dir') || process.env.CLAUDE_CONFIG_DIR || '~/.claude';
|
|
@@ -82,6 +97,14 @@ function canonicalDir(p) {
|
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
function loadHubConfig() {
|
|
100
|
+
const { config, raw, fallback } = resolveHubConfig();
|
|
101
|
+
defaultConfigDir = fallback;
|
|
102
|
+
// Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
|
|
103
|
+
if (serializeConfig(config) !== raw) writeHubConfig(config);
|
|
104
|
+
return config;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveHubConfig() {
|
|
85
108
|
const canonical = (p) => {
|
|
86
109
|
try {
|
|
87
110
|
return canonicalDir(p);
|
|
@@ -92,7 +115,6 @@ function loadHubConfig() {
|
|
|
92
115
|
const fallback = canonical(
|
|
93
116
|
process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude'),
|
|
94
117
|
);
|
|
95
|
-
defaultConfigDir = fallback;
|
|
96
118
|
let raw = '';
|
|
97
119
|
let saved = {};
|
|
98
120
|
try {
|
|
@@ -108,9 +130,7 @@ function loadHubConfig() {
|
|
|
108
130
|
// Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
|
|
109
131
|
if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
|
|
110
132
|
if (Array.isArray(saved.apps)) config.apps = saved.apps;
|
|
111
|
-
|
|
112
|
-
if (serializeConfig(config) !== raw) writeHubConfig(config);
|
|
113
|
-
return config;
|
|
133
|
+
return { config, raw, fallback };
|
|
114
134
|
}
|
|
115
135
|
|
|
116
136
|
function serializeConfig(config) {
|
|
@@ -398,20 +418,24 @@ process.on('SIGINT', shutdown);
|
|
|
398
418
|
process.on('SIGTERM', shutdown);
|
|
399
419
|
process.on('SIGHUP', shutdown);
|
|
400
420
|
process.on('exit', killAll);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
421
|
+
process.on('exit', () => removeRecord(HUB_DIR));
|
|
422
|
+
|
|
423
|
+
// Git Bash + tmux on Windows doesn't deliver signals — read stdin directly. Only a hub that starts
|
|
424
|
+
// watches it: a closed stdin would end a CLI command or the running-hub check halfway.
|
|
425
|
+
function watchStdin() {
|
|
426
|
+
process.stdin.setEncoding('utf8');
|
|
427
|
+
process.stdin.resume();
|
|
428
|
+
process.stdin.on('data', (data) => {
|
|
429
|
+
const d = data.trim().toLowerCase();
|
|
430
|
+
// Ctrl+C (0x03), Ctrl+D (0x04), or typed "q"/"exit"
|
|
431
|
+
if (data.includes('\x03') || data.includes('\x04') || d === 'q' || d === 'exit') {
|
|
432
|
+
console.log('\nShutting down...');
|
|
433
|
+
shutdown();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
process.stdin.on('end', shutdown);
|
|
437
|
+
process.stdin.on('close', shutdown);
|
|
438
|
+
}
|
|
415
439
|
|
|
416
440
|
// Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
|
|
417
441
|
// trip Node's default 16KB limit, breaking iframes with HTTP 431.
|
|
@@ -798,16 +822,18 @@ function listenWithFallback(handler, port, onReady, label, onUpgrade) {
|
|
|
798
822
|
return server;
|
|
799
823
|
}
|
|
800
824
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
825
|
+
function listenProxies() {
|
|
826
|
+
for (const [name, port] of Object.entries(publicPorts)) {
|
|
827
|
+
listenWithFallback(
|
|
828
|
+
proxyHandler(name),
|
|
829
|
+
port,
|
|
830
|
+
(actual) => {
|
|
831
|
+
publicPorts[name] = actual;
|
|
832
|
+
},
|
|
833
|
+
`[${name}] `,
|
|
834
|
+
proxyUpgrade(name),
|
|
835
|
+
);
|
|
836
|
+
}
|
|
811
837
|
}
|
|
812
838
|
|
|
813
839
|
const app = express();
|
|
@@ -1000,6 +1026,13 @@ app.get('/api/projects', async (_req, res) => {
|
|
|
1000
1026
|
}
|
|
1001
1027
|
});
|
|
1002
1028
|
|
|
1029
|
+
// The tray stops the hub through this rather than taskkill, so the children and hub.json are cleaned up.
|
|
1030
|
+
app.post('/api/shutdown', (_req, res) => {
|
|
1031
|
+
console.log('Shutdown asked for over the API');
|
|
1032
|
+
res.on('finish', shutdown);
|
|
1033
|
+
res.json({ ok: true });
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1003
1036
|
// List-picked project paths skip this: they are byte-exact copies of what kanban reported.
|
|
1004
1037
|
app.get('/api/resolve-path', (req, res) => {
|
|
1005
1038
|
res.setHeader('Cache-Control', 'no-store');
|
|
@@ -1014,6 +1047,7 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
1014
1047
|
// the hub actually bound: HUB_PORT may be busy and fall back to a random one.
|
|
1015
1048
|
const onReady = (actual) => {
|
|
1016
1049
|
hubPort = actual;
|
|
1050
|
+
writeRecord(HUB_DIR, actual);
|
|
1017
1051
|
ensurePool(hubConfig.activeConfigDir);
|
|
1018
1052
|
updateCheck.refresh();
|
|
1019
1053
|
printBanner(actual);
|
|
@@ -1022,7 +1056,24 @@ const onReady = (actual) => {
|
|
|
1022
1056
|
}
|
|
1023
1057
|
};
|
|
1024
1058
|
|
|
1025
|
-
|
|
1059
|
+
// One hub per hub dir: a second one would overwrite hub.json, and the first would run unseen.
|
|
1060
|
+
// A tray flag's command is still running here and exits on its own.
|
|
1061
|
+
if (!TRAY_FLAG) {
|
|
1062
|
+
liveHub(HUB_DIR, HUB_TOKEN).then((running) => {
|
|
1063
|
+
if (!running) {
|
|
1064
|
+
watchStdin();
|
|
1065
|
+
listenProxies();
|
|
1066
|
+
listenWithFallback(app, HUB_PORT, onReady, '');
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
console.log(`Claude Code Hub already runs for ${HUB_DIR} at ${accessUrl(running.port)}`);
|
|
1070
|
+
if (process.argv.includes('--open')) {
|
|
1071
|
+
import('open').then((m) => m.default(accessUrl(running.port))).finally(() => process.exit(0));
|
|
1072
|
+
} else {
|
|
1073
|
+
process.exit(0);
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1026
1077
|
|
|
1027
1078
|
function accessUrl(port) {
|
|
1028
1079
|
return `http://localhost:${port}/?token=${HUB_TOKEN}`;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
|
2
|
+
# DPI-aware, so the icon is drawn at the tray's real size instead of 16 px scaled up.
|
|
3
|
+
Add-Type -Namespace HubTray -Name Native -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();'
|
|
4
|
+
[void][HubTray.Native]::SetProcessDPIAware()
|
|
5
|
+
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
6
|
+
. (Join-Path $PSScriptRoot 'icon.ps1')
|
|
7
|
+
|
|
8
|
+
$TrayDir = $PSScriptRoot
|
|
9
|
+
$ConfigFile = Join-Path $TrayDir 'config.json'
|
|
10
|
+
if (-not (Test-Path $ConfigFile)) {
|
|
11
|
+
[System.Windows.Forms.MessageBox]::Show("Missing $ConfigFile. Run: claude-code-hub --tray", 'Claude Code Hub') | Out-Null
|
|
12
|
+
exit 1
|
|
13
|
+
}
|
|
14
|
+
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
|
|
15
|
+
|
|
16
|
+
# createdNew, not WaitOne: a killed tray leaves the mutex abandoned, and WaitOne then throws instead of returning false.
|
|
17
|
+
$createdNew = $false
|
|
18
|
+
$mutex = New-Object System.Threading.Mutex($false, "Local\$($config.runValue)", [ref]$createdNew)
|
|
19
|
+
if (-not $createdNew) { exit }
|
|
20
|
+
|
|
21
|
+
$HubDir = Split-Path $TrayDir
|
|
22
|
+
$RunKey = 'Registry::HKCU\Software\Microsoft\Windows\CurrentVersion\Run'
|
|
23
|
+
$TrayPidFile = Join-Path $HubDir 'tray.pid'
|
|
24
|
+
Set-Content -Path $TrayPidFile -Value $PID
|
|
25
|
+
$HubFile = Join-Path $HubDir 'hub.json'
|
|
26
|
+
$LogFile = Join-Path $HubDir 'hub.log'
|
|
27
|
+
$TrayLog = Join-Path $HubDir 'tray.log'
|
|
28
|
+
|
|
29
|
+
$script:hubProc = $null
|
|
30
|
+
$script:hub = $null
|
|
31
|
+
$script:state = ''
|
|
32
|
+
$script:appLnk = $null
|
|
33
|
+
|
|
34
|
+
function Write-TrayLog($msg) {
|
|
35
|
+
Add-Content -Path $TrayLog -Value "$(Get-Date -Format s) $msg"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# The hub writes hub.json once it listens; a dead pid there is a hub that is gone. Get-Process still
|
|
39
|
+
# returns a killed process while any handle to it is open, so HasExited is checked too.
|
|
40
|
+
function Get-Hub {
|
|
41
|
+
if (-not (Test-Path $HubFile)) { return $null }
|
|
42
|
+
$hub = Get-Content $HubFile -Raw | ConvertFrom-Json
|
|
43
|
+
$proc = Get-Process -Id $hub.pid -ErrorAction SilentlyContinue
|
|
44
|
+
if ($proc -and $proc.ProcessName -eq 'node' -and -not $proc.HasExited) { return $hub }
|
|
45
|
+
return $null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# The saved node comes first: the app packages were installed with it, and a version manager may
|
|
49
|
+
# have switched the one on PATH since.
|
|
50
|
+
function Get-HubCommand {
|
|
51
|
+
$hubArgs = "--hub-dir `"$HubDir`" --port $($config.port)"
|
|
52
|
+
$node = if (Test-Path $config.node) { $config.node } else { (Get-Command node.exe -ErrorAction SilentlyContinue | Select-Object -First 1).Source }
|
|
53
|
+
if ($node -and (Test-Path $config.serverJs)) {
|
|
54
|
+
return "`"$node`" `"$($config.serverJs)`" $hubArgs"
|
|
55
|
+
}
|
|
56
|
+
$tray.ShowBalloonTip(5000, 'Claude Code Hub', "The installed hub is gone. Starting $($config.version) with npx.", 'Warning')
|
|
57
|
+
return "npx -y claude-code-hub@$($config.version) $hubArgs"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Start-Hub {
|
|
61
|
+
if (-not (Get-Hub)) {
|
|
62
|
+
if ((Test-Path $LogFile) -and (Get-Item $LogFile).Length -gt 5MB) { Move-Item $LogFile "$LogFile.1" -Force }
|
|
63
|
+
$cmd = Get-HubCommand
|
|
64
|
+
Write-TrayLog "start: $cmd"
|
|
65
|
+
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
66
|
+
$psi.FileName = $env:ComSpec
|
|
67
|
+
$psi.Arguments = "/d /s /c `"$cmd >> `"$LogFile`" 2>&1`""
|
|
68
|
+
$psi.UseShellExecute = $false
|
|
69
|
+
$psi.CreateNoWindow = $true
|
|
70
|
+
$psi.WorkingDirectory = $HubDir
|
|
71
|
+
$script:hubProc = [System.Diagnostics.Process]::Start($psi)
|
|
72
|
+
}
|
|
73
|
+
Update-State
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Get-Token {
|
|
77
|
+
(Get-Content (Join-Path $HubDir 'token') -Raw).Trim()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# The shutdown route lets the hub stop its tools and remove hub.json; taskkill is the fallback for a
|
|
81
|
+
# hub that does not answer. Killing the node tree also ends the cmd that the tray started.
|
|
82
|
+
function Stop-Hub {
|
|
83
|
+
$hub = Get-Hub
|
|
84
|
+
if ($hub) {
|
|
85
|
+
Write-TrayLog "stop: pid $($hub.pid)"
|
|
86
|
+
try {
|
|
87
|
+
Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($hub.port)/api/shutdown?token=$(Get-Token)" -TimeoutSec 3 | Out-Null
|
|
88
|
+
Wait-Process -Id $hub.pid -Timeout 15 -ErrorAction SilentlyContinue
|
|
89
|
+
} catch {
|
|
90
|
+
Write-TrayLog "shutdown request failed: $_"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
$id = if (Get-Hub) { $hub.pid } elseif ($script:hubProc -and -not $script:hubProc.HasExited) { $script:hubProc.Id }
|
|
94
|
+
if ($id) {
|
|
95
|
+
Write-TrayLog "kill: pid $id"
|
|
96
|
+
& taskkill.exe /f /t /pid $id 2>&1 | Out-Null
|
|
97
|
+
}
|
|
98
|
+
if ($script:hubProc) {
|
|
99
|
+
[void]$script:hubProc.WaitForExit(5000)
|
|
100
|
+
$script:hubProc.Dispose()
|
|
101
|
+
$script:hubProc = $null
|
|
102
|
+
}
|
|
103
|
+
if ($hub) { Wait-Process -Id $hub.pid -Timeout 5 -ErrorAction SilentlyContinue }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
$Shell = New-Object -ComObject WScript.Shell
|
|
107
|
+
|
|
108
|
+
# Chrome and Edge put an installed app's shortcut in the Start Menu, with the app id in its arguments.
|
|
109
|
+
function Find-AppShortcut {
|
|
110
|
+
if ($script:appLnk -and (Test-Path $script:appLnk.FullName)) { return $script:appLnk }
|
|
111
|
+
$script:appLnk = $null
|
|
112
|
+
$filter = if ($config.app.id) { '*.lnk' } else { "$($config.app.name).lnk" }
|
|
113
|
+
foreach ($file in Get-ChildItem "$env:APPDATA\Microsoft\Windows\Start Menu\Programs" -Recurse -Filter $filter -ErrorAction SilentlyContinue) {
|
|
114
|
+
$lnk = $Shell.CreateShortcut($file.FullName)
|
|
115
|
+
if ($lnk.Arguments -notmatch '--app-id=(\w+)') { continue }
|
|
116
|
+
if (-not $config.app.id -or $Matches[1] -eq $config.app.id) { $script:appLnk = $lnk; return $lnk }
|
|
117
|
+
}
|
|
118
|
+
return $null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Chrome loads any URL into an app, so a hub on another port must not open in it.
|
|
122
|
+
function Open-Hub {
|
|
123
|
+
$hub = Get-Hub
|
|
124
|
+
if (-not $hub) { return }
|
|
125
|
+
$url = "http://localhost:$($hub.port)/?token=$(Get-Token)"
|
|
126
|
+
$app = if ($hub.port -eq $config.app.port) { Find-AppShortcut }
|
|
127
|
+
if ($app) {
|
|
128
|
+
Start-Process $app.TargetPath "$($app.Arguments) --app-launch-url-for-shortcuts-menu-item=`"$url`""
|
|
129
|
+
} else {
|
|
130
|
+
Start-Process $url
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function Get-Autostart {
|
|
135
|
+
$null -ne (Get-ItemProperty $RunKey -Name $config.runValue -ErrorAction SilentlyContinue)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
$icons = @{}
|
|
139
|
+
$iconSize = [System.Windows.Forms.SystemInformation]::SmallIconSize.Width
|
|
140
|
+
foreach ($s in $StateColors.Keys) {
|
|
141
|
+
$bmp = New-TrayBitmap $iconSize $StateColors[$s]
|
|
142
|
+
$icons[$s] = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
|
|
143
|
+
$bmp.Dispose()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Get-ConfigDirName {
|
|
147
|
+
try {
|
|
148
|
+
$dir = (Get-Content (Join-Path $HubDir 'config.json') -Raw | ConvertFrom-Json).activeConfigDir
|
|
149
|
+
if ($dir) { return Split-Path $dir -Leaf }
|
|
150
|
+
} catch {}
|
|
151
|
+
return $null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# The classic ContextMenu is a native Win32 menu, so it takes the Windows 11 menu style; ContextMenuStrip draws its own.
|
|
155
|
+
$tray = New-Object System.Windows.Forms.NotifyIcon
|
|
156
|
+
$menu = New-Object System.Windows.Forms.ContextMenu
|
|
157
|
+
$miHeader = New-Object System.Windows.Forms.MenuItem 'Claude Code Hub'
|
|
158
|
+
$miHeader.Enabled = $false
|
|
159
|
+
$miOpen = New-Object System.Windows.Forms.MenuItem 'Open Hub'
|
|
160
|
+
$miOpen.DefaultItem = $true
|
|
161
|
+
$miStartStop = New-Object System.Windows.Forms.MenuItem 'Stop Hub'
|
|
162
|
+
$miRestart = New-Object System.Windows.Forms.MenuItem 'Restart Hub'
|
|
163
|
+
$miLog = New-Object System.Windows.Forms.MenuItem 'Open log'
|
|
164
|
+
$miAuto = New-Object System.Windows.Forms.MenuItem 'Start with Windows'
|
|
165
|
+
$miQuit = New-Object System.Windows.Forms.MenuItem 'Quit (stops the hub)'
|
|
166
|
+
foreach ($item in @($miHeader, '-', $miOpen, $miStartStop, $miRestart, '-', $miLog, $miAuto, '-', $miQuit)) {
|
|
167
|
+
[void]$menu.MenuItems.Add($item)
|
|
168
|
+
}
|
|
169
|
+
$tray.ContextMenu = $menu
|
|
170
|
+
|
|
171
|
+
function Set-State($s) {
|
|
172
|
+
if ($script:state -eq $s) { return }
|
|
173
|
+
$script:state = $s
|
|
174
|
+
$tray.Icon = $icons[$s]
|
|
175
|
+
$tray.Text = "Claude Code Hub: $s"
|
|
176
|
+
$miStartStop.Text = if ($s -eq 'stopped') { 'Start Hub' } else { 'Stop Hub' }
|
|
177
|
+
$miOpen.Enabled = $s -eq 'running'
|
|
178
|
+
$miRestart.Enabled = $s -ne 'stopped'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function Update-Header {
|
|
182
|
+
$dot = [char]0x00B7
|
|
183
|
+
$parts = @(if ($script:hub) { "Running on port $($script:hub.port)" } elseif ($script:state -eq 'starting') { 'Starting' } else { 'Stopped' })
|
|
184
|
+
$name = Get-ConfigDirName
|
|
185
|
+
if ($name) { $parts += $name }
|
|
186
|
+
$miHeader.Text = $parts -join " $dot "
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Stop-Hub clears hubProc, so a hubProc that has exited is a hub that died on its own.
|
|
190
|
+
function Update-State {
|
|
191
|
+
$script:hub = Get-Hub
|
|
192
|
+
if ($script:hub) { Set-State 'running'; return }
|
|
193
|
+
if ($script:hubProc -and -not $script:hubProc.HasExited) { Set-State 'starting'; return }
|
|
194
|
+
if ($script:hubProc) {
|
|
195
|
+
$script:hubProc.Dispose()
|
|
196
|
+
$script:hubProc = $null
|
|
197
|
+
Write-TrayLog 'hub exited'
|
|
198
|
+
$tray.ShowBalloonTip(5000, 'Claude Code Hub', 'The hub stopped. Start it from the tray menu; see Open log.', 'Warning')
|
|
199
|
+
}
|
|
200
|
+
Set-State 'stopped'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
$tray.add_MouseClick({ param($s, $e) if ($e.Button -eq 'Left') { Open-Hub } })
|
|
204
|
+
$miOpen.add_Click({ Open-Hub })
|
|
205
|
+
$miStartStop.add_Click({ if ($script:state -eq 'stopped') { Start-Hub } else { Stop-Hub; Update-State } })
|
|
206
|
+
$miRestart.add_Click({ Stop-Hub; Start-Hub })
|
|
207
|
+
$miLog.add_Click({ if (Test-Path $LogFile) { Start-Process $LogFile } })
|
|
208
|
+
$menu.add_Popup({ $miAuto.Checked = Get-Autostart; Update-Header })
|
|
209
|
+
$miAuto.add_Click({
|
|
210
|
+
if (Get-Autostart) { Remove-ItemProperty $RunKey -Name $config.runValue }
|
|
211
|
+
else { Set-ItemProperty $RunKey -Name $config.runValue -Value "wscript.exe `"$(Join-Path $TrayDir 'launch.vbs')`"" }
|
|
212
|
+
})
|
|
213
|
+
$miQuit.add_Click({
|
|
214
|
+
$timer.Stop()
|
|
215
|
+
Stop-Hub
|
|
216
|
+
Remove-Item $TrayPidFile -ErrorAction SilentlyContinue
|
|
217
|
+
$tray.Visible = $false
|
|
218
|
+
[System.Windows.Forms.Application]::Exit()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
$timer = New-Object System.Windows.Forms.Timer
|
|
222
|
+
$timer.Interval = 3000
|
|
223
|
+
$timer.add_Tick({ try { Update-State } catch { Write-TrayLog "tick: $_" } })
|
|
224
|
+
|
|
225
|
+
Set-State 'starting'
|
|
226
|
+
$tray.Visible = $true
|
|
227
|
+
try { Start-Hub } catch { Write-TrayLog "start failed: $_" }
|
|
228
|
+
$timer.Start()
|
|
229
|
+
[System.Windows.Forms.Application]::Run()
|
|
230
|
+
$tray.Dispose()
|
package/tray/icon.ps1
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function New-RoundRect([float]$x, [float]$y, [float]$w, [float]$h, [float]$r) {
|
|
2
|
+
$p = New-Object System.Drawing.Drawing2D.GraphicsPath
|
|
3
|
+
$d = $r * 2
|
|
4
|
+
$p.AddArc($x, $y, $d, $d, 180, 90)
|
|
5
|
+
$p.AddArc($x + $w - $d, $y, $d, $d, 270, 90)
|
|
6
|
+
$p.AddArc($x + $w - $d, $y + $h - $d, $d, $d, 0, 90)
|
|
7
|
+
$p.AddArc($x, $y + $h - $d, $d, $d, 90, 90)
|
|
8
|
+
$p.CloseFigure()
|
|
9
|
+
$p
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
# public/icons/icon-192.svg redrawn at the tray's own size, with a status dot in place of the bottom-right tile.
|
|
13
|
+
function New-TrayBitmap([int]$size, [System.Drawing.Color]$status) {
|
|
14
|
+
$k = $size / 192
|
|
15
|
+
$bmp = New-Object System.Drawing.Bitmap $size, $size
|
|
16
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
17
|
+
$g.SmoothingMode = 'AntiAlias'
|
|
18
|
+
$g.PixelOffsetMode = 'Half'
|
|
19
|
+
$bg = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(26, 26, 46))
|
|
20
|
+
$path = New-RoundRect 0 0 $size $size (32 * $k)
|
|
21
|
+
$g.FillPath($bg, $path)
|
|
22
|
+
$path.Dispose()
|
|
23
|
+
# Whole-pixel tiles and stroke keep the outlines sharp at 16 px.
|
|
24
|
+
$stroke = [Math]::Max(1, [Math]::Round(6 * $k))
|
|
25
|
+
$tile = [Math]::Round(40 * $k)
|
|
26
|
+
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(233, 69, 96)), $stroke
|
|
27
|
+
foreach ($pos in @(@(44, 44), @(108, 44), @(44, 108))) {
|
|
28
|
+
$x = [Math]::Round($pos[0] * $k) + $stroke / 2
|
|
29
|
+
$y = [Math]::Round($pos[1] * $k) + $stroke / 2
|
|
30
|
+
$path = New-RoundRect $x $y ($tile - $stroke) ($tile - $stroke) ([Math]::Max(0.5, 6 * $k))
|
|
31
|
+
$g.DrawPath($pen, $path)
|
|
32
|
+
$path.Dispose()
|
|
33
|
+
}
|
|
34
|
+
$c = 128 * $k
|
|
35
|
+
$dot = $size * 0.2
|
|
36
|
+
$brush = New-Object System.Drawing.SolidBrush $status
|
|
37
|
+
$g.FillEllipse($brush, $c - $dot, $c - $dot, 2 * $dot, 2 * $dot)
|
|
38
|
+
$brush.Dispose()
|
|
39
|
+
$pen.Dispose()
|
|
40
|
+
$bg.Dispose()
|
|
41
|
+
$g.Dispose()
|
|
42
|
+
$bmp
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
$StateColors = @{
|
|
46
|
+
running = [System.Drawing.Color]::FromArgb(34, 197, 94)
|
|
47
|
+
starting = [System.Drawing.Color]::FromArgb(245, 158, 11)
|
|
48
|
+
stopped = [System.Drawing.Color]::FromArgb(239, 68, 68)
|
|
49
|
+
}
|
package/tray/launch.vbs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
' Starts the tray with no console window; powershell -WindowStyle Hidden still flashes one.
|
|
2
|
+
Set fso = CreateObject("Scripting.FileSystemObject")
|
|
3
|
+
dir = fso.GetParentFolderName(WScript.ScriptFullName)
|
|
4
|
+
CreateObject("WScript.Shell").Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File """ & dir & "\hub-tray.ps1""", 0, False
|