claude-code-hub 1.28.0 → 1.30.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
@@ -183,12 +183,15 @@ The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd
183
183
  --host <addr> Bind address (default: 127.0.0.1)
184
184
  --allowed-hosts <list> Extra host names to accept, comma-separated
185
185
  --open Open the browser on start
186
+ --show-token Print the URL with its token also when output is not a terminal
186
187
  --detached Keep running when stdin closes (the macOS tray uses it)
187
188
  --tray Windows, macOS: run the hub from a tray icon
188
189
  --autostart Windows, macOS: same as --tray, and start the tray at logon
189
190
  --no-autostart Windows, macOS: stop starting the tray at logon
190
191
  --tray-status Windows, macOS: show whether autostart is on and the tray and hub are running
191
192
  --app-id <id> Windows, macOS, with --tray or --autostart: the installed app that Open Hub starts
193
+ -v, --version Print the version
194
+ -h, --help Print the flags
192
195
  ```
193
196
 
194
197
  If a port is busy, the hub uses a free port and reports it.
package/lib/cli.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const { version: HUB_VERSION } = require('../package.json');
4
+
5
+ // Every flag the hub reads, and whether it takes a value. `--<app id>-port` is open-ended, because
6
+ // config.json can add apps.
7
+ const FLAGS = {
8
+ port: true,
9
+ 'hub-dir': true,
10
+ 'pool-size': true,
11
+ 'disable-terminal': false,
12
+ 'no-mods': false,
13
+ host: true,
14
+ 'allowed-hosts': true,
15
+ open: false,
16
+ 'show-token': false,
17
+ detached: false,
18
+ install: false,
19
+ uninstall: false,
20
+ dir: true,
21
+ tray: false,
22
+ autostart: false,
23
+ 'no-autostart': false,
24
+ 'tray-status': false,
25
+ 'app-id': true,
26
+ version: false,
27
+ help: false,
28
+ };
29
+ const SHORT = { '-v': 'version', '-h': 'help' };
30
+ const APP_PORT_RE = /^[a-z0-9-]+-port$/;
31
+
32
+ const HELP = `Claude Code Hub ${HUB_VERSION}
33
+
34
+ Usage:
35
+ claude-code-hub [options] Start the hub, or print the URL of the one that runs
36
+ claude-code-hub ext <command> Manage extensions (claude-code-hub ext --help)
37
+
38
+ Options:
39
+ --port <n> Hub port (default 3540)
40
+ --<app>-port <n> Port of an app: kanban, marketplace, cost, memory
41
+ --hub-dir <path> Hub dir (default ~/.claude-hub)
42
+ --pool-size <n> Config dirs kept running (default 3)
43
+ --disable-terminal Turn off the embedded terminal in Kanban
44
+ --no-mods Start without mods
45
+ --host <addr> Bind address (default 127.0.0.1)
46
+ --allowed-hosts <list> Extra host names to accept, comma-separated
47
+ --open Open the hub in the browser
48
+ --show-token Print the hub URL with its token also when output is not a terminal
49
+ --detached Keep running when stdin closes
50
+ --install, --uninstall Install or remove the hub plugin (--dir <config dir>)
51
+ --tray, --autostart, --no-autostart, --tray-status, --app-id <id>
52
+ Tray and start at login (Windows and macOS)
53
+ -v, --version Print the version
54
+ -h, --help Print this help`;
55
+
56
+ // {exit, out} when the command line is answered here: the version, the help, or a usage error.
57
+ // {} when the hub should go on. `ext` keeps its own parser.
58
+ function checkArgs(args) {
59
+ if (args[0] === 'ext') return {};
60
+ const bad = (what) => ({ exit: 1, out: `${what}\n\n${HELP}` });
61
+ for (let i = 0; i < args.length; i++) {
62
+ const arg = args[i];
63
+ if (!arg.startsWith('-')) return bad(i === 0 ? `Unknown command: ${arg}` : `Unexpected argument: ${arg}`);
64
+ const [flag, value] = arg.startsWith('--') ? [arg.slice(2).split('=')[0], arg.includes('=')] : [SHORT[arg], false];
65
+ const takes = FLAGS[flag] ?? (APP_PORT_RE.test(flag ?? '') || undefined);
66
+ if (takes === undefined) return bad(`Unknown option: ${arg}`);
67
+ if (flag === 'version') return { exit: 0, out: HUB_VERSION };
68
+ if (flag === 'help') return { exit: 0, out: HELP };
69
+ if (takes && !value) i++;
70
+ }
71
+ return {};
72
+ }
73
+
74
+ // The hub URL as printed. Off a terminal (an agent's shell, a pipe, the tray's hub.log) the output is
75
+ // kept or read by others, so the token is hidden unless --show-token asks for it.
76
+ function printedUrl(line, url, { show, tokenFile }) {
77
+ if (show) return `${line} ${url}`;
78
+ const hidden = url.replace(/([?&]token=)[^&]*/, '$1<hidden>');
79
+ return `${line} ${hidden}\n The token is hidden. Open the hub with --open, print it with --show-token, or read ${tokenFile}`;
80
+ }
81
+
82
+ module.exports = { HUB_VERSION, FLAGS, HELP, checkArgs, printedUrl };
package/lib/ext.js CHANGED
@@ -9,7 +9,7 @@ const { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, readApp } = require('./apps'
9
9
  const { isContained } = require('./contain');
10
10
  const { readConfig, writeConfigFile } = require('./hub-config');
11
11
  const { claudeEnv, hasClaude, installPlugin, reinstallPlugin, uninstallPlugin, report, run } = require('./install');
12
- const { BUNDLED, bundledRoot, entryRoot, modJsHash, readMod } = require('./mods');
12
+ const { BUNDLED, bundledRoot, entryRoot, readMod } = require('./mods');
13
13
  const { pluginChecker } = require('./plugin-status');
14
14
  const { isNewer } = require('./update-check');
15
15
 
@@ -31,12 +31,15 @@ const USAGE = `Usage: claude-code-hub ext <command>
31
31
 
32
32
  A mod (mod.json) adds CSS and JS to the hub page and the apps, and can bring color themes.
33
33
  The hub ships some mods (xmas): ext install <id> finds them before the registry.
34
- A mod with JS runs only when you type y, at install and when ext update changes its code.
35
34
  Open the hub at /?nomods to load it with no mod. "modsEnabled": false in config.json
36
35
  or --no-mods turns every mod off.
37
36
 
38
37
  --hub-dir <path> The hub dir (default CLAUDE_HUB_DIR, else ~/.claude-hub)
39
- --yes Let a mod run code without asking
38
+ An extension from GitHub or a folder runs code with your rights: its server, its plugin's hooks
39
+ and its JS in the hub's pages. ext install and ext update say what runs and ask y/N first.
40
+ An extension the hub ships (xmas, voice) does not ask.
41
+
42
+ --yes Install or update without asking
40
43
  --dir <path> The config dir for plugin and dock steps (default CLAUDE_CONFIG_DIR, else ~/.claude)`;
41
44
 
42
45
  const RESTART = ' Restart the hub to load the change.';
@@ -248,6 +251,10 @@ const KINDS = {
248
251
  file: 'hub-app.json',
249
252
  read: readApp,
250
253
  step: (ctx, _id, ext, step, root) => !ext?.plugin || pluginStep(ctx, ext.plugin, step, root),
254
+ code: (ext) => [
255
+ ['a server the hub starts', 'a server'],
256
+ ...(ext.plugin ? [[`Claude Code plugin ${ext.plugin.id}, in your sessions`, `plugin ${ext.plugin.id}`]] : []),
257
+ ],
251
258
  stepName: 'Plugin install',
252
259
  removeName: 'Plugin removal',
253
260
  unread: 'The plugin is not removed, because the manifest does not name it.',
@@ -261,6 +268,7 @@ const KINDS = {
261
268
  file: 'addon.json',
262
269
  read: readAddon,
263
270
  step: dockStep,
271
+ code: (ext) => (ext.manifest.run ? [['a server Kanban starts', 'a server']] : []),
264
272
  stepName: 'Putting it on the dock',
265
273
  removeName: 'Taking it off the dock',
266
274
  unread: 'It is taken off the dock by its id.',
@@ -273,8 +281,12 @@ const KINDS = {
273
281
  list: 'mods',
274
282
  file: 'mod.json',
275
283
  read: readMod,
276
- step: modStep,
277
- stepName: 'Checking the mod',
284
+ step: () => true,
285
+ // A mod's JS runs in the page's own origin: in Kanban's it can read the terminal token and type into sessions.
286
+ code: (ext) => {
287
+ const js = jsTargets(ext.manifest);
288
+ return js.length ? [[`JS in ${js.join(', ')}`, `JS in ${js.join(', ')}`]] : [];
289
+ },
278
290
  removeName: 'Removing the mod',
279
291
  unread: 'It is removed by its id.',
280
292
  installed: RESTART,
@@ -305,13 +317,17 @@ function askTerminal(question) {
305
317
  }
306
318
  }
307
319
 
308
- // A mod's JS runs in the page's own origin, with the same trust as a patch: in Kanban's origin it can
309
- // read the terminal token and type into sessions. So it runs only when the user types yes.
310
- function allowJs(ctx, id, js, refused) {
320
+ // Code from a clone or a folder runs only when the user types yes, at install and at each update that
321
+ // moves it. An extension with no code, such as a CSS-only mod, does not ask.
322
+ function consent(ctx, { head, code, verb, update, refused }) {
323
+ if (!code.length) return true;
324
+ console.log(`\n ${head}`);
325
+ if (update) console.log(` It runs with your rights: ${code.map(([, s]) => s).join(', ')}`);
326
+ else console.log(` It runs with your rights:\n${code.map(([l]) => ` ${l}`).join('\n')}`);
311
327
  if (ctx.yes) return true;
312
- const answer = (ctx.ask ?? askTerminal)(` Let ${id} run code in ${js.join(', ')}? [y/N] `);
328
+ const answer = (ctx.ask ?? askTerminal)(` ${verb} it? [y/N] `);
313
329
  if (answer === null) {
314
- console.log(`\n There is no terminal to ask on. Pass --yes to let ${id} run code.`);
330
+ console.log(`\n There is no terminal to ask on. Pass --yes to ${verb.toLowerCase()} it.`);
315
331
  return false;
316
332
  }
317
333
  if (/^y(es)?$/i.test(answer)) return true;
@@ -319,33 +335,8 @@ function allowJs(ctx, id, js, refused) {
319
335
  return false;
320
336
  }
321
337
 
322
- // Asks only at install. Update asks itself, and only when the code changes.
323
- function modStep(ctx, id, ext, step) {
324
- if (step !== 'install' || !ext) return true;
325
- const { targets } = ext.manifest;
326
- console.log(`\n Mod ${id} changes: ${Object.keys(targets).join(', ')}`);
327
- const js = jsTargets(ext.manifest);
328
- if (!js.length) return true;
329
- console.log(` It runs code in ${js.join(', ')}, with the same access as the app's own code.`);
330
- return allowJs(ctx, id, js, `${id} is not installed.`);
331
- }
332
-
333
- function jsHashOf(root, id) {
334
- try {
335
- return modJsHash(root, readMod(root, id).manifest);
336
- } catch {
337
- return null;
338
- }
339
- }
340
-
341
- function updateAllowed(ctx, id, kind, root, before) {
342
- if (kind !== KINDS.mod) return true;
343
- const { manifest } = readMod(root, id);
344
- const js = jsTargets(manifest);
345
- if (!js.length || modJsHash(root, manifest) === before) return true;
346
- console.log(`\n The new version of ${id} changes the code it runs in ${js.join(', ')}.`);
347
- return allowJs(ctx, id, js, `${id} keeps the code it had.`);
348
- }
338
+ const askInstall = (ctx, id, kind, ext, from) =>
339
+ consent(ctx, { head: `${id} from ${from}`, code: kind.code(ext), verb: 'Install', refused: `${id} is not installed.` });
349
340
 
350
341
  // An id the hub runs gets its plugin installed. A folder is added to the hub, with its plugin, unless the
351
342
  // hub already runs it from there; then only the plugin is installed. Any other name is looked up in the registry.
@@ -482,6 +473,10 @@ function installGithub(ctx, target, file, config, name) {
482
473
  return true;
483
474
  }
484
475
  checkRequires(ctx, ext.manifest, config);
476
+ if (!askInstall(ctx, id, kind, ext, `${src.source} at ${short(sha)}`)) {
477
+ removeDir(dir);
478
+ return false;
479
+ }
485
480
  } catch (e) {
486
481
  removeDir(dir);
487
482
  throw e;
@@ -545,6 +540,7 @@ function installFolder(ctx, target, file, config) {
545
540
  return true;
546
541
  }
547
542
  const linked = root.replaceAll('\\', '/');
543
+ if (!askInstall(ctx, id, kind, ext, linked)) return false;
548
544
  if (!addExtension(ctx, root, file, config, { path: linked, source: 'local' }, kind)) return false;
549
545
  console.log(`\n Linked ${id} to ${linked}.\n${kind.installed}`);
550
546
  return true;
@@ -578,17 +574,22 @@ function update(ctx, id) {
578
574
  const [entry, kind] = managedEntry(config, id, 'update');
579
575
  const root = rootOf(ctx, entry);
580
576
  let sha;
577
+ let ext;
581
578
  if (isGithub(entry)) {
582
579
  const clone = cloneOf(ctx, entry);
583
- const before = kind === KINDS.mod ? jsHashOf(root, id) : null;
584
580
  const was = git(['rev-parse', 'HEAD'], clone);
585
581
  git(['fetch', '--quiet', 'origin'], clone);
586
582
  sha = checkout(clone, entry.ref);
587
583
  const back = () => was !== sha && checkout(clone, was);
588
- let allowed;
584
+ let allowed = true;
589
585
  try {
590
- checkRequires(ctx, kind.read(root, id).manifest, config);
591
- allowed = updateAllowed(ctx, id, kind, root, before);
586
+ ext = kind.read(root, id);
587
+ checkRequires(ctx, ext.manifest, config);
588
+ if (sha !== was) {
589
+ const n = git(['rev-list', '--count', `${was}..${sha}`], clone);
590
+ const head = `${id} ${short(was)} -> ${short(sha)} (${n} commit${n === '1' ? '' : 's'})`;
591
+ allowed = consent(ctx, { head, code: kind.code(ext), verb: 'Update', update: true, refused: `${id} keeps the code it had.` });
592
+ }
592
593
  } catch (e) {
593
594
  back();
594
595
  throw e;
@@ -598,9 +599,10 @@ function update(ctx, id) {
598
599
  return false;
599
600
  }
600
601
  } else {
601
- checkRequires(ctx, kind.read(root, id).manifest, config);
602
+ ext = kind.read(root, id);
603
+ checkRequires(ctx, ext.manifest, config);
602
604
  }
603
- if (!kind.step(ctx, id, kind.read(root, id), 'reinstall', root, config)) return false;
605
+ if (!kind.step(ctx, id, ext, 'reinstall', root, config)) return false;
604
606
  if (sha && sha !== entry.sha) writeConfigFile(file, withList(config, kind, (list) => list.map((e) => (e === entry ? { ...e, sha } : e))));
605
607
  const at = sha ? ` to ${short(sha)}` : '';
606
608
  console.log(`\n Updated ${id}${at}. ${kind.updated}`);
package/lib/mods.js CHANGED
@@ -76,14 +76,6 @@ function readSurface(root) {
76
76
  }
77
77
  }
78
78
 
79
- // Changes with any JS file of the mod, and with the page each one runs in. `ext update` asks again when it does.
80
- function modJsHash(root, manifest) {
81
- const hash = crypto.createHash('sha1');
82
- for (const [target, parts] of Object.entries(manifest.targets))
83
- for (const f of parts.js ?? []) hash.update(`${target}\0${f}\0`).update(fs.readFileSync(path.join(root, f))).update('\0');
84
- return hash.digest('hex');
85
- }
86
-
87
79
  // The mods of `config.mods`, read once at hub start. `version` changes with any file of the mod,
88
80
  // so the browser fetches the new files after a hub restart and caches them until then.
89
81
  // `surfaces` maps "hub" and each enabled app id to its surface version, or null when it has none.
@@ -153,4 +145,4 @@ function hubModScript(assets) {
153
145
  ].join('\n');
154
146
  }
155
147
 
156
- module.exports = { BUNDLED, readMod, readSurface, modJsHash, entryRoot, bundledRoot, resolveMods, modAssets, appModEnv, hubModScript };
148
+ module.exports = { BUNDLED, readMod, readSurface, entryRoot, bundledRoot, resolveMods, modAssets, appModEnv, hubModScript };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
48
48
  "dependencies": {
49
49
  "claude-code-cost": "1.6.1",
50
- "claude-code-kanban": "7.26.0",
50
+ "claude-code-kanban": "7.27.0",
51
51
  "claude-code-marketplace": "1.7.2",
52
52
  "claude-code-memory-explorer": "1.3.5",
53
53
  "express": "^4.21.0",
package/server.js CHANGED
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const { HUB_VERSION, checkArgs, printedUrl } = require('./lib/cli');
4
+
5
+ // Before the other requires: -v and -h answer at once, and a bad flag never reaches the running-hub check,
6
+ // which prints the hub URL with its token.
7
+ const cli = checkArgs(process.argv.slice(2));
8
+ if (cli.exit !== undefined) {
9
+ (cli.exit ? console.error : console.log)(cli.out);
10
+ process.exit(cli.exit);
11
+ }
12
+
3
13
  const { spawn } = require('child_process');
4
14
  const crypto = require('crypto');
5
15
  const express = require('express');
@@ -33,7 +43,6 @@ const {
33
43
  MAX_POOL_SIZE,
34
44
  settingValues,
35
45
  } = require('./lib/settings');
36
- const { version: HUB_VERSION } = require('./package.json');
37
46
 
38
47
  function getArg(name) {
39
48
  const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
@@ -1464,7 +1473,7 @@ if (!TRAY_FLAG) {
1464
1473
  listenWithFallback(app, HUB_PORT, onReady, '');
1465
1474
  return;
1466
1475
  }
1467
- console.log(`Claude Code Hub already runs for ${HUB_DIR} at ${accessUrl(running.port)}`);
1476
+ printUrl(`Claude Code Hub already runs for ${HUB_DIR} at`, running.port);
1468
1477
  if (process.argv.includes('--open')) {
1469
1478
  import('open').then((m) => m.default(accessUrl(running.port))).finally(() => process.exit(0));
1470
1479
  } else {
@@ -1477,8 +1486,13 @@ function accessUrl(port) {
1477
1486
  return `http://localhost:${port}/?token=${HUB_TOKEN}`;
1478
1487
  }
1479
1488
 
1489
+ function printUrl(line, port) {
1490
+ const show = process.argv.includes('--show-token') || require('tty').isatty(1);
1491
+ console.log(printedUrl(line, accessUrl(port), { show, tokenFile: HUB_TOKEN_FILE }));
1492
+ }
1493
+
1480
1494
  function printBanner(port) {
1481
- console.log(`Claude Code Hub running at ${accessUrl(port)}`);
1495
+ printUrl('Claude Code Hub running at', port);
1482
1496
  const warning = net.exposureWarning();
1483
1497
  if (warning) console.log(warning);
1484
1498
  console.log('Type "q" or "exit" to stop the server');