claude-code-hub 1.27.2 → 1.29.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/ext.js CHANGED
@@ -3,11 +3,13 @@
3
3
  const { execFileSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const tty = require('node:tty');
6
7
  const { parseArgs } = require('node:util');
7
8
  const { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, readApp } = require('./apps');
8
9
  const { isContained } = require('./contain');
9
10
  const { readConfig, writeConfigFile } = require('./hub-config');
10
11
  const { claudeEnv, hasClaude, installPlugin, reinstallPlugin, uninstallPlugin, report, run } = require('./install');
12
+ const { BUNDLED, bundledRoot, entryRoot, readMod } = require('./mods');
11
13
  const { pluginChecker } = require('./plugin-status');
12
14
  const { isNewer } = require('./update-check');
13
15
 
@@ -27,7 +29,17 @@ const USAGE = `Usage: claude-code-hub ext <command>
27
29
 
28
30
  A feature the hub ships (voice) is turned on by ext install <id> and off by ext remove <id>.
29
31
 
32
+ A mod (mod.json) adds CSS and JS to the hub page and the apps, and can bring color themes.
33
+ The hub ships some mods (xmas): ext install <id> finds them before the registry.
34
+ Open the hub at /?nomods to load it with no mod. "modsEnabled": false in config.json
35
+ or --no-mods turns every mod off.
36
+
30
37
  --hub-dir <path> The hub dir (default CLAUDE_HUB_DIR, else ~/.claude-hub)
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
31
43
  --dir <path> The config dir for plugin and dock steps (default CLAUDE_CONFIG_DIR, else ~/.claude)`;
32
44
 
33
45
  const RESTART = ' Restart the hub to load the change.';
@@ -67,11 +79,14 @@ function managedEntry(config, id, verb) {
67
79
  const found = extEntry(config, id);
68
80
  const [entry] = found;
69
81
  const { source } = entry;
70
- if (source !== 'local' && !isGithub(entry)) throw new Error(`${id} has source "${source}": only a local or GitHub extension can ${verb}.`);
82
+ if (source === 'bundled' && verb === 'update') throw new Error(`${id} ships with the hub: it updates when the hub does.`);
83
+ if (source !== 'local' && source !== 'bundled' && !isGithub(entry)) {
84
+ throw new Error(`${id} has source "${source}": only a local, bundled or GitHub extension can ${verb}.`);
85
+ }
71
86
  return found;
72
87
  }
73
88
 
74
- const rootOf = (ctx, entry) => path.resolve(ctx.hubDir, entry.path);
89
+ const rootOf = (ctx, entry) => entryRoot(entry, ctx.hubDir, ctx.bundled);
75
90
 
76
91
  const GITHUB_RE = /^github:([\w.-]+)\/([\w.-]+)((?:\/[\w.-]+)*)(?:@(\w[\w./-]*))?$/;
77
92
 
@@ -236,6 +251,10 @@ const KINDS = {
236
251
  file: 'hub-app.json',
237
252
  read: readApp,
238
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
+ ],
239
258
  stepName: 'Plugin install',
240
259
  removeName: 'Plugin removal',
241
260
  unread: 'The plugin is not removed, because the manifest does not name it.',
@@ -249,6 +268,7 @@ const KINDS = {
249
268
  file: 'addon.json',
250
269
  read: readAddon,
251
270
  step: dockStep,
271
+ code: (ext) => (ext.manifest.run ? [['a server Kanban starts', 'a server']] : []),
252
272
  stepName: 'Putting it on the dock',
253
273
  removeName: 'Taking it off the dock',
254
274
  unread: 'It is taken off the dock by its id.',
@@ -257,9 +277,66 @@ const KINDS = {
257
277
  updated: 'Restart its server (Ctrl+R on its row in the app launcher) to load the new code.',
258
278
  noPlugin: 'dock add-on',
259
279
  },
280
+ mod: {
281
+ list: 'mods',
282
+ file: 'mod.json',
283
+ read: readMod,
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
+ },
290
+ removeName: 'Removing the mod',
291
+ unread: 'It is removed by its id.',
292
+ installed: RESTART,
293
+ removed: RESTART,
294
+ updated: 'Restart the hub to load the new code.',
295
+ noPlugin: 'mod',
296
+ },
297
+ };
298
+
299
+ const kindOf = (root) => {
300
+ if (fs.existsSync(path.join(root, 'mod.json'))) return KINDS.mod;
301
+ return fs.existsSync(path.join(root, 'addon.json')) ? KINDS.addon : KINDS.app;
260
302
  };
261
303
 
262
- const kindOf = (root) => (fs.existsSync(path.join(root, 'addon.json')) ? KINDS.addon : KINDS.app);
304
+ const jsTargets = (manifest) => Object.keys(manifest.targets).filter((t) => manifest.targets[t].js?.length);
305
+
306
+ // The answer typed on the terminal, or null when there is no terminal to ask on. isatty, not
307
+ // process.stdin.isTTY: that getter opens a tty.ReadStream, which on macOS and Linux makes fd 0
308
+ // non-blocking, and readSync then throws EAGAIN.
309
+ function askTerminal(question) {
310
+ if (!tty.isatty(0)) return null;
311
+ process.stdout.write(question);
312
+ const buf = Buffer.alloc(256);
313
+ try {
314
+ return buf.toString('utf8', 0, fs.readSync(0, buf, 0, buf.length, null)).trim();
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+
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')}`);
327
+ if (ctx.yes) return true;
328
+ const answer = (ctx.ask ?? askTerminal)(` ${verb} it? [y/N] `);
329
+ if (answer === null) {
330
+ console.log(`\n There is no terminal to ask on. Pass --yes to ${verb.toLowerCase()} it.`);
331
+ return false;
332
+ }
333
+ if (/^y(es)?$/i.test(answer)) return true;
334
+ console.log(`\n ${refused}`);
335
+ return false;
336
+ }
337
+
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.` });
263
340
 
264
341
  // An id the hub runs gets its plugin installed. A folder is added to the hub, with its plugin, unless the
265
342
  // hub already runs it from there; then only the plugin is installed. Any other name is looked up in the registry.
@@ -274,6 +351,8 @@ function install(ctx, target) {
274
351
  if (addon) return dockStep(ctx, target, null, 'install', rootOf(ctx, addon), config);
275
352
  const feature = readFeature(target, ctx.features);
276
353
  if (feature) return setFeature(ctx, feature, true, file, config);
354
+ const bundled = !fs.existsSync(target) && bundledRoot(target, ctx.bundled);
355
+ if (bundled) return installBundled(ctx, target, bundled, file, config);
277
356
  if (!fs.existsSync(target) && ID_RE.test(target)) return installRegistry(ctx, target, file, config);
278
357
  return installFolder(ctx, target, file, config);
279
358
  }
@@ -394,6 +473,10 @@ function installGithub(ctx, target, file, config, name) {
394
473
  return true;
395
474
  }
396
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
+ }
397
480
  } catch (e) {
398
481
  removeDir(dir);
399
482
  throw e;
@@ -415,6 +498,21 @@ function installGithub(ctx, target, file, config, name) {
415
498
  return true;
416
499
  }
417
500
 
501
+ function installBundled(ctx, id, root, file, config) {
502
+ const kind = kindOf(root);
503
+ const ext = kind.read(root, id);
504
+ const entry = claimId(id, config, file, kind, (e) => e.source === 'bundled');
505
+ checkRequires(ctx, ext.manifest, config);
506
+ if (entry) {
507
+ if (!kind.step(ctx, id, ext, 'install', root, config)) return false;
508
+ console.log(`\n ${id} is already in the hub.`);
509
+ return true;
510
+ }
511
+ if (!addExtension(ctx, root, file, config, { source: 'bundled' }, kind)) return false;
512
+ console.log(`\n Installed ${id}, which ships with the hub.\n${kind.installed}`);
513
+ return true;
514
+ }
515
+
418
516
  function installAppPlugin(ctx, app) {
419
517
  const { root, plugin: p } = appPlugin(app);
420
518
  if (!p) throw new Error(`${app.id} ships no plugin.`);
@@ -442,6 +540,7 @@ function installFolder(ctx, target, file, config) {
442
540
  return true;
443
541
  }
444
542
  const linked = root.replaceAll('\\', '/');
543
+ if (!askInstall(ctx, id, kind, ext, linked)) return false;
445
544
  if (!addExtension(ctx, root, file, config, { path: linked, source: 'local' }, kind)) return false;
446
545
  console.log(`\n Linked ${id} to ${linked}.\n${kind.installed}`);
447
546
  return true;
@@ -475,25 +574,35 @@ function update(ctx, id) {
475
574
  const [entry, kind] = managedEntry(config, id, 'update');
476
575
  const root = rootOf(ctx, entry);
477
576
  let sha;
577
+ let ext;
478
578
  if (isGithub(entry)) {
479
579
  const clone = cloneOf(ctx, entry);
580
+ const was = git(['rev-parse', 'HEAD'], clone);
480
581
  git(['fetch', '--quiet', 'origin'], clone);
481
582
  sha = checkout(clone, entry.ref);
482
- const back = () => entry.sha && entry.sha !== sha && checkout(clone, entry.sha);
583
+ const back = () => was !== sha && checkout(clone, was);
584
+ let allowed = true;
483
585
  try {
484
- checkRequires(ctx, kind.read(root, id).manifest, config);
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
+ }
485
593
  } catch (e) {
486
594
  back();
487
595
  throw e;
488
596
  }
489
- if (!installDeps(ctx, root)) {
597
+ if (!allowed || !installDeps(ctx, root)) {
490
598
  back();
491
599
  return false;
492
600
  }
493
601
  } else {
494
- checkRequires(ctx, kind.read(root, id).manifest, config);
602
+ ext = kind.read(root, id);
603
+ checkRequires(ctx, ext.manifest, config);
495
604
  }
496
- 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;
497
606
  if (sha && sha !== entry.sha) writeConfigFile(file, withList(config, kind, (list) => list.map((e) => (e === entry ? { ...e, sha } : e))));
498
607
  const at = sha ? ` to ${short(sha)}` : '';
499
608
  console.log(`\n Updated ${id}${at}. ${kind.updated}`);
@@ -523,7 +632,8 @@ function remove(ctx, id) {
523
632
  if (removeDir(clone)) console.log(`\n Removed ${id} and its clone.\n${kind.removed}`);
524
633
  return true;
525
634
  }
526
- console.log(`\n Removed ${id}. ${entry.path} is left in place.\n${kind.removed}`);
635
+ const kept = entry.path ? ` ${entry.path} is left in place.` : '';
636
+ console.log(`\n Removed ${id}.${kept}\n${kind.removed}`);
527
637
  return true;
528
638
  }
529
639
 
@@ -560,18 +670,19 @@ const COMMANDS = { install, list, update, remove };
560
670
  // ctx: {hubDir, configDir: () => dir for plugin steps, hubVersion, nodeVersion?, exec?, githubBase?, registry?: URL or file}.
561
671
  function runExt(argv, ctx) {
562
672
  const registry = process.env.CLAUDE_HUB_REGISTRY || REGISTRY;
563
- const full = { nodeVersion: process.versions.node, exec: run, githubBase: 'https://github.com', registry, ...ctx };
673
+ const full = { nodeVersion: process.versions.node, exec: run, githubBase: 'https://github.com', registry, bundled: BUNDLED, ...ctx };
564
674
  let parsed;
565
675
  try {
566
676
  parsed = parseArgs({
567
677
  args: argv,
568
678
  allowPositionals: true,
569
- options: { dir: { type: 'string' }, 'hub-dir': { type: 'string' }, help: { type: 'boolean', short: 'h' } },
679
+ options: { dir: { type: 'string' }, 'hub-dir': { type: 'string' }, yes: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
570
680
  });
571
681
  } catch (e) {
572
682
  console.error(`${e.message}\n\n${USAGE}`);
573
683
  return false;
574
684
  }
685
+ full.yes = parsed.values.yes === true;
575
686
  const [name, target] = parsed.positionals;
576
687
  const command = COMMANDS[name];
577
688
  if (!command || parsed.values.help) {
package/lib/mods.js ADDED
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { HUB_ROOT, ID_RE } = require('./apps');
7
+ const { isContained } = require('./contain');
8
+
9
+ // Extensions the hub ships. `ext install <id>` finds them before the registry.
10
+ const BUNDLED = path.join(HUB_ROOT, 'extensions');
11
+
12
+ // A mod file is served at /mods/<id>/<file>, so its name must be a plain relative URL path.
13
+ const FILE_RE = /^[\w-][\w.-]*(\/[\w-][\w.-]*)*$/;
14
+ const TYPES = { css: '.css', js: '.js' };
15
+
16
+ function modProblem(m, root) {
17
+ if (m?.kind !== 'mod' || !ID_RE.test(m.id ?? '')) return 'a mod manifest needs "kind": "mod" and an id of lowercase letters, digits and dashes';
18
+ if (m.themes !== undefined && !Array.isArray(m.themes)) return 'themes must be a list of themes';
19
+ if (!m.targets || typeof m.targets !== 'object' || Array.isArray(m.targets)) return 'targets must be an object keyed by "hub" or an app id';
20
+ for (const [target, parts] of Object.entries(m.targets)) {
21
+ if (!ID_RE.test(target)) return `targets.${target} is not "hub" or an app id`;
22
+ if (!parts || typeof parts !== 'object') return `targets.${target} must be an object of css and js lists`;
23
+ if (!Number.isInteger(parts.surface) || parts.surface < 1) {
24
+ return `targets.${target}.surface must be the version of the ${target} mod surface its files are written for`;
25
+ }
26
+ for (const [type, files] of Object.entries(parts)) {
27
+ if (type === 'surface') continue;
28
+ const where = `targets.${target}.${type}`;
29
+ if (!TYPES[type]) return `${where}: a target takes css and js only`;
30
+ if (!Array.isArray(files)) return `${where} must be a list of files`;
31
+ for (const f of files) {
32
+ if (typeof f !== 'string' || !FILE_RE.test(f) || !f.endsWith(TYPES[type])) return `${where}: "${f}" is not a relative ${TYPES[type]} path`;
33
+ const abs = path.join(root, f);
34
+ if (!isContained(abs, root)) return `${where}: ${f} is outside the mod folder`;
35
+ if (!fs.statSync(abs, { throwIfNoEntry: false })?.isFile()) return `${where}: ${f} is not found`;
36
+ }
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function readMod(root, id) {
43
+ const file = path.join(root, 'mod.json');
44
+ let raw;
45
+ let manifest;
46
+ try {
47
+ raw = fs.readFileSync(file, 'utf8');
48
+ manifest = JSON.parse(raw);
49
+ } catch (e) {
50
+ throw new Error(`${file}: ${e.code === 'ENOENT' ? 'not found' : e.message}`);
51
+ }
52
+ const problem = modProblem(manifest, root);
53
+ if (problem) throw new Error(`${file}: ${problem}`);
54
+ if (id && manifest.id !== id) throw new Error(`${file}: its id is ${manifest.id}, not ${id}`);
55
+ return { manifest, raw };
56
+ }
57
+
58
+ // A bundled entry has no path: it follows the hub's own copy, so it moves with each hub upgrade.
59
+ const entryRoot = (entry, hubDir, bundled = BUNDLED) =>
60
+ entry.source === 'bundled' ? path.join(bundled, entry.id) : path.resolve(hubDir, entry.path);
61
+
62
+ const bundledRoot = (id, bundled = BUNDLED) => {
63
+ if (!ID_RE.test(id ?? '')) return null;
64
+ const root = path.join(bundled, id);
65
+ return fs.existsSync(root) ? root : null;
66
+ };
67
+
68
+ // The `surface` number in <root>/mod-surface.json: the version of the names a mod may use on that
69
+ // page. Null when the file is missing or has none.
70
+ function readSurface(root) {
71
+ try {
72
+ const { surface } = JSON.parse(fs.readFileSync(path.join(root, 'mod-surface.json'), 'utf8'));
73
+ return Number.isInteger(surface) ? surface : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // The mods of `config.mods`, read once at hub start. `version` changes with any file of the mod,
80
+ // so the browser fetches the new files after a hub restart and caches them until then.
81
+ // `surfaces` maps "hub" and each enabled app id to its surface version, or null when it has none.
82
+ // A target whose page is not running is left out; one written for another surface is left out with a log line.
83
+ function resolveMods(config, hubDir, { warn, surfaces, bundled = BUNDLED, off = false }) {
84
+ if (off) return [];
85
+ const out = [];
86
+ for (const entry of config.mods ?? []) {
87
+ if (typeof entry?.id !== 'string' || entry.enabled === false) continue;
88
+ const root = entryRoot(entry, hubDir, bundled);
89
+ let manifest;
90
+ let raw;
91
+ try {
92
+ ({ manifest, raw } = readMod(root, entry.id));
93
+ } catch (e) {
94
+ warn(`[mods] skipped ${entry.id}: ${e.message}`);
95
+ continue;
96
+ }
97
+ const hash = crypto.createHash('sha1').update(raw);
98
+ const targets = {};
99
+ for (const [target, parts] of Object.entries(manifest.targets)) {
100
+ const have = surfaces[target];
101
+ if (have === undefined) continue;
102
+ if (have !== parts.surface) {
103
+ const why = have === null ? `${target} has no mod surface` : `${target} has surface ${have}`;
104
+ warn(`[mods] ${entry.id}: skipped its ${target} files: they are written for surface ${parts.surface}, and ${why}`);
105
+ continue;
106
+ }
107
+ targets[target] = { css: parts.css ?? [], js: parts.js ?? [] };
108
+ for (const f of [...targets[target].css, ...targets[target].js]) hash.update(f).update(fs.readFileSync(path.join(root, f)));
109
+ }
110
+ out.push({ id: manifest.id, root, version: hash.digest('hex').slice(0, 10), themes: manifest.themes ?? [], targets });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ // What one page loads: its mods, the URLs of their CSS and JS in mod order then file order, and the
116
+ // `/<id>/<file>` → path map of the only files its /mods route serves.
117
+ function modAssets(mods, target) {
118
+ const mine = mods.filter((m) => m.targets[target]);
119
+ const pick = (type) => mine.flatMap((m) => (m.targets[target][type] ?? []).map((f) => [m, f]));
120
+ const url = ([m, f]) => `/mods/${m.id}/${f}?v=${m.version}`;
121
+ const css = pick('css');
122
+ const js = pick('js');
123
+ const files = new Map([...css, ...js].map(([m, f]) => [`/${m.id}/${f}`, path.join(m.root, f)]));
124
+ return { mods: mine.map((m) => ({ id: m.id, version: m.version })), css: css.map(url), js: js.map(url), files };
125
+ }
126
+
127
+ // HUB_MODS for an app: the page's list as the client reads it, and the files the SDK's mount()
128
+ // serves. The SDK uses both as given, so the URL format lives only here. Null when the app has no mod.
129
+ function appModEnv(mods, app) {
130
+ const { files, ...assets } = modAssets(mods, app);
131
+ return assets.mods.length ? JSON.stringify({ assets, files: Object.fromEntries(files) }) : null;
132
+ }
133
+
134
+ const MOD_KIT = path.join(HUB_ROOT, 'packages', 'claude-hub-sdk', 'src', 'mods.js');
135
+
136
+ // /mods/hub.js: the SDK's mod runtime as ClaudeHub.mod on the hub page, which then loads the mod
137
+ // scripts. Empty when no mod has JS for the hub page.
138
+ function hubModScript(assets) {
139
+ if (!assets.js.length) return '';
140
+ return [
141
+ `self.ClaudeHubMods = ${JSON.stringify({ mods: assets.mods, js: assets.js })};`,
142
+ fs.readFileSync(MOD_KIT, 'utf8'),
143
+ 'self.ClaudeHub = { mod: ClaudeHubModKit.createMods(self).mod };',
144
+ '',
145
+ ].join('\n');
146
+ }
147
+
148
+ module.exports = { BUNDLED, readMod, readSurface, entryRoot, bundledRoot, resolveMods, modAssets, appModEnv, hubModScript };
package/lib/themes.js CHANGED
@@ -54,7 +54,7 @@ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
54
54
  function themeProblem(e, builtins, seen) {
55
55
  if (!isObject(e)) return 'not an object';
56
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';
57
+ if (seen.has(e.id)) return seen.get(e.id) ? 'an installed mod has a theme with this id' : 'an earlier theme in the file has this id';
58
58
  if (e.label !== undefined && typeof e.label !== 'string') return 'label must be a string';
59
59
  if (e.extends !== undefined) {
60
60
  if (builtins.has(e.id)) return 'a built-in id cannot take extends';
@@ -76,17 +76,31 @@ function themeProblem(e, builtins, seen) {
76
76
 
77
77
  // The built-in list with the user's themes applied: a built-in id changes that theme in place, a
78
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) {
79
+ // The themes of the installed mods. A mod adds themes and never replaces one: not a built-in, which
80
+ // the user's themes.json may tune, and not another mod's. Checked once at hub start.
81
+ function modThemes(registry, mods, warn) {
82
+ const taken = new Set((registry ?? []).map((t) => t.id));
83
+ return mods.flatMap((m) =>
84
+ (m.themes ?? []).filter((t) => {
85
+ if (!taken.has(t?.id)) return taken.add(t?.id);
86
+ warn(`[mods] ${m.id}: skipped its theme "${t?.id}": another theme has this id`);
87
+ return false;
88
+ }),
89
+ );
90
+ }
91
+
92
+ // The first `fromMods` entries are mod themes, which modThemes has checked against each other.
93
+ function withUserThemes(themes, entries, warn, fromMods = 0) {
80
94
  const builtins = new Map(themes.map((t) => [t.id, t]));
81
95
  const out = new Map(builtins);
82
- const seen = new Set();
96
+ const seen = new Map();
83
97
  entries.forEach((e, i) => {
84
98
  const problem = themeProblem(e, builtins, seen);
85
99
  if (problem) {
86
100
  warn(`[themes] skipped ${typeof e?.id === 'string' ? `"${e.id}"` : `entry ${i + 1}`}: ${problem}`);
87
101
  return;
88
102
  }
89
- seen.add(e.id);
103
+ seen.set(e.id, i < fromMods);
90
104
  const base = builtins.get(e.id) ?? builtins.get(e.extends);
91
105
  out.set(e.id, {
92
106
  id: e.id,
@@ -121,14 +135,16 @@ function readRegistry(file = THEMES_FILE) {
121
135
 
122
136
  // What /api/config carries. An unreadable registry gives empty maps: the hub keeps index.html's
123
137
  // 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 } = {}) {
138
+ // <hub-dir>/themes.json, read on each call. `extra` holds the themes of installed mods, which go
139
+ // before the user's, so a user theme cannot take a mod theme's id.
140
+ function themeConfig(registry = readRegistry(), { userFile, extra = [], warn = console.warn } = {}) {
126
141
  if (!registry) return { themes: [], themeVars: {} };
127
- const themes = userFile ? withUserThemes(registry, readUserThemes(userFile, warn), warn) : registry;
142
+ const entries = [...extra, ...(userFile ? readUserThemes(userFile, warn) : [])];
143
+ const themes = entries.length ? withUserThemes(registry, entries, warn, extra.length) : registry;
128
144
  return {
129
145
  themes: themes.map((t) => ({ id: t.id, label: t.label, swatch: modes(t, swatch) })),
130
146
  themeVars: Object.fromEntries(themes.map((t) => [t.id, modes(t, coreVars)])),
131
147
  };
132
148
  }
133
149
 
134
- module.exports = { readThemes, readRegistry, coreVars, swatch, themeConfig, ROLES };
150
+ module.exports = { readThemes, readRegistry, coreVars, swatch, themeConfig, modThemes, ROLES };
@@ -14,9 +14,11 @@ const PREFIX = /^\s*(?:hub|hob|hab)\b[\s,.:;!?-]*/i;
14
14
  const TAIL = '\\b[\\s,.:;!?-]*([\\s\\S]*?)\\s*$';
15
15
  const phrase = (verbs, noun) =>
16
16
  new RegExp(`^(?:(?:${verbs})\\s+(?:(?:a|the)\\s+)?(?:new\\s+)?)?${noun}${TAIL}`, 'i');
17
+ const words = (alts) => new RegExp(`^(?:${alts})${TAIL}`, 'i');
17
18
  const dropEndPunct = (s) => s.replace(/[.!?]+$/, '');
18
19
 
19
- // `param` takes the words after the phrase; `tidy` cleans them. `say` and `help` feed the page's help panel.
20
+ // `param` takes the words after the phrase; a row without one matches only when no words follow, so
21
+ // "approve, but not that file" never approves. `tidy` cleans them. `say` and `help` feed the page's help panel.
20
22
  // `live` names the app's dialog action that the page fills as the user speaks, and the param that takes
21
23
  // the words so far.
22
24
  const COMMANDS = [
@@ -31,10 +33,10 @@ const COMMANDS = [
31
33
  {
32
34
  action: 'session.find',
33
35
  label: 'Open session',
34
- phrase: new RegExp(`^(?:open|switch\\s+to|go\\s+to|show)\\s+(?:(?:the|my)\\s+)?session${TAIL}`, 'i'),
36
+ phrase: words('(?:open|go\\s+to|show)\\s+(?:(?:the|my)\\s+)?session|switch(?:\\s+to)?(?:\\s+(?:the|my))?(?:\\s+session)?'),
35
37
  param: 'name',
36
38
  tidy: dropEndPunct,
37
- say: 'open session',
39
+ say: 'switch to',
38
40
  help: 'Opens the session that matches',
39
41
  },
40
42
  {
@@ -47,6 +49,29 @@ const COMMANDS = [
47
49
  help: 'Opens Add task with your words',
48
50
  live: { action: 'task.live', param: 'description' },
49
51
  },
52
+ {
53
+ action: 'session.approve',
54
+ label: 'Approve',
55
+ phrase: words('approve|allow|accept|yes'),
56
+ say: 'approve',
57
+ help: 'Allows what this session asks',
58
+ },
59
+ {
60
+ action: 'session.deny',
61
+ label: 'Deny',
62
+ phrase: words('deny|reject|decline|no'),
63
+ param: 'message',
64
+ tidy: dropEndPunct,
65
+ say: 'deny',
66
+ help: 'Denies it, with your reason if you add one',
67
+ },
68
+ {
69
+ action: 'session.next',
70
+ label: 'Next',
71
+ phrase: words('next(?:\\s+(?:waiting|session|one))?'),
72
+ say: 'next',
73
+ help: 'Opens the next session that needs you',
74
+ },
50
75
  ];
51
76
 
52
77
  const commandList = () =>
@@ -62,7 +87,9 @@ function matchCommand(text, can = () => true) {
62
87
  for (const c of COMMANDS) {
63
88
  const m = c.phrase.exec(body);
64
89
  if (!m || !can(c.action)) continue;
65
- return { action: c.action, label: c.label, param: c.param, live: c.live, rest: c.tidy ? c.tidy(m[1]) : m[1] };
90
+ const rest = c.tidy ? c.tidy(m[1]) : m[1];
91
+ if (!c.param && rest) continue;
92
+ return { action: c.action, label: c.label, param: c.param, live: c.live, rest };
66
93
  }
67
94
  return { action: null, label: 'Hub', rest: body.trim() };
68
95
  }
@@ -0,0 +1,10 @@
1
+ {
2
+ "surface": 1,
3
+ "functions": {},
4
+ "events": {
5
+ "<mod id>:<name>": "A message from the same mod's code in an app page, sent there with mod.send(topic, data). The detail is {app, data}: the app id and the data as sent."
6
+ },
7
+ "parts": {},
8
+ "states": {},
9
+ "page": "The hub page holds one iframe per app and its own chrome. A mod may draw over it, such as a fixed canvas with pointer-events: none at a z-index from 11 to 14: over the app frames and the loading screen (10), under the hub's toasts (15 and up) and the palette (20)."
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.27.2",
3
+ "version": "1.29.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.24.0",
50
+ "claude-code-kanban": "7.26.0",
51
51
  "claude-code-marketplace": "1.7.2",
52
52
  "claude-code-memory-explorer": "1.3.5",
53
53
  "express": "^4.21.0",
@@ -61,6 +61,8 @@
61
61
  "public/**/*",
62
62
  "lib/**/*",
63
63
  "features/**/*",
64
+ "extensions/**/*",
65
+ "mod-surface.json",
64
66
  "plugin/**/*",
65
67
  "tray/**/*",
66
68
  "packages/claude-hub-sdk/src/**/*"
@@ -14,6 +14,7 @@
14
14
 
15
15
  // keys.js comes first in the served file. Required as a module, client.js loads it itself.
16
16
  const { comboOf, keyParts, keySigs } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
17
+ const { createMods } = typeof ClaudeHubModKit === 'object' ? ClaudeHubModKit : require('./mods');
17
18
 
18
19
  function isVars(v) {
19
20
  return (
@@ -41,11 +42,24 @@
41
42
  : [],
42
43
  );
43
44
 
45
+ // The page's light or dark, for mod CSS: [data-hub-mode="light"]. Cached so it is set before the hub answers.
46
+ const MODE_KEY = 'claude-hub:mode';
47
+
44
48
  function createClaudeHub(win) {
45
49
  const doc = win.document;
46
50
  const framed = win.top !== win;
47
51
  let appliedVars = null;
48
52
  let hub = null;
53
+ const mods = createMods(win);
54
+ const { mod } = mods;
55
+
56
+ function setMode(mode) {
57
+ if (mode !== 'light' && mode !== 'dark') return;
58
+ if (doc.documentElement) doc.documentElement.dataset.hubMode = mode;
59
+ try {
60
+ win.localStorage.setItem(MODE_KEY, mode);
61
+ } catch (_) {}
62
+ }
49
63
 
50
64
  function setVars(vars) {
51
65
  const body = doc.body;
@@ -82,6 +96,9 @@
82
96
  if (framed) {
83
97
  const cache = readCache();
84
98
  if (cache && hubOnTop(cache.origin)) setVars(cache.vars);
99
+ try {
100
+ setMode(win.localStorage.getItem(MODE_KEY));
101
+ } catch (_) {}
85
102
  }
86
103
 
87
104
  function connect({ standalone = {} } = {}) {
@@ -151,6 +168,7 @@
151
168
  if (isVars(p.vars)) adoptVars(p.vars, origin);
152
169
  else if (!waiting()) adoptVars(null);
153
170
  lastTheme = p;
171
+ setMode(p.theme);
154
172
  applyMode(p);
155
173
  emit('theme.changed', p);
156
174
  }
@@ -188,6 +206,11 @@
188
206
  actions: strings(m.actions),
189
207
  keys: comboMap(m.keys),
190
208
  themes: Array.isArray(m.themes) ? m.themes : [],
209
+ voice: Array.isArray(m.voice)
210
+ ? m.voice
211
+ .filter((c) => typeof c?.say === 'string' && typeof c?.help === 'string')
212
+ .map(({ say, help }) => ({ say, help }))
213
+ : [],
191
214
  };
192
215
  forward = strings(m.forward);
193
216
  appOrigins = strings(m.origins);
@@ -351,6 +374,7 @@
351
374
  Promise.all([config, loaded]).then(() => {
352
375
  if (!origin) return;
353
376
  post({ type: 'hub:hello', protocol: [1], subscribes: [...topics.keys()] });
377
+ mods.connectSender(post);
354
378
  setStatus('waiting');
355
379
  // A hub older than protocol v1 never answers. The app then runs as if alone, with no hub calls.
356
380
  win.setTimeout(() => {
@@ -463,6 +487,8 @@
463
487
  while (n < all[0].length - 1 && all.every((p) => p.length - 1 > n && p[n] === all[0][n])) n++;
464
488
  return [...all[0].slice(0, n), all.map((p) => p.slice(n).join('+')).join('/')];
465
489
  },
490
+ // The voice commands this app handles, [{say, help}], for its help. [] with voice off, before welcome, or standalone.
491
+ voiceCommands: () => (welcome ? welcome.voice.map((c) => ({ ...c })) : []),
466
492
  closeGuard(on) {
467
493
  post({ type: 'hub:closeGuard', on: !!on });
468
494
  },
@@ -493,7 +519,7 @@
493
519
  return hub;
494
520
  }
495
521
 
496
- return { connect, comboOf };
522
+ return { connect, comboOf, mod };
497
523
  }
498
524
 
499
525
  if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };