claude-code-hub 1.9.0 → 1.10.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/lib/apps.js CHANGED
@@ -4,6 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { isContained } = require('./contain');
6
6
  const { COMBO_RE } = require('./keymap');
7
+ const { bundledVersion } = require('./plugin-status');
7
8
 
8
9
  // Built-in apps in their default tab order. The rest of each app comes from its manifest.
9
10
  const APPS = [
@@ -65,7 +66,19 @@ function manifestError(m, id, root) {
65
66
  if (typeof m.run?.entry !== 'string') return 'run.entry is missing';
66
67
  const entry = path.resolve(root, m.run.entry);
67
68
  if (entry === path.resolve(root) || !isContained(entry, root)) return 'run.entry is outside the app directory';
68
- return providesError(m.provides) ?? publishesError(m.publishes) ?? keysError(m.keys);
69
+ return providesError(m.provides) ?? publishesError(m.publishes) ?? keysError(m.keys) ?? pluginError(m.plugin, root);
70
+ }
71
+
72
+ const PLUGIN_ID_RE = /^[\w.-]+@[\w.-]+$/;
73
+
74
+ function pluginError(plugin, root) {
75
+ if (plugin === undefined) return null;
76
+ if (typeof plugin?.id !== 'string' || !PLUGIN_ID_RE.test(plugin.id)) return 'plugin.id is not <name>@<marketplace>';
77
+ if (typeof plugin.path !== 'string') return 'plugin.path is missing';
78
+ const dir = path.resolve(root, plugin.path);
79
+ if (dir === path.resolve(root) || !isContained(dir, root)) return 'plugin.path is outside the app directory';
80
+ if (typeof plugin.install !== 'string' || !plugin.install) return 'plugin.install is missing';
81
+ return null;
69
82
  }
70
83
 
71
84
  function keysError(keys) {
@@ -146,8 +159,19 @@ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve,
146
159
  log(`${path.join(root, 'hub-app.json')}: ${reason}. ${app.id} is skipped`);
147
160
  return null;
148
161
  }
162
+ let version = null;
163
+ try {
164
+ version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version ?? null;
165
+ } catch {}
166
+ const { plugin } = manifest;
149
167
  return {
150
168
  id: app.id,
169
+ version,
170
+ plugin: plugin && {
171
+ id: plugin.id,
172
+ bundled: bundledVersion(path.join(root, plugin.path)),
173
+ install: plugin.install,
174
+ },
151
175
  entry: path.join(root, manifest.run.entry),
152
176
  port: userPort(app, flagPort, log) ?? manifest.run.defaultPort ?? 0,
153
177
  name: manifest.name ?? app.id,
package/lib/install.js CHANGED
@@ -2,18 +2,22 @@
2
2
 
3
3
  const { execSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
- const os = require('node:os');
6
5
  const path = require('node:path');
6
+ const { bundledVersion, isDefaultConfigDir } = require('./plugin-status');
7
7
 
8
8
  const PLUGIN_SRC = path.join(__dirname, '..', 'plugin');
9
9
  const MARKETPLACE = 'claude-code-hub';
10
10
  const PLUGIN_ID = `claude-code-hub@${MARKETPLACE}`;
11
+ const HUB_PLUGIN = {
12
+ id: PLUGIN_ID,
13
+ bundled: bundledVersion(path.join(PLUGIN_SRC, 'plugins', 'claude-code-hub')),
14
+ install: 'claude-code-hub --install',
15
+ };
11
16
 
12
17
  // Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but inside the
13
18
  // dir when it is set, so the default dir must stay unset rather than be spelled out.
14
19
  function claudeEnv(configDir) {
15
- const isDefault = path.resolve(configDir) === path.resolve(os.homedir(), '.claude');
16
- return isDefault ? process.env : { ...process.env, CLAUDE_CONFIG_DIR: configDir };
20
+ return isDefaultConfigDir(configDir) ? process.env : { ...process.env, CLAUDE_CONFIG_DIR: configDir };
17
21
  }
18
22
 
19
23
  // `idempotent` when the output names an ok pattern, on exit 0 too: some commands report "already" and succeed.
@@ -86,4 +90,4 @@ function runUninstall({ configDir }) {
86
90
  return ok;
87
91
  }
88
92
 
89
- module.exports = { runInstall, runUninstall, claudeEnv };
93
+ module.exports = { runInstall, runUninstall, claudeEnv, HUB_PLUGIN };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ function readJson(file) {
8
+ try {
9
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function isDefaultConfigDir(configDir) {
16
+ return path.resolve(configDir) === path.resolve(os.homedir(), '.claude');
17
+ }
18
+
19
+ // --dir rather than CLAUDE_CONFIG_DIR=…, so the same line runs in bash, PowerShell and cmd.
20
+ function installCommand(base, configDir) {
21
+ return isDefaultConfigDir(configDir) ? base : `${base} --dir "${configDir}"`;
22
+ }
23
+
24
+ // The version in the plugin.json under pluginDir, the folder an app or the hub ships its plugin in.
25
+ function bundledVersion(pluginDir) {
26
+ return readJson(path.join(pluginDir, '.claude-plugin', 'plugin.json'))?.version ?? null;
27
+ }
28
+
29
+ // Reads the config dir once and gives a check for each plugin, {id, bundled, install}. The claude CLI
30
+ // records installs in <config dir>/plugins/installed_plugins.json, one entry per scope; a user-scope
31
+ // entry wins, because the installers install at user scope.
32
+ function pluginChecker(configDir) {
33
+ const installs = readJson(path.join(configDir, 'plugins', 'installed_plugins.json'))?.plugins ?? {};
34
+ const enabledPlugins = readJson(path.join(configDir, 'settings.json'))?.enabledPlugins ?? {};
35
+ return ({ id, bundled, install }) => {
36
+ const entries = Array.isArray(installs[id]) ? installs[id] : [];
37
+ const entry = entries.find((e) => e?.scope === 'user') ?? entries[0];
38
+ const installed = typeof entry?.version === 'string' ? entry.version : null;
39
+ let state = 'ok';
40
+ if (!installed) state = 'missing';
41
+ else if (enabledPlugins[id] === false) state = 'disabled';
42
+ else if (bundled && installed !== bundled) state = 'mismatch';
43
+ return { id, bundled, installed, state, installCommand: installCommand(install, configDir) };
44
+ };
45
+ }
46
+
47
+ module.exports = { pluginChecker, bundledVersion, isDefaultConfigDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
48
48
  "dependencies": {
49
- "claude-code-cost": "1.3.0",
50
- "claude-code-kanban": "7.5.0",
51
- "claude-code-marketplace": "1.5.0",
52
- "claude-code-memory-explorer": "1.3.0",
49
+ "claude-code-cost": "1.3.1",
50
+ "claude-code-kanban": "7.6.0",
51
+ "claude-code-marketplace": "1.5.1",
52
+ "claude-code-memory-explorer": "1.3.1",
53
53
  "express": "^4.21.0",
54
54
  "open": "^10.1.0"
55
55
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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"
@@ -60,7 +60,7 @@ After a server change, restart the app: `Ctrl+Alt+A`, select it, `Ctrl+R`. After
60
60
 
61
61
  ## The manifest
62
62
 
63
- `hub-app.json` declares how to run the app and what it offers: `manifest: 1`, `id`, `name`, `icon` (a Lucide name), `run.entry` (inside the folder; a path into `node_modules` is fine), `run.defaultPort`, `loading.verbs`, `publishes`, `actions.handles`, `provides`. The hub reads it before the app runs and skips the app, with a log line, when it breaks a rule. Page: `extensibility/reference/manifest/`.
63
+ `hub-app.json` declares how to run the app and what it offers: `manifest: 1`, `id`, `name`, `icon` (a Lucide name), `run.entry` (inside the folder; a path into `node_modules` is fine), `run.defaultPort`, `loading.verbs`, `publishes`, `actions.handles`, `provides`, `keys.keeps`, `plugin` (`{id, path, install}` of a Claude Code plugin the app ships; the app launcher shows its state). The hub reads it before the app runs and skips the app, with a log line, when it breaks a rule. Page: `extensibility/reference/manifest/`.
64
64
 
65
65
  The npm packages of the built-in tools do not ship `hub-app.json`. A patch or fork starts from the tool's manifest in its GitHub repo and changes `id`, `name` and `run.entry`. Page for what each built-in declares: `extensibility/reference/built-in/`.
66
66
 
package/public/app.js CHANGED
@@ -37,6 +37,8 @@ const missed = new Map();
37
37
  const MAX_EVENT_CHARS = 16 * 1024;
38
38
  // The config dir the hub falls back to (~/.claude). Stays out of the window title.
39
39
  let defaultConfigDir = null;
40
+ // The app launcher's footer: the hub version from /api/config and its plugin from /api/apps/stats.
41
+ const hubInfo = { version: null, plugin: null };
40
42
  // mode: 'project' (Ctrl+Alt+P), 'configDir' (Ctrl+Alt+W) or 'app' (Ctrl+Alt+A). One widget, three row sources.
41
43
  const palette = {
42
44
  open: false,
@@ -47,6 +49,7 @@ const palette = {
47
49
  sel: 0,
48
50
  query: '',
49
51
  confirmRestart: null,
52
+ message: null,
50
53
  };
51
54
 
52
55
  function loadThemeState() {
@@ -229,6 +232,7 @@ async function init() {
229
232
  const res = await fetch('/api/config');
230
233
  const config = await res.json();
231
234
  userKeys = config.keys ?? {};
235
+ hubInfo.version = config.version ?? null;
232
236
  setApps(config.apps);
233
237
  defaultConfigDir = config.defaultConfigDir ?? null;
234
238
  activeConfigDir = config.activeConfigDir ?? null;
@@ -816,7 +820,9 @@ function relAge(ts) {
816
820
  }
817
821
 
818
822
  async function loadAppStats() {
819
- palette.appStats = await sendJson('GET', '/api/apps/stats');
823
+ const { hub, apps: appStats } = await sendJson('GET', '/api/apps/stats');
824
+ palette.appStats = appStats;
825
+ hubInfo.plugin = hub.plugin;
820
826
  }
821
827
 
822
828
  async function loadConfigDirs() {
@@ -920,8 +926,52 @@ function rowButton(className, glyph, key, title, label) {
920
926
  return btn;
921
927
  }
922
928
 
923
- function setPaletteHint(text) {
924
- document.getElementById('palette-hint').textContent = text;
929
+ // The footer shows palette.message, set by an action, else the mode's hint.
930
+ function drawPaletteHint() {
931
+ const content = palette.message ?? PALETTE_MODES[palette.mode].hint ?? '';
932
+ document.getElementById('palette-hint').replaceChildren(...[].concat(content));
933
+ }
934
+
935
+ function setPaletteHint(message) {
936
+ palette.message = message;
937
+ drawPaletteHint();
938
+ }
939
+
940
+ // The plugin in the active config dir: its version, or a ⚠ that copies the install command.
941
+ function pluginNode(p) {
942
+ const problem = {
943
+ missing: { label: 'plugin not installed', cause: `${p.id} is not installed in this config dir.` },
944
+ disabled: {
945
+ label: `plugin ${p.installed} is off`,
946
+ cause: `${p.id} ${p.installed} is disabled. Enable it with /plugin in Claude Code.`,
947
+ noCopy: true,
948
+ },
949
+ mismatch: {
950
+ label: `plugin ${p.installed}`,
951
+ cause: `${p.id} ${p.installed} is installed, this version ships ${p.bundled}.`,
952
+ },
953
+ }[p.state];
954
+ if (!problem) return el('span', 'ver', `plugin ${p.installed}`);
955
+ const badge = el('button', 'plugin-warn', `⚠ ${problem.label}`);
956
+ badge.type = 'button';
957
+ badge.title = problem.cause;
958
+ badge.setAttribute('aria-label', problem.cause);
959
+ if (problem.noCopy) return badge;
960
+ badge.title += `\nClick to copy: ${p.installCommand}\nThen restart open sessions.`;
961
+ badge.addEventListener('click', (e) => {
962
+ e.stopPropagation();
963
+ copyToHint(p.installCommand);
964
+ });
965
+ return badge;
966
+ }
967
+
968
+ async function copyToHint(text) {
969
+ try {
970
+ await navigator.clipboard.writeText(text);
971
+ setPaletteHint(`Copied ${text}`);
972
+ } catch (err) {
973
+ setPaletteHint(`Could not copy ${text}: ${err.message}`);
974
+ }
925
975
  }
926
976
 
927
977
  // The terminal provider answers 409 with a terminal count; the second Ctrl+R on the same app sends force.
@@ -944,16 +994,6 @@ async function restartFromPalette(id) {
944
994
  switchTab(id);
945
995
  }
946
996
 
947
- async function copyAppUrl(id) {
948
- const url = apps[id].url;
949
- try {
950
- await navigator.clipboard.writeText(url);
951
- setPaletteHint(`Copied ${url}`);
952
- } catch (err) {
953
- setPaletteHint(`Could not copy ${url}: ${err.message}`);
954
- }
955
- }
956
-
957
997
  // `typed` is the trimmed query, `q` its lowercase form for matching.
958
998
  const PALETTE_MODES = {
959
999
  project: {
@@ -1019,7 +1059,12 @@ const PALETTE_MODES = {
1019
1059
  app: {
1020
1060
  placeholder: 'Search apps...',
1021
1061
  label: 'Switch app',
1022
- hint: 'Enter switch · Ctrl+C copy the URL · Ctrl+R restart the app',
1062
+ get hint() {
1063
+ const keys = 'Enter switch · Ctrl+C copy the URL · Ctrl+R restart the app';
1064
+ if (!hubInfo.version) return keys;
1065
+ const plugin = hubInfo.plugin ? [pluginNode(hubInfo.plugin), ' · '] : [];
1066
+ return [`Claude Code Hub ${hubInfo.version} · `, ...plugin, keys];
1067
+ },
1023
1068
  empty: 'No matching app',
1024
1069
  rows: (_typed, q) => appRows(q),
1025
1070
  load: loadAppStats,
@@ -1030,6 +1075,8 @@ const PALETTE_MODES = {
1030
1075
  fillRow(li, row, q) {
1031
1076
  li.append(appIcon(row.app), markedSpan('name', row.app.name, q), el('span', 'url', new URL(row.app.url).host));
1032
1077
  const s = palette.appStats?.[row.id];
1078
+ if (s?.version) li.append(el('span', 'ver', s.version));
1079
+ if (s?.plugin) li.append(pluginNode(s.plugin));
1033
1080
  if (s?.slowMs) {
1034
1081
  const badge = el('span', 'slow', 'slow port');
1035
1082
  badge.title = `Its port delays requests by up to ${(s.slowMs / 1000).toFixed(1)} s. Restart (Ctrl+R) moves it to a new port.`;
@@ -1046,7 +1093,7 @@ const PALETTE_MODES = {
1046
1093
  rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`),
1047
1094
  );
1048
1095
  },
1049
- rowKeys: { c: (row) => copyAppUrl(row.id), r: (row) => restartFromPalette(row.id) },
1096
+ rowKeys: { c: (row) => copyToHint(apps[row.id].url), r: (row) => restartFromPalette(row.id) },
1050
1097
  commit(row) {
1051
1098
  closePalette();
1052
1099
  switchTab(row.id);
@@ -1105,10 +1152,11 @@ function openPalette(mode) {
1105
1152
  palette.query = '';
1106
1153
  palette.sel = null;
1107
1154
  palette.confirmRestart = null;
1155
+ palette.message = null;
1108
1156
  input.value = '';
1109
1157
  input.placeholder = spec.placeholder;
1110
1158
  document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
1111
- setPaletteHint(spec.hint ?? '');
1159
+ drawPaletteHint();
1112
1160
  document.getElementById('palette').hidden = false;
1113
1161
  // Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
1114
1162
  // or take focus back, and then Escape is handled inside it — the palette stays open and only the
@@ -1119,7 +1167,9 @@ function openPalette(mode) {
1119
1167
  // Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
1120
1168
  Promise.resolve(spec.load?.())
1121
1169
  .then(() => {
1122
- if (palette.open && palette.mode === mode) renderPalette();
1170
+ if (!palette.open || palette.mode !== mode) return;
1171
+ drawPaletteHint();
1172
+ renderPalette();
1123
1173
  })
1124
1174
  .catch((err) => {
1125
1175
  if (err.status === 401) showErrorToast('Could not load the list', err);
package/public/index.html CHANGED
@@ -24,6 +24,7 @@
24
24
  --surface-hover: #1f2a4a;
25
25
  --text: #e8e8ef;
26
26
  --text-dim: #8a8fa3;
27
+ --warn: #f5a524;
27
28
  --shadow: rgba(0, 0, 0, 0.55);
28
29
  --ghost-mix: 22%;
29
30
  }
@@ -39,6 +40,7 @@
39
40
  --surface-hover: #f1f1f4;
40
41
  --text: #18181b;
41
42
  --text-dim: #71717a;
43
+ --warn: #8a4b00;
42
44
  --shadow: rgba(0, 0, 0, 0.18);
43
45
  --ghost-mix: 10%;
44
46
  }
@@ -283,11 +285,30 @@
283
285
  margin-left: 10px;
284
286
  padding: 0 6px;
285
287
  border-radius: 4px;
286
- color: #f5a524;
287
- border: 1px solid #f5a524;
288
+ color: var(--warn);
289
+ border: 1px solid var(--warn);
288
290
  font-size: 11px;
289
291
  }
290
292
 
293
+ .palette-row .ver,
294
+ .palette-hint .ver {
295
+ color: var(--text-dim);
296
+ font-size: 12px;
297
+ }
298
+
299
+ .plugin-warn {
300
+ padding: 0 6px;
301
+ border: 1px solid var(--warn);
302
+ border-radius: 4px;
303
+ background: transparent;
304
+ color: var(--warn);
305
+ font: inherit;
306
+ font-size: 11px;
307
+ cursor: pointer;
308
+ }
309
+
310
+ .palette-hint .plugin-warn { border: none; padding: 0; font-size: 12px; }
311
+
291
312
  .palette-row.literal {
292
313
  border-top: 1px solid var(--border);
293
314
  color: var(--accent);
package/server.js CHANGED
@@ -19,6 +19,8 @@ const { createPortHealth } = require('./lib/port-health');
19
19
  const { readRegistry, themeConfig } = require('./lib/themes');
20
20
  const { createUpdateCheck } = require('./lib/update-check');
21
21
  const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
22
+ const { HUB_PLUGIN } = require('./lib/install');
23
+ const { pluginChecker } = require('./lib/plugin-status');
22
24
  const { version: HUB_VERSION } = require('./package.json');
23
25
 
24
26
  function getArg(name) {
@@ -893,6 +895,7 @@ app.get('/api/config', (_req, res) => {
893
895
  keys: USER_KEYS,
894
896
  activeConfigDir: hubConfig.activeConfigDir,
895
897
  defaultConfigDir,
898
+ version: HUB_VERSION,
896
899
  });
897
900
  });
898
901
 
@@ -999,14 +1002,19 @@ const STATS_TIMEOUT_MS = 1000;
999
1002
  app.get('/api/apps/stats', async (_req, res) => {
1000
1003
  res.setHeader('Cache-Control', 'no-store');
1001
1004
  const pool = activePool();
1005
+ const check = pluginChecker(hubConfig.activeConfigDir);
1006
+ const plugin = (p) => (p ? check(p) : null);
1002
1007
  const entries = await Promise.all(
1003
1008
  ENABLED_APPS.map(async (a) => {
1004
1009
  const child = pool?.children.get(a.id);
1005
1010
  const slowMs = (child && portHealth.flagged(child)?.worstMs) ?? null;
1006
- return [a.id, { ...(await stats(child, STATS_TIMEOUT_MS)), slowMs }];
1011
+ return [
1012
+ a.id,
1013
+ { ...(await stats(child, STATS_TIMEOUT_MS)), slowMs, version: a.version, plugin: plugin(a.plugin) },
1014
+ ];
1007
1015
  }),
1008
1016
  );
1009
- res.json(Object.fromEntries(entries));
1017
+ res.json({ hub: { plugin: plugin(HUB_PLUGIN) }, apps: Object.fromEntries(entries) });
1010
1018
  });
1011
1019
 
1012
1020
  // Restarting the terminal provider ends its live terminals, so that takes force: true.