claude-code-hub 1.34.0 → 1.36.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/public/index.html CHANGED
@@ -25,6 +25,7 @@
25
25
  --text: #e8e8ef;
26
26
  --text-dim: #8a8fa3;
27
27
  --warn: #f5a524;
28
+ --ok: #3fb950;
28
29
  --shadow: rgba(0, 0, 0, 0.55);
29
30
  --ghost-mix: 22%;
30
31
  }
@@ -41,6 +42,7 @@
41
42
  --text: #18181b;
42
43
  --text-dim: #71717a;
43
44
  --warn: #8a4b00;
45
+ --ok: #1a7f37;
44
46
  --shadow: rgba(0, 0, 0, 0.18);
45
47
  --ghost-mix: 10%;
46
48
  }
@@ -413,6 +415,28 @@
413
415
  .settings-bar h3 { font-size: 14px; font-weight: 600; }
414
416
  .settings-bar kbd { margin-left: auto; }
415
417
 
418
+ #settings .palette-box { width: min(820px, 94vw); }
419
+ #settings-body:has(> .set-nav) { display: grid; grid-template-columns: 150px 1fr; min-height: 360px; }
420
+
421
+ .set-nav {
422
+ display: flex;
423
+ flex-direction: column;
424
+ gap: 2px;
425
+ padding: 8px 6px;
426
+ border-right: 1px solid var(--border);
427
+ }
428
+
429
+ .settings-box .set-nav button {
430
+ border: none;
431
+ border-radius: 6px;
432
+ background: none;
433
+ padding: 6px 10px;
434
+ text-align: left;
435
+ color: var(--text-dim);
436
+ }
437
+
438
+ .set-pane { min-width: 0; }
439
+
416
440
  .set-sec { padding: 6px 0; border-bottom: 1px solid var(--border); }
417
441
  .set-sec:last-child { border-bottom: none; }
418
442
 
@@ -450,6 +474,103 @@
450
474
  cursor: pointer;
451
475
  }
452
476
 
477
+ #setup { padding-top: 6vh; }
478
+
479
+ .setup-box {
480
+ width: min(860px, 94vw);
481
+ max-height: 88vh;
482
+ border-radius: 14px;
483
+ box-shadow: 0 28px 80px var(--shadow);
484
+ font-size: 14px;
485
+ }
486
+
487
+ .setup-hero {
488
+ display: flex;
489
+ align-items: center;
490
+ gap: 18px;
491
+ padding: 28px 32px 22px;
492
+ border-bottom: 1px solid var(--border);
493
+ background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%);
494
+ }
495
+
496
+ .setup-hero img { width: 56px; height: 56px; flex: none; }
497
+ .setup-hero h3 { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
498
+ .setup-hero p { margin-top: 4px; color: var(--text-dim); font-size: 13px; }
499
+ .setup-hero kbd { margin-left: auto; align-self: flex-start; }
500
+
501
+ .setup-meter { display: flex; align-items: center; gap: 14px; padding: 18px 32px 6px; }
502
+ .setup-lead { flex: none; color: var(--text-dim); font-size: 13px; }
503
+ .setup-lead b { color: var(--text); }
504
+ .setup-progress { flex: 1; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
505
+ .setup-progress > i { display: block; height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s; }
506
+
507
+ .setup-steps { display: grid; gap: 10px; list-style: none; padding: 12px 24px 24px; }
508
+
509
+ .setup-step {
510
+ display: grid;
511
+ grid-template-columns: 36px 1fr auto;
512
+ gap: 4px 16px;
513
+ align-items: center;
514
+ padding: 16px 18px;
515
+ border: 1px solid var(--border);
516
+ border-radius: 10px;
517
+ }
518
+
519
+ .setup-step.current {
520
+ border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
521
+ background: color-mix(in srgb, var(--accent) 7%, transparent);
522
+ }
523
+
524
+ .setup-step.done { opacity: 0.75; }
525
+
526
+ .setup-mark {
527
+ grid-row: span 2;
528
+ align-self: start;
529
+ display: grid;
530
+ place-items: center;
531
+ width: 36px;
532
+ height: 36px;
533
+ border: 2px solid var(--border);
534
+ border-radius: 50%;
535
+ font-size: 15px;
536
+ font-weight: 700;
537
+ color: var(--text-dim);
538
+ }
539
+
540
+ .setup-step.done .setup-mark { border-color: var(--accent); background: var(--accent); color: var(--bg); }
541
+ .setup-step.current .setup-mark { border-color: var(--accent); color: var(--accent); }
542
+ .setup-title { font-size: 16px; font-weight: 700; }
543
+ .setup-step.done .setup-title { font-weight: 600; }
544
+ .setup-desc { grid-column: 2 / -1; color: var(--text-dim); font-size: 13px; line-height: 1.5; }
545
+ .setup-tag { margin-left: 8px; padding: 1px 6px; border: 1px solid var(--border); border-radius: 4px; font-size: 11px; font-weight: 500; color: var(--text-dim); vertical-align: 2px; }
546
+
547
+ .setup-list { grid-column: 2 / -1; display: grid; gap: 6px; margin-top: 10px; }
548
+
549
+ .setup-item {
550
+ display: grid;
551
+ grid-template-columns: 1fr auto;
552
+ gap: 4px 12px;
553
+ align-items: center;
554
+ padding: 8px 12px;
555
+ border-radius: 6px;
556
+ background: var(--bg);
557
+ font-size: 13px;
558
+ }
559
+
560
+ .setup-item code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
561
+ .setup-item .set-help { grid-column: 1 / -1; }
562
+ .setup-item a { color: var(--accent); font-size: 12px; }
563
+ .setup-notice { padding: 8px 12px; border-left: 3px solid var(--warn); background: var(--bg); border-radius: 6px; font-size: 13px; }
564
+ .setup-ok { color: var(--text-dim); font-size: 12px; }
565
+ .setup-ok.good { color: var(--ok); font-weight: 600; }
566
+ .setup-out { grid-column: 2 / -1; max-height: 160px; overflow: auto; margin-top: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; white-space: pre-wrap; }
567
+
568
+ .setup-box .settings-foot { padding: 14px 32px; align-items: center; font-size: 13px; }
569
+ .setup-box button { padding: 7px 16px; border-radius: 6px; font-size: 13px; font-weight: 600; }
570
+ .setup-box .setup-item button { padding: 3px 10px; font-size: 12px; }
571
+ .settings-box.setup-box button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
572
+ .settings-box.setup-box button.primary:hover:not(:disabled) { color: #fff; filter: brightness(1.08); }
573
+
453
574
  .settings-box button:hover:not(:disabled) { color: var(--text); }
454
575
  .settings-box button:disabled { opacity: 0.4; cursor: default; }
455
576
 
@@ -469,7 +590,8 @@
469
590
 
470
591
  .seg button { border-color: transparent; }
471
592
 
472
- .seg button.on {
593
+ .seg button.on,
594
+ .settings-box .set-nav button[aria-selected="true"] {
473
595
  background: color-mix(in srgb, var(--accent) 18%, transparent);
474
596
  color: var(--text);
475
597
  font-weight: 600;
@@ -486,6 +608,14 @@
486
608
  .set-apps th { color: var(--text-dim); font-size: 11px; font-weight: 400; }
487
609
  .set-apps td:first-child { font-weight: 600; }
488
610
 
611
+ .keys-table tr.recording td { background: color-mix(in srgb, var(--accent) 7%, transparent); }
612
+ .key-btns { display: flex; gap: 4px; }
613
+ .key-tag { margin-left: 6px; padding: 0 5px; border: 1px solid var(--border); border-radius: 4px; font-size: 10px; font-weight: 500; color: var(--text-dim); }
614
+ .key-combo { display: inline-flex; align-items: center; gap: 6px; }
615
+ .key-local { color: var(--text-dim); font-size: 11px; }
616
+ .key-prompt { color: var(--accent); font-weight: 600; }
617
+ .key-keeps { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 14px 10px; }
618
+
489
619
  .settings-foot { display: flex; gap: 12px; }
490
620
 
491
621
  .settings-foot .set-keys { margin-left: auto; }
@@ -707,12 +837,36 @@
707
837
  </div>
708
838
  </div>
709
839
 
840
+ <div class="palette" id="shortcuts" hidden>
841
+ <div class="palette-box settings-box" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title" tabindex="-1">
842
+ <div class="settings-bar"><h3 id="shortcuts-title">Keyboard shortcuts</h3><kbd>Esc</kbd></div>
843
+ <div id="shortcuts-body"></div>
844
+ <div class="palette-hint settings-foot" id="shortcuts-foot"></div>
845
+ </div>
846
+ </div>
847
+
848
+ <div class="palette" id="setup" hidden>
849
+ <div class="palette-box settings-box setup-box" role="dialog" aria-modal="true" aria-labelledby="setup-title" tabindex="-1">
850
+ <div class="setup-hero">
851
+ <img src="/icons/icon-192.svg" alt="">
852
+ <div>
853
+ <h3 id="setup-title">Welcome to Claude Code Hub</h3>
854
+ <p id="setup-sub">A few quick steps make the hub feel like a desktop app.</p>
855
+ </div>
856
+ <kbd>Esc</kbd>
857
+ </div>
858
+ <div id="setup-body"></div>
859
+ <div class="palette-hint settings-foot" id="setup-foot"></div>
860
+ </div>
861
+ </div>
862
+
710
863
  <div class="update-toast" id="update-toast" role="status" hidden>
711
864
  <div class="update-toast-body">
712
865
  <div class="update-toast-title">Claude Code Hub <span id="update-latest"></span> is available</div>
713
866
  <div class="update-toast-meta">You have <span id="update-current"></span> · <a id="update-notes" target="_blank" rel="noopener">Release notes</a></div>
714
- <code>npm i -g claude-code-hub@latest</code>
867
+ <code id="update-command"></code>
715
868
  <div class="update-toast-actions">
869
+ <button class="update-toast-skip" id="update-review" type="button">Review update…</button>
716
870
  <button class="update-toast-skip" id="update-skip" type="button">Skip this version</button>
717
871
  </div>
718
872
  </div>
package/server.js CHANGED
@@ -33,6 +33,7 @@ const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
33
33
  const { HUB_PLUGIN } = require('./lib/install');
34
34
  const { pluginChecker } = require('./lib/plugin-status');
35
35
  const { readConfig, serializeConfig, writeConfigFile } = require('./lib/hub-config');
36
+ const { ONBOARDING, findConfigDirs, pluginsToInstall, setupStatus } = require('./lib/onboarding');
36
37
  const {
37
38
  appSettings,
38
39
  appsShape,
@@ -174,6 +175,7 @@ function resolveHubConfig() {
174
175
  if (typeof saved.modsEnabled === 'boolean') config.modsEnabled = saved.modsEnabled;
175
176
  if (saved.keys !== undefined) config.keys = saved.keys;
176
177
  if (saved.voice && typeof saved.voice === 'object') config.voice = saved.voice;
178
+ if (ONBOARDING.includes(saved.onboarding)) config.onboarding = saved.onboarding;
177
179
  copyLive(saved, config);
178
180
  const savedPrevious = typeof saved.previousConfigDir === 'string' ? canonical(saved.previousConfigDir) : null;
179
181
  if (dirs.includes(savedPrevious) && savedPrevious !== active) config.previousConfigDir = savedPrevious;
@@ -232,7 +234,7 @@ const MODS = resolveMods(hubConfig, path.dirname(HUB_CONFIG_FILE), {
232
234
  });
233
235
  for (const m of MODS) console.log(`mod ${m.id} changes ${Object.keys(m.targets).join(', ')}`);
234
236
  const ACTIONS = actionTable(ENABLED_APPS);
235
- const { keys: USER_KEYS, lines: keyLines } = userKeys(hubConfig.keys, ENABLED_APPS);
237
+ let { keys: userKeyMap, lines: keyLines } = userKeys(hubConfig.keys, ENABLED_APPS);
236
238
  for (const line of keyLines) console.log(`${HUB_CONFIG_FILE}: ${line}`);
237
239
  // The first enabled app in tab order that declares the capability in its manifest.
238
240
  const provider = (cap) => ENABLED_APPS.find((a) => a.provides[cap]);
@@ -395,6 +397,7 @@ async function restartApp(pool, app) {
395
397
  const { id } = app;
396
398
  pool.restarts[id] = [];
397
399
  pool.stopped.delete(id);
400
+ pool.deferred.delete(id);
398
401
  await killApp(pool, id);
399
402
  if (pool.retired) return false;
400
403
  const child = spawnOne(pool, app);
@@ -404,9 +407,21 @@ async function restartApp(pool, app) {
404
407
 
405
408
  async function stopApp(pool, { id }) {
406
409
  pool.stopped.add(id);
410
+ pool.deferred.delete(id);
407
411
  await killApp(pool, id);
408
412
  }
409
413
 
414
+ // A deferredStart app that nothing has started yet starts on its first page load: its tab, a pane,
415
+ // an action. One the user stopped stays stopped.
416
+ function startOnDemand(name) {
417
+ const pool = activePool();
418
+ if (!pool?.deferred.delete(name)) return;
419
+ console.log(`[${name}] starting on first open`);
420
+ pool.stopped.delete(name);
421
+ const target = ENABLED_APPS.find((a) => a.id === name);
422
+ spawnOne(pool, target);
423
+ }
424
+
410
425
  // Out of pool.children before the kill, so the exit handler does not restart it.
411
426
  async function killApp(pool, id) {
412
427
  const old = pool.children.get(id);
@@ -446,7 +461,7 @@ async function ensurePool(dir) {
446
461
  pool = pools.get(dir);
447
462
  if (pool) return pool.ready;
448
463
  const stopped = new Set(ENABLED_APPS.filter((a) => a.deferredStart).map((a) => a.id));
449
- pool = { dir, children: new Map(), ports: {}, restarts: {}, stopped, retired: false };
464
+ pool = { dir, children: new Map(), ports: {}, restarts: {}, stopped, deferred: new Set(stopped), retired: false };
450
465
  pools.set(dir, pool);
451
466
  spawnChildren(pool);
452
467
  const ready = [...pool.children.values()].map((c) => c.ready);
@@ -817,6 +832,7 @@ function proxyHandler(name) {
817
832
  if (body === undefined) return;
818
833
  // One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
819
834
  const budget = { deadline: Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS) };
835
+ if (isNavigation(req)) startOnDemand(name);
820
836
  // One listener for the request, however many attempts it takes.
821
837
  let current = null;
822
838
  res.once('close', () => current?.destroy());
@@ -961,6 +977,7 @@ function appsConfig() {
961
977
  publishes: a.publishes,
962
978
  };
963
979
  if (a.hidden) entry.hidden = true;
980
+ if (a.deferredStart) entry.deferredStart = true;
964
981
  if (a.settings.length) entry.settings = appValues.get(a.id);
965
982
  if (TERMINAL_TOKEN && a.id === TERMINAL_APP.id) entry.terminalToken = TERMINAL_TOKEN;
966
983
  return [a.id, entry];
@@ -991,12 +1008,13 @@ app.get('/api/config', (_req, res) => {
991
1008
  ...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE, extra: MOD_THEMES }),
992
1009
  apps: appsConfig(),
993
1010
  actions: ACTIONS,
994
- keys: USER_KEYS,
1011
+ keys: userKeyMap,
995
1012
  activeConfigDir: hubConfig.activeConfigDir,
996
1013
  defaultConfigDir,
997
1014
  configDirSwitch: configDirSwitch(hubConfig),
998
1015
  voice: voice && { provider: voice.provider, auto: voice.auto, commands: voice.commands },
999
1016
  version: HUB_VERSION,
1017
+ onboarding: process.env.CLAUDE_HUB_NO_ONBOARDING ? 'skipped' : (hubConfig.onboarding ?? null),
1000
1018
  });
1001
1019
  });
1002
1020
 
@@ -1129,6 +1147,9 @@ function settingsView(saved) {
1129
1147
  ...(SETTING_SCHEMAS[a.id] && { settings: settingsOf(a.id) }),
1130
1148
  })),
1131
1149
  restartNeeded: appsShape(apps) !== RUNNING_APPS,
1150
+ keys: userKeyMap,
1151
+ keyWarnings: keyLines,
1152
+ keeps: ENABLED_APPS.filter((a) => a.keeps.length).map((a) => ({ id: a.id, name: appName(a), combos: a.keeps })),
1132
1153
  };
1133
1154
  }
1134
1155
 
@@ -1156,17 +1177,127 @@ app.post('/api/settings', (req, res) => {
1156
1177
  }
1157
1178
  const saved = readSettingsConfig(res);
1158
1179
  if (!saved) return;
1159
- const r = applySettings(saved, req.body, CONFIG_BASE, SETTING_SCHEMAS);
1180
+ const r = applySettings(saved, req.body, CONFIG_BASE, SETTING_SCHEMAS, ENABLED_APPS);
1160
1181
  if (r.error) {
1161
1182
  res.status(400).json({ error: r.error });
1162
1183
  return;
1163
1184
  }
1164
1185
  writeHubConfig(r.config);
1165
1186
  copyLive(r.config, hubConfig);
1187
+ if (req.body.keys !== undefined) ({ keys: userKeyMap, lines: keyLines } = userKeys(r.config.keys, ENABLED_APPS));
1166
1188
  loadAppValues(r.config);
1167
1189
  res.json(settingsView(r.config));
1168
1190
  });
1169
1191
 
1192
+ // The setup checklist runs the same commands a user would type, in a child hub process: --install and
1193
+ // ext install call the claude CLI with execSync, and the tray flags start the tray and exit.
1194
+ const SETUP_PLUGINS = [
1195
+ { ...HUB_PLUGIN, name: 'Hub skill', args: ['--install'] },
1196
+ ...ENABLED_APPS.filter((a) => a.plugin).map((a) => ({ ...a.plugin, name: a.name, args: ['ext', 'install', a.id] })),
1197
+ ];
1198
+ const SETUP_TIMEOUT_MS = 120_000;
1199
+ let setupBusy = false;
1200
+
1201
+ function runHub(args) {
1202
+ return new Promise((resolve) => {
1203
+ const child = spawn(process.execPath, [__filename, ...args, '--hub-dir', HUB_DIR], {
1204
+ env: process.env,
1205
+ stdio: ['ignore', 'pipe', 'pipe'],
1206
+ windowsHide: true,
1207
+ timeout: SETUP_TIMEOUT_MS,
1208
+ });
1209
+ let output = '';
1210
+ const add = (d) => (output += d);
1211
+ child.stdout.on('data', add);
1212
+ child.stderr.on('data', add);
1213
+ child.on('error', (e) => resolve({ ok: false, output: e.message }));
1214
+ child.on('close', (code) => resolve({ ok: code === 0, output: output.trim() }));
1215
+ });
1216
+ }
1217
+
1218
+ async function currentSetup() {
1219
+ const { trayState } = require('./lib/tray');
1220
+ return setupStatus({
1221
+ tray: await trayState({ hubDir: HUB_DIR }),
1222
+ configDirs: hubConfig.configDirs,
1223
+ activeConfigDir: hubConfig.activeConfigDir,
1224
+ candidates: findConfigDirs({ home: os.homedir(), known: hubConfig.configDirs, hubDir: HUB_DIR }),
1225
+ plugins: SETUP_PLUGINS,
1226
+ });
1227
+ }
1228
+
1229
+ async function setupJob(res, job) {
1230
+ if (setupBusy) {
1231
+ res.status(409).json({ error: 'Another setup step is running' });
1232
+ return;
1233
+ }
1234
+ setupBusy = true;
1235
+ try {
1236
+ const r = await job();
1237
+ res.status(r.ok ? 200 : 500).json({ ...r, setup: await currentSetup() });
1238
+ } finally {
1239
+ setupBusy = false;
1240
+ }
1241
+ }
1242
+
1243
+ app.get('/api/setup', async (_req, res) => {
1244
+ res.setHeader('Cache-Control', 'no-store');
1245
+ res.json(await currentSetup());
1246
+ });
1247
+
1248
+ app.post('/api/setup', (req, res) => {
1249
+ const value = req.body?.onboarding;
1250
+ if (!ONBOARDING.includes(value)) {
1251
+ res.status(400).json({ error: `onboarding must be one of ${ONBOARDING.join(', ')}` });
1252
+ return;
1253
+ }
1254
+ const saved = readSettingsConfig(res);
1255
+ if (!saved) return;
1256
+ hubConfig.onboarding = value;
1257
+ writeHubConfig({ ...saved, onboarding: value });
1258
+ res.json({ onboarding: value });
1259
+ });
1260
+
1261
+ app.post('/api/setup/tray', (_req, res) => {
1262
+ const appId = getArg('app-id');
1263
+ setupJob(res, () => runHub(['--autostart', '--port', String(hubPort), ...(appId ? ['--app-id', appId] : [])]));
1264
+ });
1265
+
1266
+ async function installPlugins(dir) {
1267
+ const outputs = [];
1268
+ let pending = pluginsToInstall(dir, SETUP_PLUGINS);
1269
+ while (pending.length) {
1270
+ const next = pending[0];
1271
+ const r = await runHub([...next.args, '--dir', dir]);
1272
+ outputs.push(r.output);
1273
+ pending = pluginsToInstall(dir, SETUP_PLUGINS);
1274
+ if (!r.ok || pending[0]?.id === next.id) return { dir, ok: false, output: outputs.join('\n') };
1275
+ }
1276
+ return { dir, ok: true, output: outputs.join('\n') };
1277
+ }
1278
+
1279
+ app.post('/api/setup/plugins', (req, res) => {
1280
+ const all = req.body?.all === true;
1281
+ const dir = req.body?.dir;
1282
+ if (!all && !hubConfig.configDirs.includes(dir)) {
1283
+ res.status(404).json({ error: 'Unknown config dir; add it first' });
1284
+ return;
1285
+ }
1286
+ setupJob(res, async () => {
1287
+ const dirs = all ? hubConfig.configDirs.filter((d) => pluginsToInstall(d, SETUP_PLUGINS).length) : [dir];
1288
+ const results = [];
1289
+ for (const d of dirs) results.push(await installPlugins(d));
1290
+ return {
1291
+ ok: results.every((r) => r.ok),
1292
+ output: results
1293
+ .filter((r) => r.output)
1294
+ .map((r) => `${r.dir}\n${r.output}`)
1295
+ .join('\n\n'),
1296
+ results: results.map(({ dir, ok }) => ({ dir, ok })),
1297
+ };
1298
+ });
1299
+ });
1300
+
1170
1301
  // The stalls hit a few percent of requests, so each round sends several probes per app.
1171
1302
  const HEALTH_INTERVAL_MS = 30_000;
1172
1303
  const HEALTH_PROBES = 5;