claude-code-hub 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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]);
@@ -991,12 +993,13 @@ app.get('/api/config', (_req, res) => {
991
993
  ...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE, extra: MOD_THEMES }),
992
994
  apps: appsConfig(),
993
995
  actions: ACTIONS,
994
- keys: USER_KEYS,
996
+ keys: userKeyMap,
995
997
  activeConfigDir: hubConfig.activeConfigDir,
996
998
  defaultConfigDir,
997
999
  configDirSwitch: configDirSwitch(hubConfig),
998
1000
  voice: voice && { provider: voice.provider, auto: voice.auto, commands: voice.commands },
999
1001
  version: HUB_VERSION,
1002
+ onboarding: hubConfig.onboarding ?? null,
1000
1003
  });
1001
1004
  });
1002
1005
 
@@ -1129,6 +1132,9 @@ function settingsView(saved) {
1129
1132
  ...(SETTING_SCHEMAS[a.id] && { settings: settingsOf(a.id) }),
1130
1133
  })),
1131
1134
  restartNeeded: appsShape(apps) !== RUNNING_APPS,
1135
+ keys: userKeyMap,
1136
+ keyWarnings: keyLines,
1137
+ keeps: ENABLED_APPS.filter((a) => a.keeps.length).map((a) => ({ id: a.id, name: appName(a), combos: a.keeps })),
1132
1138
  };
1133
1139
  }
1134
1140
 
@@ -1156,17 +1162,127 @@ app.post('/api/settings', (req, res) => {
1156
1162
  }
1157
1163
  const saved = readSettingsConfig(res);
1158
1164
  if (!saved) return;
1159
- const r = applySettings(saved, req.body, CONFIG_BASE, SETTING_SCHEMAS);
1165
+ const r = applySettings(saved, req.body, CONFIG_BASE, SETTING_SCHEMAS, ENABLED_APPS);
1160
1166
  if (r.error) {
1161
1167
  res.status(400).json({ error: r.error });
1162
1168
  return;
1163
1169
  }
1164
1170
  writeHubConfig(r.config);
1165
1171
  copyLive(r.config, hubConfig);
1172
+ if (req.body.keys !== undefined) ({ keys: userKeyMap, lines: keyLines } = userKeys(r.config.keys, ENABLED_APPS));
1166
1173
  loadAppValues(r.config);
1167
1174
  res.json(settingsView(r.config));
1168
1175
  });
1169
1176
 
1177
+ // The setup checklist runs the same commands a user would type, in a child hub process: --install and
1178
+ // ext install call the claude CLI with execSync, and the tray flags start the tray and exit.
1179
+ const SETUP_PLUGINS = [
1180
+ { ...HUB_PLUGIN, name: 'Hub skill', args: ['--install'] },
1181
+ ...ENABLED_APPS.filter((a) => a.plugin).map((a) => ({ ...a.plugin, name: a.name, args: ['ext', 'install', a.id] })),
1182
+ ];
1183
+ const SETUP_TIMEOUT_MS = 120_000;
1184
+ let setupBusy = false;
1185
+
1186
+ function runHub(args) {
1187
+ return new Promise((resolve) => {
1188
+ const child = spawn(process.execPath, [__filename, ...args, '--hub-dir', HUB_DIR], {
1189
+ env: process.env,
1190
+ stdio: ['ignore', 'pipe', 'pipe'],
1191
+ windowsHide: true,
1192
+ timeout: SETUP_TIMEOUT_MS,
1193
+ });
1194
+ let output = '';
1195
+ const add = (d) => (output += d);
1196
+ child.stdout.on('data', add);
1197
+ child.stderr.on('data', add);
1198
+ child.on('error', (e) => resolve({ ok: false, output: e.message }));
1199
+ child.on('close', (code) => resolve({ ok: code === 0, output: output.trim() }));
1200
+ });
1201
+ }
1202
+
1203
+ async function currentSetup() {
1204
+ const { trayState } = require('./lib/tray');
1205
+ return setupStatus({
1206
+ tray: await trayState({ hubDir: HUB_DIR }),
1207
+ configDirs: hubConfig.configDirs,
1208
+ activeConfigDir: hubConfig.activeConfigDir,
1209
+ candidates: findConfigDirs({ home: os.homedir(), known: hubConfig.configDirs, hubDir: HUB_DIR }),
1210
+ plugins: SETUP_PLUGINS,
1211
+ });
1212
+ }
1213
+
1214
+ async function setupJob(res, job) {
1215
+ if (setupBusy) {
1216
+ res.status(409).json({ error: 'Another setup step is running' });
1217
+ return;
1218
+ }
1219
+ setupBusy = true;
1220
+ try {
1221
+ const r = await job();
1222
+ res.status(r.ok ? 200 : 500).json({ ...r, setup: await currentSetup() });
1223
+ } finally {
1224
+ setupBusy = false;
1225
+ }
1226
+ }
1227
+
1228
+ app.get('/api/setup', async (_req, res) => {
1229
+ res.setHeader('Cache-Control', 'no-store');
1230
+ res.json(await currentSetup());
1231
+ });
1232
+
1233
+ app.post('/api/setup', (req, res) => {
1234
+ const value = req.body?.onboarding;
1235
+ if (!ONBOARDING.includes(value)) {
1236
+ res.status(400).json({ error: `onboarding must be one of ${ONBOARDING.join(', ')}` });
1237
+ return;
1238
+ }
1239
+ const saved = readSettingsConfig(res);
1240
+ if (!saved) return;
1241
+ hubConfig.onboarding = value;
1242
+ writeHubConfig({ ...saved, onboarding: value });
1243
+ res.json({ onboarding: value });
1244
+ });
1245
+
1246
+ app.post('/api/setup/tray', (_req, res) => {
1247
+ const appId = getArg('app-id');
1248
+ setupJob(res, () => runHub(['--autostart', '--port', String(hubPort), ...(appId ? ['--app-id', appId] : [])]));
1249
+ });
1250
+
1251
+ async function installPlugins(dir) {
1252
+ const outputs = [];
1253
+ let pending = pluginsToInstall(dir, SETUP_PLUGINS);
1254
+ while (pending.length) {
1255
+ const next = pending[0];
1256
+ const r = await runHub([...next.args, '--dir', dir]);
1257
+ outputs.push(r.output);
1258
+ pending = pluginsToInstall(dir, SETUP_PLUGINS);
1259
+ if (!r.ok || pending[0]?.id === next.id) return { dir, ok: false, output: outputs.join('\n') };
1260
+ }
1261
+ return { dir, ok: true, output: outputs.join('\n') };
1262
+ }
1263
+
1264
+ app.post('/api/setup/plugins', (req, res) => {
1265
+ const all = req.body?.all === true;
1266
+ const dir = req.body?.dir;
1267
+ if (!all && !hubConfig.configDirs.includes(dir)) {
1268
+ res.status(404).json({ error: 'Unknown config dir; add it first' });
1269
+ return;
1270
+ }
1271
+ setupJob(res, async () => {
1272
+ const dirs = all ? hubConfig.configDirs.filter((d) => pluginsToInstall(d, SETUP_PLUGINS).length) : [dir];
1273
+ const results = [];
1274
+ for (const d of dirs) results.push(await installPlugins(d));
1275
+ return {
1276
+ ok: results.every((r) => r.ok),
1277
+ output: results
1278
+ .filter((r) => r.output)
1279
+ .map((r) => `${r.dir}\n${r.output}`)
1280
+ .join('\n\n'),
1281
+ results: results.map(({ dir, ok }) => ({ dir, ok })),
1282
+ };
1283
+ });
1284
+ });
1285
+
1170
1286
  // The stalls hit a few percent of requests, so each round sends several probes per app.
1171
1287
  const HEALTH_INTERVAL_MS = 30_000;
1172
1288
  const HEALTH_PROBES = 5;