claude-code-hub 0.42.0 → 1.0.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 CHANGED
@@ -116,9 +116,14 @@ By default the hub runs all four tools. To turn a tool off or change the tab ord
116
116
  - The ids are `kanban`, `marketplace`, `cost` and `memory`.
117
117
  - The tools you list come first, in list order. The tools you do not list follow in the default order.
118
118
  - A tool with `"enabled": false` does not start and gets no tab. Links from other tools to it do nothing.
119
+ - `"port": 4543` sets the tool's port. See [Ports](https://nikiforovall.blog/claude-code-hub/reference/configuration/#ports) for the order and the busy-port fallback.
119
120
  - With Kanban off, the embedded terminal is off, and the project palette has no project list. You can still type a path.
120
121
  - Restart the hub after you edit the file.
121
122
 
123
+ ## Run a second hub
124
+
125
+ Give the second hub its own folder for the config file and token with `--hub-dir <path>` or `CLAUDE_HUB_DIR`. See [Run a second hub](https://nikiforovall.blog/claude-code-hub/reference/configuration/#run-a-second-hub).
126
+
122
127
  ## Embedded terminal
123
128
 
124
129
  Under the hub, Kanban has an embedded terminal, on by default, so you can start or resume Claude Code sessions from the board. While a terminal is attached, the hub asks before it closes the window, because <kbd>Ctrl</kbd>+<kbd>W</kbd> meant for the terminal would close it.
@@ -145,6 +150,7 @@ The hub has no visible UI of its own. Use these shortcuts:
145
150
  | <kbd>Alt</kbd>+<kbd>4</kbd> | Memory Diagnoser |
146
151
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd> | Project palette: set the project for all tools |
147
152
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>W</kbd> | Config-dir palette: switch, add, or remove a config dir |
153
+ | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd> | App launcher: switch to a tool, or restart it with <kbd>Ctrl</kbd>+<kbd>R</kbd> |
148
154
 
149
155
  <kbd>Alt</kbd>+<kbd>1</kbd> to <kbd>Alt</kbd>+<kbd>4</kbd> follow the tab order, so they change when you [choose the tools](#choose-the-tools).
150
156
 
package/lib/actions.js ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const PARAM_TYPES = new Set(['string', 'string?']);
4
+ const MODES = new Set(['url', 'message']);
5
+
6
+ function actionError(name, spec) {
7
+ if (name.startsWith('hub.')) return 'uses the hub. prefix';
8
+ if (typeof spec !== 'object' || spec === null) return 'is not an object';
9
+ if (spec.url !== undefined && typeof spec.url !== 'string') return 'has a url that is not a string';
10
+ if (spec.url?.includes('#')) return 'has a # in its url';
11
+ if (spec.mode !== undefined && !MODES.has(spec.mode)) return `has an unknown mode ${JSON.stringify(spec.mode)}`;
12
+ const params = spec.params ?? {};
13
+ if (typeof params !== 'object' || Object.values(params).some((t) => !PARAM_TYPES.has(t))) {
14
+ return 'has a param type that is not "string" or "string?"';
15
+ }
16
+ return null;
17
+ }
18
+
19
+ // Protocol §7: the first app in tab order that declares an action handles it.
20
+ function actionTable(apps, log = console.log) {
21
+ const table = {};
22
+ for (const app of apps) {
23
+ const handles = app.actions?.handles ?? {};
24
+ if (typeof handles !== 'object' || Array.isArray(handles)) {
25
+ log(`${app.id}: actions.handles is not an object, skipped`);
26
+ continue;
27
+ }
28
+ for (const [name, spec] of Object.entries(handles)) {
29
+ const err = actionError(name, spec);
30
+ if (err) log(`${app.id}: action "${name}" ${err}, skipped`);
31
+ else if (!Object.hasOwn(table, name)) {
32
+ table[name] = { app: app.id, params: spec.params ?? {}, url: spec.url, mode: spec.mode ?? 'url' };
33
+ }
34
+ }
35
+ }
36
+ return table;
37
+ }
38
+
39
+ module.exports = { actionTable };
package/lib/apps.js CHANGED
@@ -1,23 +1,150 @@
1
1
  'use strict';
2
2
 
3
- // Built-in apps in their default tab order.
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { isContained } = require('./contain');
6
+
7
+ // Built-in apps in their default tab order. The rest of each app comes from its manifest.
4
8
  const APPS = [
5
- { id: 'kanban', dir: 'cck', pkg: 'claude-code-kanban', name: 'Kanban', icon: 'columns', port: 3541 },
6
- { id: 'marketplace', dir: 'marketplace', pkg: 'claude-code-marketplace', name: 'Marketplace', icon: 'store', port: 3542 },
7
- { id: 'cost', dir: 'cost', pkg: 'claude-code-cost', name: 'Cost', icon: 'dollar-sign', port: 3543 },
8
- { id: 'memory', dir: 'memory', pkg: 'claude-code-memory-explorer', name: 'Memory Diagnoser', icon: 'database', port: 3544 },
9
+ { id: 'kanban', dir: 'cck', pkg: 'claude-code-kanban' },
10
+ { id: 'marketplace', dir: 'marketplace', pkg: 'claude-code-marketplace' },
11
+ { id: 'cost', dir: 'cost', pkg: 'claude-code-cost' },
12
+ { id: 'memory', dir: 'memory', pkg: 'claude-code-memory-explorer' },
9
13
  ];
10
14
 
15
+ const ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
16
+ const HUB_ROOT = path.join(__dirname, '..');
17
+
18
+ const validPort = (p) => Number.isInteger(p) && p > 0 && p < 65536;
19
+
11
20
  // `entries` is the hand-edited `apps` list in config.json. Listed apps come first in list order and
12
21
  // unlisted ones follow in default order, so an app added in a later release shows up without an edit.
13
- function selectApps(entries = []) {
22
+ // An entry with a `path` runs the app in that folder: a fork in place of a built-in app, or a new app.
23
+ // A relative `path` is resolved against `base`, the folder of config.json.
24
+ function selectApps(entries = [], base = process.cwd()) {
14
25
  const listed = entries.filter((e) => typeof e?.id === 'string');
15
- const byId = (id) => APPS.find((a) => a.id === id);
26
+ const entryOf = (id) => listed.find((e) => e.id === id);
27
+ const pathOf = (id) => {
28
+ const p = entryOf(id)?.path;
29
+ return typeof p === 'string' && p ? path.resolve(base, p) : undefined;
30
+ };
31
+ const byId = (id) => APPS.find((a) => a.id === id) ?? (pathOf(id) ? { id } : undefined);
16
32
  const unknown = listed.map((e) => e.id).filter((id) => !byId(id));
17
33
  const off = new Set(listed.filter((e) => e.enabled === false).map((e) => e.id));
18
34
  const order = new Set([...listed.map((e) => e.id), ...APPS.map((a) => a.id)]);
19
- const apps = [...order].map(byId).filter((a) => a && !off.has(a.id));
35
+ const apps = [...order]
36
+ .map(byId)
37
+ .filter((a) => a && !off.has(a.id))
38
+ .map((a) => ({ ...a, port: entryOf(a.id)?.port, path: pathOf(a.id) }));
20
39
  return { apps, unknown };
21
40
  }
22
41
 
23
- module.exports = { selectApps };
42
+ // Two enabled apps that claim the same capability, action or default port. The first in tab order
43
+ // wins each one, so a fork listed next to the app it replaces loses silently without these lines.
44
+ function conflicts(apps) {
45
+ const lines = [];
46
+ const seen = new Map();
47
+ const claim = (kind, key, id) => {
48
+ const first = seen.get(`${kind}\n${key}`);
49
+ if (first === undefined) seen.set(`${kind}\n${key}`, id);
50
+ else lines.push(`${id} and ${first} both declare ${kind} "${key}"; ${first} has it. Disable one of them.`);
51
+ };
52
+ for (const a of apps) {
53
+ for (const cap of Object.keys(a.provides ?? {})) claim('capability', cap, a.id);
54
+ for (const name of Object.keys(a.actions?.handles ?? {})) claim('action', name, a.id);
55
+ if (a.port) claim('port', String(a.port), a.id);
56
+ }
57
+ return lines;
58
+ }
59
+
60
+ function manifestError(m, id, root) {
61
+ if (m?.manifest !== 1) return `unknown manifest version ${JSON.stringify(m?.manifest)}`;
62
+ if (typeof m.id !== 'string' || !ID_RE.test(m.id)) return `id ${JSON.stringify(m.id)} does not match ${ID_RE}`;
63
+ if (m.id !== id) return `id "${m.id}" is not "${id}"`;
64
+ if (typeof m.run?.entry !== 'string') return 'run.entry is missing';
65
+ const entry = path.resolve(root, m.run.entry);
66
+ if (entry === path.resolve(root) || !isContained(entry, root)) return 'run.entry is outside the app directory';
67
+ return providesError(m.provides) ?? publishesError(m.publishes);
68
+ }
69
+
70
+ const TOPIC_RE = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
71
+ // The hub publishes these itself.
72
+ const HUB_TOPICS = new Set(['theme.changed', 'project.changed']);
73
+
74
+ function publishesError(publishes) {
75
+ if (publishes === undefined) return null;
76
+ if (!Array.isArray(publishes)) return 'publishes is not an array';
77
+ for (const t of publishes) {
78
+ if (typeof t !== 'string' || !TOPIC_RE.test(t)) return `publishes topic ${JSON.stringify(t)} does not match ${TOPIC_RE}`;
79
+ if (HUB_TOPICS.has(t) || t.startsWith('hub.')) return `publishes topic "${t}" is the hub's`;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // The hub fetches each path from the provider's own server.
85
+ const CAPABILITY_PATHS = { projects: 'path', terminal: 'liveWork' };
86
+
87
+ function providesError(provides) {
88
+ if (provides === undefined) return null;
89
+ if (typeof provides !== 'object' || provides === null || Array.isArray(provides)) return 'provides is not an object';
90
+ for (const [cap, key] of Object.entries(CAPABILITY_PATHS)) {
91
+ if (provides[cap] === undefined) continue;
92
+ const p = provides[cap]?.[key];
93
+ if (typeof p !== 'string' || !p.startsWith('/')) return `provides.${cap}.${key} is not a path`;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function readManifest(root, id) {
99
+ const m = JSON.parse(fs.readFileSync(path.join(root, 'hub-app.json'), 'utf8'));
100
+ const err = manifestError(m, id, root);
101
+ if (err) throw new Error(err);
102
+ return m;
103
+ }
104
+
105
+ // The entry's path, else the submodule when it is checked out, else the installed package.
106
+ function appRoot(app, hubRoot, resolve) {
107
+ if (app.path) return app.path;
108
+ const local = path.join(hubRoot, app.dir);
109
+ if (fs.existsSync(path.join(local, 'package.json'))) return local;
110
+ return path.dirname(resolve(`${app.pkg}/package.json`));
111
+ }
112
+
113
+ // The --<id>-port flag, then the config entry's port. Undefined when neither is set and valid.
114
+ function userPort(app, flagPort, log) {
115
+ const sources = [
116
+ [`--${app.id}-port`, flagPort == null ? undefined : Number(flagPort)],
117
+ [`"port" of "${app.id}" in config.json`, app.port],
118
+ ];
119
+ for (const [source, port] of sources) {
120
+ if (port === undefined) continue;
121
+ if (validPort(port)) return port;
122
+ log(`${source} is not a valid port, ignored`);
123
+ }
124
+ }
125
+
126
+ // Null, with a log line, when the app's hub-app.json is missing or not valid.
127
+ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve, log = console.log } = {}) {
128
+ const root = appRoot(app, hubRoot, resolve);
129
+ let manifest;
130
+ try {
131
+ manifest = readManifest(root, app.id);
132
+ } catch (e) {
133
+ const reason = e.code === 'ENOENT' ? 'not found' : e.message;
134
+ log(`${path.join(root, 'hub-app.json')}: ${reason}. ${app.id} is skipped`);
135
+ return null;
136
+ }
137
+ return {
138
+ id: app.id,
139
+ entry: path.join(root, manifest.run.entry),
140
+ port: userPort(app, flagPort, log) ?? manifest.run.defaultPort ?? 0,
141
+ name: manifest.name ?? app.id,
142
+ icon: manifest.icon,
143
+ loading: manifest.loading ?? {},
144
+ actions: manifest.actions ?? {},
145
+ provides: manifest.provides ?? {},
146
+ publishes: manifest.publishes ?? [],
147
+ };
148
+ }
149
+
150
+ module.exports = { APPS, selectApps, loadApp, manifestError, conflicts };
package/lib/contain.js ADDED
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ // Path containment check.
4
+ //
5
+ // The obvious implementation — `child.startsWith(root)` — is wrong twice over:
6
+ // it accepts a sibling directory that merely shares a prefix ("/plugins/foo-evil"
7
+ // passes a check for "/plugins/foo"), and it is blind to symlinks, `..`, casing
8
+ // and Windows 8.3 short names. This uses path.relative instead, and canonicalises
9
+ // both sides first.
10
+ //
11
+ // CANONICAL COPY: scripts/security-lib/contain.js in the claude-code-hub repo.
12
+ // Run scripts/sync-security-lib.sh after editing.
13
+
14
+ const path = require('node:path');
15
+ const fs = require('node:fs');
16
+
17
+ const IS_WIN = process.platform === 'win32';
18
+
19
+ // realpath the deepest existing ancestor, then re-append the non-existent tail,
20
+ // so a not-yet-created file under a symlinked directory still canonicalises.
21
+ function realpathDeepest(target) {
22
+ let cur = target;
23
+ const tail = [];
24
+ for (;;) {
25
+ try {
26
+ const real = fs.realpathSync.native(cur);
27
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
28
+ } catch {
29
+ const parent = path.dirname(cur);
30
+ if (parent === cur) return target; // reached the volume root, nothing resolvable
31
+ tail.push(path.basename(cur));
32
+ cur = parent;
33
+ }
34
+ }
35
+ }
36
+
37
+ function canonical(target, deepest) {
38
+ let out = path.resolve(target);
39
+ if (deepest) out = realpathDeepest(out);
40
+ else { try { out = fs.realpathSync.native(out); } catch { /* may not exist yet */ } }
41
+ // realpathSync.native has already expanded 8.3 short names (PROGRA~1) and fixed
42
+ // the casing of the existing portion; lowercase covers the rest.
43
+ return IS_WIN ? out.toLowerCase() : out;
44
+ }
45
+
46
+ function beneath(canonicalChild, root) {
47
+ const rel = path.relative(canonical(root, false), canonicalChild);
48
+ // '' means child === root. An absolute rel means a different drive or UNC share,
49
+ // which path.relative signals by giving up — reject those.
50
+ if (rel === '') return true;
51
+ if (path.isAbsolute(rel)) return false;
52
+ return rel !== '..' && !rel.startsWith(`..${path.sep}`);
53
+ }
54
+
55
+ /**
56
+ * True iff `child` is `root` itself or lies beneath it.
57
+ * @param {string} child
58
+ * @param {string} root
59
+ */
60
+ function isContained(child, root) {
61
+ if (typeof child !== 'string' || typeof root !== 'string' || !child || !root) return false;
62
+ return beneath(canonical(child, true), root);
63
+ }
64
+
65
+ /**
66
+ * True iff `child` is contained by any of `roots`. Canonicalises the child once
67
+ * instead of once per root — callers testing a single path against several roots
68
+ * were paying that walk repeatedly.
69
+ * @param {string} child
70
+ * @param {string[]} roots
71
+ */
72
+ function isContainedAny(child, roots) {
73
+ if (typeof child !== 'string' || !child || !Array.isArray(roots)) return false;
74
+ const c = canonical(child, true);
75
+ return roots.some((r) => typeof r === 'string' && r && beneath(c, r));
76
+ }
77
+
78
+ module.exports = { isContained, isContainedAny, realpathDeepest };
package/lib/install.js ADDED
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ const PLUGIN_SRC = path.join(__dirname, '..', 'plugin');
9
+ const MARKETPLACE = 'claude-code-hub';
10
+ const PLUGIN_ID = `claude-code-hub@${MARKETPLACE}`;
11
+
12
+ // Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but inside the
13
+ // dir when it is set, so the default dir must stay unset rather than be spelled out.
14
+ 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 };
17
+ }
18
+
19
+ // `idempotent` when the output names an ok pattern, on exit 0 too: some commands report "already" and succeed.
20
+ function run(cmd, env, okPatterns = []) {
21
+ const matches = (text) => okPatterns.some((p) => text.toLowerCase().includes(p));
22
+ try {
23
+ const output = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env }).trim();
24
+ return { ok: true, idempotent: matches(output), output };
25
+ } catch (e) {
26
+ const error = e.stderr?.trim() || e.stdout?.trim() || e.message;
27
+ if (matches(error)) return { ok: true, idempotent: true, output: '' };
28
+ return { ok: false, error };
29
+ }
30
+ }
31
+
32
+ // Deletes files but never directories: on Windows a running Claude Code process holds handles on
33
+ // the registered marketplace dirs, so removing one fails EPERM and leaves the copy half gone.
34
+ function clearFiles(dir) {
35
+ if (!fs.existsSync(dir)) return;
36
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
37
+ const target = path.join(dir, entry.name);
38
+ if (entry.isDirectory()) clearFiles(target);
39
+ else fs.rmSync(target, { force: true });
40
+ }
41
+ }
42
+
43
+ function report(label, result, idempotentText) {
44
+ if (result.ok) console.log(` ${label}: ${result.idempotent ? idempotentText : 'done'}`);
45
+ else console.log(` ${label}: failed\n ${result.error.split('\n').join('\n ')}`);
46
+ return result.ok;
47
+ }
48
+
49
+ // One copy of the plugin in the hub dir; each config dir registers that copy as a marketplace.
50
+ function runInstall({ hubDir, configDir }) {
51
+ const dest = path.join(hubDir, 'plugin');
52
+ const env = claudeEnv(configDir);
53
+ console.log(`\n claude-code-hub plugin install\n Config dir: ${configDir}\n Plugin copy: ${dest}\n`);
54
+
55
+ if (!run('claude --version', env).ok) {
56
+ console.log(' claude CLI not found. Install Claude Code first.');
57
+ return false;
58
+ }
59
+
60
+ clearFiles(dest);
61
+ fs.cpSync(PLUGIN_SRC, dest, { recursive: true });
62
+ console.log(' Copy plugin: done');
63
+
64
+ let ok = report('Register marketplace', run(`claude plugin marketplace add "${dest}"`, env, ['already']), 'already registered');
65
+ // Claude caches the marketplace manifest, so a re-copied plugin stays invisible until refreshed.
66
+ ok = report('Refresh marketplace', run(`claude plugin marketplace update ${MARKETPLACE}`, env)) && ok;
67
+
68
+ const install = run(`claude plugin install ${PLUGIN_ID}`, env, ['already installed']);
69
+ ok = report('Install plugin', install, 'already installed') && ok;
70
+ // `install` exits 0 on a plugin that is present and keeps the cached old version; `update` loads the new copy.
71
+ if (install.idempotent) {
72
+ ok = report('Update plugin', run(`claude plugin update ${PLUGIN_ID}`, env, ['already at the latest']), 'already at the latest version') && ok;
73
+ }
74
+
75
+ console.log(ok ? '\n Done. Start a new Claude Code session to load the hub-builder skill.\n' : '\n Install incomplete, see the errors above.\n');
76
+ return ok;
77
+ }
78
+
79
+ // Keeps the plugin copy in the hub dir, because other config dirs can still point at it.
80
+ function runUninstall({ configDir }) {
81
+ const env = claudeEnv(configDir);
82
+ console.log(`\n claude-code-hub plugin uninstall\n Config dir: ${configDir}\n`);
83
+ let ok = report('Remove plugin', run(`claude plugin uninstall ${PLUGIN_ID}`, env, ['not found', 'not installed']), 'not installed');
84
+ ok = report('Remove marketplace', run(`claude plugin marketplace remove ${MARKETPLACE}`, env, ['not found', 'not configured']), 'not registered') && ok;
85
+ console.log(ok ? '\n Done.\n' : '\n Uninstall incomplete, see the errors above.\n');
86
+ return ok;
87
+ }
88
+
89
+ module.exports = { runInstall, runUninstall, claudeEnv };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ const { spawn: nodeSpawn } = require('node:child_process');
3
+
4
+ const KILL_GRACE_MS = 3000;
5
+
6
+ const exited = (child) => child.exitCode !== null || child.signalCode !== null;
7
+
8
+ // POSIX: SIGTERM lets an app run its exit handlers (cck removes its server.json and ends its
9
+ // terminals), but a handler on a frozen event loop never runs, so SIGKILL follows. Windows has no
10
+ // SIGTERM for a Node child: taskkill /f /t ends it and the processes it started.
11
+ // Resolves when the child has exited, or after graceMs (with SIGKILL sent on POSIX).
12
+ function killChild(child, { platform = process.platform, spawn = nodeSpawn, graceMs = KILL_GRACE_MS } = {}) {
13
+ if (exited(child)) return Promise.resolve();
14
+ const done = new Promise((resolve) => {
15
+ const timer = setTimeout(() => {
16
+ if (platform !== 'win32' && !exited(child)) child.kill('SIGKILL');
17
+ resolve();
18
+ }, graceMs);
19
+ timer.unref();
20
+ child.once('exit', () => {
21
+ clearTimeout(timer);
22
+ resolve();
23
+ });
24
+ });
25
+ if (child.hubKilled) return done;
26
+ child.hubKilled = true;
27
+ if (platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
28
+ else child.kill('SIGTERM');
29
+ return done;
30
+ }
31
+
32
+ module.exports = { killChild, KILL_GRACE_MS };
package/lib/net-guard.js CHANGED
@@ -36,6 +36,7 @@ const PORT_RE = /^[0-9]{1,5}$/;
36
36
  // The port is validated but not compared — the listen(0) fallbacks make the real
37
37
  // port dynamic, so only the host is allowlisted.
38
38
  const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
39
+ const KEEP_ALIVE_MS = 65000;
39
40
 
40
41
  function isLoopbackAddress(host) {
41
42
  if (!host) return false;
@@ -249,6 +250,10 @@ function createNetGuard(config = {}) {
249
250
  const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
250
251
  const makeServer = (handler) => {
251
252
  const s = http.createServer(serverOpts, handler);
253
+ // Node's 5 s default closes idle sockets that the hub proxy and the browser would reuse, and a
254
+ // fresh loopback connect is the one that some Windows network filters drop. The hub's agent
255
+ // drops its idle sockets before this, so it never reuses one the server is closing.
256
+ s.keepAliveTimeout = KEEP_ALIVE_MS;
252
257
  if (opts.onUpgrade) s.on('upgrade', opts.onUpgrade);
253
258
  return s;
254
259
  };
@@ -281,4 +286,4 @@ function createNetGuard(config = {}) {
281
286
  return { BIND_HOST, ALLOWED_HOSTS, EXPOSED, hostGuard, originGuard, upgradeVerdict, frameGuard, listenLoopback, exposureWarning };
282
287
  }
283
288
 
284
- module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
289
+ module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress, KEEP_ALIVE_MS };
package/lib/ping.js ADDED
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ let seq = 0;
4
+
5
+ // Resolves true when the child answers hub:ping over its IPC channel within timeoutMs. The channel
6
+ // does not use TCP, so a loopback connect failure does not stop the answer.
7
+ function ping(child, timeoutMs) {
8
+ if (!child?.connected) return Promise.resolve(false);
9
+ const id = ++seq;
10
+ return new Promise((resolve) => {
11
+ const done = (ok) => {
12
+ clearTimeout(timer);
13
+ child.off('message', onMessage);
14
+ resolve(ok);
15
+ };
16
+ const onMessage = (m) => m?.type === 'hub:pong' && m.id === id && done(true);
17
+ const timer = setTimeout(() => done(false), timeoutMs);
18
+ child.on('message', onMessage);
19
+ child.send({ type: 'hub:ping', id }, (err) => err && done(false));
20
+ });
21
+ }
22
+
23
+ module.exports = { ping };
package/lib/themes.js ADDED
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const THEMES_FILE = path.join(__dirname, 'themes.json');
7
+
8
+ const readThemes = (file = THEMES_FILE) => JSON.parse(fs.readFileSync(file, 'utf8'));
9
+
10
+ const hexToRgb = (hex) => {
11
+ const n = parseInt(hex.slice(1), 16);
12
+ return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
13
+ };
14
+
15
+ const glow = (color, mode) =>
16
+ /^#[0-9a-f]{6}$/i.test(color)
17
+ ? `rgba(${hexToRgb(color)}, ${mode === 'dark' ? '0.55' : '0.5'})`
18
+ : `color-mix(in srgb, ${color} ${mode === 'dark' ? '55%' : '50%'}, transparent)`;
19
+
20
+ // Palette role → shared CSS variable. --accent-glow is mechanical (rgba of the accent), mirroring
21
+ // how each app's :root derives it from --accent.
22
+ const coreVars = (p, mode) => ({
23
+ '--accent': p.ember,
24
+ '--accent-text': p.emberGlow,
25
+ '--accent-dim': p.emberDim,
26
+ '--accent-glow': glow(p.ember, mode),
27
+ '--bg-deep': p.field,
28
+ '--bg-surface': p.surface,
29
+ '--bg-elevated': p.elevated,
30
+ '--bg-hover': p.hover,
31
+ '--border': p.border,
32
+ '--text-primary': p.ink1,
33
+ '--text-secondary': p.ink2,
34
+ '--text-tertiary': p.ink3,
35
+ '--text-muted': p.inkMuted,
36
+ ...(p.sidebar && { '--sidebar-bg': p.sidebar }),
37
+ ...(p.sidebarItem && { '--sidebar-item-bg': p.sidebarItem }),
38
+ });
39
+
40
+ const swatch = (p) => ({ bg: p.surface, accent: p.ember, ink: p.ink1, border: p.border });
41
+
42
+ const modes = (t, fn) => ({ dark: fn(t.dark, 'dark'), light: fn(t.light, 'light') });
43
+
44
+ const MODES = ['dark', 'light'];
45
+ const ROLES = ['ember', 'emberGlow', 'emberDim', 'field', 'surface', 'elevated', 'hover', 'border', 'ink1', 'ink2', 'ink3', 'inkMuted'];
46
+ const OPTIONAL_ROLES = ['sidebar', 'sidebarItem'];
47
+ const THEME_ID = /^[a-z0-9-]{1,32}$/;
48
+ // The SDK writes these values into inline styles and a style sheet, so no url(), quotes, semicolons or nested parens.
49
+ const COLOR = /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|(rgba?|hsla?|oklch)\([\w\s.,%/+-]*\))$/i;
50
+
51
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
52
+
53
+ // Why a user theme cannot be used, or null. `builtins` maps id → built-in theme.
54
+ function themeProblem(e, builtins, seen) {
55
+ if (!isObject(e)) return 'not an object';
56
+ if (typeof e.id !== 'string' || !THEME_ID.test(e.id)) return 'id must match ^[a-z0-9-]{1,32}$';
57
+ if (seen.has(e.id)) return 'an earlier theme in the file has this id';
58
+ if (e.label !== undefined && typeof e.label !== 'string') return 'label must be a string';
59
+ if (e.extends !== undefined) {
60
+ if (builtins.has(e.id)) return 'a built-in id cannot take extends';
61
+ if (!builtins.has(e.extends)) return `extends names no built-in theme: ${e.extends}`;
62
+ }
63
+ const based = builtins.has(e.id) || e.extends !== undefined;
64
+ for (const mode of MODES) {
65
+ if (e[mode] === undefined && based) continue;
66
+ if (!isObject(e[mode])) return `${mode} must be an object of colors`;
67
+ for (const [role, value] of Object.entries(e[mode])) {
68
+ if (!ROLES.includes(role) && !OPTIONAL_ROLES.includes(role)) return `${mode}.${role} is not a color role`;
69
+ if (typeof value !== 'string' || !COLOR.test(value)) return `${mode}.${role} is not a color: ${value}`;
70
+ }
71
+ const missing = based ? [] : ROLES.filter((r) => !(r in e[mode]));
72
+ if (missing.length) return `${mode} is missing ${missing.join(', ')}`;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // The built-in list with the user's themes applied: a built-in id changes that theme in place, a
78
+ // new id is added at the end. A theme that breaks a rule is skipped, and `warn` says why.
79
+ function withUserThemes(themes, entries, warn) {
80
+ const builtins = new Map(themes.map((t) => [t.id, t]));
81
+ const out = new Map(builtins);
82
+ const seen = new Set();
83
+ entries.forEach((e, i) => {
84
+ const problem = themeProblem(e, builtins, seen);
85
+ if (problem) {
86
+ warn(`[themes] skipped ${typeof e?.id === 'string' ? `"${e.id}"` : `entry ${i + 1}`}: ${problem}`);
87
+ return;
88
+ }
89
+ seen.add(e.id);
90
+ const base = builtins.get(e.id) ?? builtins.get(e.extends);
91
+ out.set(e.id, {
92
+ id: e.id,
93
+ label: e.label ?? base?.label ?? e.id,
94
+ dark: { ...base?.dark, ...e.dark },
95
+ light: { ...base?.light, ...e.light },
96
+ });
97
+ });
98
+ return [...out.values()];
99
+ }
100
+
101
+ function readUserThemes(file, warn) {
102
+ let entries;
103
+ try {
104
+ entries = JSON.parse(fs.readFileSync(file, 'utf8'));
105
+ } catch (err) {
106
+ if (err.code !== 'ENOENT') warn(`[themes] skipped ${file}: ${err.message}`);
107
+ return [];
108
+ }
109
+ if (Array.isArray(entries)) return entries;
110
+ warn(`[themes] skipped ${file}: it must hold a JSON array of themes`);
111
+ return [];
112
+ }
113
+
114
+ function readRegistry(file = THEMES_FILE) {
115
+ try {
116
+ return readThemes(file);
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ // What /api/config carries. An unreadable registry gives empty maps: the hub keeps index.html's
123
+ // colors and sends no vars, so each app uses its own theme CSS. `userFile` is the user's
124
+ // <hub-dir>/themes.json, read on each call.
125
+ function themeConfig(registry = readRegistry(), { userFile, warn = console.warn } = {}) {
126
+ if (!registry) return { themes: [], themeVars: {} };
127
+ const themes = userFile ? withUserThemes(registry, readUserThemes(userFile, warn), warn) : registry;
128
+ return {
129
+ themes: themes.map((t) => ({ id: t.id, label: t.label, swatch: modes(t, swatch) })),
130
+ themeVars: Object.fromEntries(themes.map((t) => [t.id, modes(t, coreVars)])),
131
+ };
132
+ }
133
+
134
+ module.exports = { readThemes, readRegistry, coreVars, swatch, themeConfig, ROLES };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.42.0",
3
+ "version": "1.0.1",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -9,14 +9,15 @@
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
11
  "dev": "node server.js --open",
12
- "test": "node --test test/*.test.js",
12
+ "test": "node --test test/*.test.js packages/claude-hub-sdk/test/*.test.js",
13
13
  "update": "bash scripts/update-submodules.sh",
14
14
  "update:check": "bash scripts/update-submodules.sh --check",
15
- "lint": "npx @biomejs/biome check public/app.js server.js && npm run check:escaping && npm run check:security-lib",
16
- "lint:fix": "npx @biomejs/biome check --fix public/app.js server.js",
15
+ "lint": "npx @biomejs/biome check public/app.js server.js packages/claude-hub-sdk && npm run check:escaping && npm run check:security-lib",
16
+ "lint:fix": "npx @biomejs/biome check --fix public/app.js server.js packages/claude-hub-sdk",
17
17
  "prepare": "husky",
18
18
  "check:escaping": "node scripts/check-escaping.mjs",
19
19
  "check:security-lib": "bash scripts/sync-security-lib.sh --check",
20
+ "sdk:sync": "node scripts/sdk-sync.mjs",
20
21
  "perf": "node scripts/perf.mjs run",
21
22
  "perf:compare": "node scripts/perf.mjs compare",
22
23
  "perf:report": "node scripts/perf.mjs report",
@@ -44,10 +45,10 @@
44
45
  },
45
46
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
46
47
  "dependencies": {
47
- "claude-code-cost": "^0.26.0",
48
- "claude-code-kanban": "^4.31.1",
49
- "claude-code-marketplace": "^0.22.0",
50
- "claude-code-memory-explorer": "^0.17.0",
48
+ "claude-code-cost": "1.0.0",
49
+ "claude-code-kanban": "5.1.0",
50
+ "claude-code-marketplace": "1.0.0",
51
+ "claude-code-memory-explorer": "1.0.0",
51
52
  "express": "^4.21.0",
52
53
  "open": "^10.1.0"
53
54
  },
@@ -57,7 +58,9 @@
57
58
  "files": [
58
59
  "server.js",
59
60
  "public/**/*",
60
- "lib/**/*"
61
+ "lib/**/*",
62
+ "plugin/**/*",
63
+ "packages/claude-hub-sdk/src/**/*"
61
64
  ],
62
65
  "devDependencies": {
63
66
  "@biomejs/biome": "^2.4.10",