claude-code-hub 1.27.2 → 1.28.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, modJsHash, readMod } = require('./mods');
11
13
  const { pluginChecker } = require('./plugin-status');
12
14
  const { isNewer } = require('./update-check');
13
15
 
@@ -27,7 +29,14 @@ 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
+ A mod with JS runs only when you type y, at install and when ext update changes its code.
35
+ Open the hub at /?nomods to load it with no mod. "modsEnabled": false in config.json
36
+ or --no-mods turns every mod off.
37
+
30
38
  --hub-dir <path> The hub dir (default CLAUDE_HUB_DIR, else ~/.claude-hub)
39
+ --yes Let a mod run code without asking
31
40
  --dir <path> The config dir for plugin and dock steps (default CLAUDE_CONFIG_DIR, else ~/.claude)`;
32
41
 
33
42
  const RESTART = ' Restart the hub to load the change.';
@@ -67,11 +76,14 @@ function managedEntry(config, id, verb) {
67
76
  const found = extEntry(config, id);
68
77
  const [entry] = found;
69
78
  const { source } = entry;
70
- if (source !== 'local' && !isGithub(entry)) throw new Error(`${id} has source "${source}": only a local or GitHub extension can ${verb}.`);
79
+ if (source === 'bundled' && verb === 'update') throw new Error(`${id} ships with the hub: it updates when the hub does.`);
80
+ if (source !== 'local' && source !== 'bundled' && !isGithub(entry)) {
81
+ throw new Error(`${id} has source "${source}": only a local, bundled or GitHub extension can ${verb}.`);
82
+ }
71
83
  return found;
72
84
  }
73
85
 
74
- const rootOf = (ctx, entry) => path.resolve(ctx.hubDir, entry.path);
86
+ const rootOf = (ctx, entry) => entryRoot(entry, ctx.hubDir, ctx.bundled);
75
87
 
76
88
  const GITHUB_RE = /^github:([\w.-]+)\/([\w.-]+)((?:\/[\w.-]+)*)(?:@(\w[\w./-]*))?$/;
77
89
 
@@ -257,9 +269,83 @@ const KINDS = {
257
269
  updated: 'Restart its server (Ctrl+R on its row in the app launcher) to load the new code.',
258
270
  noPlugin: 'dock add-on',
259
271
  },
272
+ mod: {
273
+ list: 'mods',
274
+ file: 'mod.json',
275
+ read: readMod,
276
+ step: modStep,
277
+ stepName: 'Checking the mod',
278
+ removeName: 'Removing the mod',
279
+ unread: 'It is removed by its id.',
280
+ installed: RESTART,
281
+ removed: RESTART,
282
+ updated: 'Restart the hub to load the new code.',
283
+ noPlugin: 'mod',
284
+ },
260
285
  };
261
286
 
262
- const kindOf = (root) => (fs.existsSync(path.join(root, 'addon.json')) ? KINDS.addon : KINDS.app);
287
+ const kindOf = (root) => {
288
+ if (fs.existsSync(path.join(root, 'mod.json'))) return KINDS.mod;
289
+ return fs.existsSync(path.join(root, 'addon.json')) ? KINDS.addon : KINDS.app;
290
+ };
291
+
292
+ const jsTargets = (manifest) => Object.keys(manifest.targets).filter((t) => manifest.targets[t].js?.length);
293
+
294
+ // The answer typed on the terminal, or null when there is no terminal to ask on. isatty, not
295
+ // process.stdin.isTTY: that getter opens a tty.ReadStream, which on macOS and Linux makes fd 0
296
+ // non-blocking, and readSync then throws EAGAIN.
297
+ function askTerminal(question) {
298
+ if (!tty.isatty(0)) return null;
299
+ process.stdout.write(question);
300
+ const buf = Buffer.alloc(256);
301
+ try {
302
+ return buf.toString('utf8', 0, fs.readSync(0, buf, 0, buf.length, null)).trim();
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ // A mod's JS runs in the page's own origin, with the same trust as a patch: in Kanban's origin it can
309
+ // read the terminal token and type into sessions. So it runs only when the user types yes.
310
+ function allowJs(ctx, id, js, refused) {
311
+ if (ctx.yes) return true;
312
+ const answer = (ctx.ask ?? askTerminal)(` Let ${id} run code in ${js.join(', ')}? [y/N] `);
313
+ if (answer === null) {
314
+ console.log(`\n There is no terminal to ask on. Pass --yes to let ${id} run code.`);
315
+ return false;
316
+ }
317
+ if (/^y(es)?$/i.test(answer)) return true;
318
+ console.log(`\n ${refused}`);
319
+ return false;
320
+ }
321
+
322
+ // Asks only at install. Update asks itself, and only when the code changes.
323
+ function modStep(ctx, id, ext, step) {
324
+ if (step !== 'install' || !ext) return true;
325
+ const { targets } = ext.manifest;
326
+ console.log(`\n Mod ${id} changes: ${Object.keys(targets).join(', ')}`);
327
+ const js = jsTargets(ext.manifest);
328
+ if (!js.length) return true;
329
+ console.log(` It runs code in ${js.join(', ')}, with the same access as the app's own code.`);
330
+ return allowJs(ctx, id, js, `${id} is not installed.`);
331
+ }
332
+
333
+ function jsHashOf(root, id) {
334
+ try {
335
+ return modJsHash(root, readMod(root, id).manifest);
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ function updateAllowed(ctx, id, kind, root, before) {
342
+ if (kind !== KINDS.mod) return true;
343
+ const { manifest } = readMod(root, id);
344
+ const js = jsTargets(manifest);
345
+ if (!js.length || modJsHash(root, manifest) === before) return true;
346
+ console.log(`\n The new version of ${id} changes the code it runs in ${js.join(', ')}.`);
347
+ return allowJs(ctx, id, js, `${id} keeps the code it had.`);
348
+ }
263
349
 
264
350
  // An id the hub runs gets its plugin installed. A folder is added to the hub, with its plugin, unless the
265
351
  // hub already runs it from there; then only the plugin is installed. Any other name is looked up in the registry.
@@ -274,6 +360,8 @@ function install(ctx, target) {
274
360
  if (addon) return dockStep(ctx, target, null, 'install', rootOf(ctx, addon), config);
275
361
  const feature = readFeature(target, ctx.features);
276
362
  if (feature) return setFeature(ctx, feature, true, file, config);
363
+ const bundled = !fs.existsSync(target) && bundledRoot(target, ctx.bundled);
364
+ if (bundled) return installBundled(ctx, target, bundled, file, config);
277
365
  if (!fs.existsSync(target) && ID_RE.test(target)) return installRegistry(ctx, target, file, config);
278
366
  return installFolder(ctx, target, file, config);
279
367
  }
@@ -415,6 +503,21 @@ function installGithub(ctx, target, file, config, name) {
415
503
  return true;
416
504
  }
417
505
 
506
+ function installBundled(ctx, id, root, file, config) {
507
+ const kind = kindOf(root);
508
+ const ext = kind.read(root, id);
509
+ const entry = claimId(id, config, file, kind, (e) => e.source === 'bundled');
510
+ checkRequires(ctx, ext.manifest, config);
511
+ if (entry) {
512
+ if (!kind.step(ctx, id, ext, 'install', root, config)) return false;
513
+ console.log(`\n ${id} is already in the hub.`);
514
+ return true;
515
+ }
516
+ if (!addExtension(ctx, root, file, config, { source: 'bundled' }, kind)) return false;
517
+ console.log(`\n Installed ${id}, which ships with the hub.\n${kind.installed}`);
518
+ return true;
519
+ }
520
+
418
521
  function installAppPlugin(ctx, app) {
419
522
  const { root, plugin: p } = appPlugin(app);
420
523
  if (!p) throw new Error(`${app.id} ships no plugin.`);
@@ -477,16 +580,20 @@ function update(ctx, id) {
477
580
  let sha;
478
581
  if (isGithub(entry)) {
479
582
  const clone = cloneOf(ctx, entry);
583
+ const before = kind === KINDS.mod ? jsHashOf(root, id) : null;
584
+ const was = git(['rev-parse', 'HEAD'], clone);
480
585
  git(['fetch', '--quiet', 'origin'], clone);
481
586
  sha = checkout(clone, entry.ref);
482
- const back = () => entry.sha && entry.sha !== sha && checkout(clone, entry.sha);
587
+ const back = () => was !== sha && checkout(clone, was);
588
+ let allowed;
483
589
  try {
484
590
  checkRequires(ctx, kind.read(root, id).manifest, config);
591
+ allowed = updateAllowed(ctx, id, kind, root, before);
485
592
  } catch (e) {
486
593
  back();
487
594
  throw e;
488
595
  }
489
- if (!installDeps(ctx, root)) {
596
+ if (!allowed || !installDeps(ctx, root)) {
490
597
  back();
491
598
  return false;
492
599
  }
@@ -523,7 +630,8 @@ function remove(ctx, id) {
523
630
  if (removeDir(clone)) console.log(`\n Removed ${id} and its clone.\n${kind.removed}`);
524
631
  return true;
525
632
  }
526
- console.log(`\n Removed ${id}. ${entry.path} is left in place.\n${kind.removed}`);
633
+ const kept = entry.path ? ` ${entry.path} is left in place.` : '';
634
+ console.log(`\n Removed ${id}.${kept}\n${kind.removed}`);
527
635
  return true;
528
636
  }
529
637
 
@@ -560,18 +668,19 @@ const COMMANDS = { install, list, update, remove };
560
668
  // ctx: {hubDir, configDir: () => dir for plugin steps, hubVersion, nodeVersion?, exec?, githubBase?, registry?: URL or file}.
561
669
  function runExt(argv, ctx) {
562
670
  const registry = process.env.CLAUDE_HUB_REGISTRY || REGISTRY;
563
- const full = { nodeVersion: process.versions.node, exec: run, githubBase: 'https://github.com', registry, ...ctx };
671
+ const full = { nodeVersion: process.versions.node, exec: run, githubBase: 'https://github.com', registry, bundled: BUNDLED, ...ctx };
564
672
  let parsed;
565
673
  try {
566
674
  parsed = parseArgs({
567
675
  args: argv,
568
676
  allowPositionals: true,
569
- options: { dir: { type: 'string' }, 'hub-dir': { type: 'string' }, help: { type: 'boolean', short: 'h' } },
677
+ options: { dir: { type: 'string' }, 'hub-dir': { type: 'string' }, yes: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
570
678
  });
571
679
  } catch (e) {
572
680
  console.error(`${e.message}\n\n${USAGE}`);
573
681
  return false;
574
682
  }
683
+ full.yes = parsed.values.yes === true;
575
684
  const [name, target] = parsed.positionals;
576
685
  const command = COMMANDS[name];
577
686
  if (!command || parsed.values.help) {
package/lib/mods.js ADDED
@@ -0,0 +1,156 @@
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
+ // Changes with any JS file of the mod, and with the page each one runs in. `ext update` asks again when it does.
80
+ function modJsHash(root, manifest) {
81
+ const hash = crypto.createHash('sha1');
82
+ for (const [target, parts] of Object.entries(manifest.targets))
83
+ for (const f of parts.js ?? []) hash.update(`${target}\0${f}\0`).update(fs.readFileSync(path.join(root, f))).update('\0');
84
+ return hash.digest('hex');
85
+ }
86
+
87
+ // The mods of `config.mods`, read once at hub start. `version` changes with any file of the mod,
88
+ // so the browser fetches the new files after a hub restart and caches them until then.
89
+ // `surfaces` maps "hub" and each enabled app id to its surface version, or null when it has none.
90
+ // A target whose page is not running is left out; one written for another surface is left out with a log line.
91
+ function resolveMods(config, hubDir, { warn, surfaces, bundled = BUNDLED, off = false }) {
92
+ if (off) return [];
93
+ const out = [];
94
+ for (const entry of config.mods ?? []) {
95
+ if (typeof entry?.id !== 'string' || entry.enabled === false) continue;
96
+ const root = entryRoot(entry, hubDir, bundled);
97
+ let manifest;
98
+ let raw;
99
+ try {
100
+ ({ manifest, raw } = readMod(root, entry.id));
101
+ } catch (e) {
102
+ warn(`[mods] skipped ${entry.id}: ${e.message}`);
103
+ continue;
104
+ }
105
+ const hash = crypto.createHash('sha1').update(raw);
106
+ const targets = {};
107
+ for (const [target, parts] of Object.entries(manifest.targets)) {
108
+ const have = surfaces[target];
109
+ if (have === undefined) continue;
110
+ if (have !== parts.surface) {
111
+ const why = have === null ? `${target} has no mod surface` : `${target} has surface ${have}`;
112
+ warn(`[mods] ${entry.id}: skipped its ${target} files: they are written for surface ${parts.surface}, and ${why}`);
113
+ continue;
114
+ }
115
+ targets[target] = { css: parts.css ?? [], js: parts.js ?? [] };
116
+ for (const f of [...targets[target].css, ...targets[target].js]) hash.update(f).update(fs.readFileSync(path.join(root, f)));
117
+ }
118
+ out.push({ id: manifest.id, root, version: hash.digest('hex').slice(0, 10), themes: manifest.themes ?? [], targets });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ // What one page loads: its mods, the URLs of their CSS and JS in mod order then file order, and the
124
+ // `/<id>/<file>` → path map of the only files its /mods route serves.
125
+ function modAssets(mods, target) {
126
+ const mine = mods.filter((m) => m.targets[target]);
127
+ const pick = (type) => mine.flatMap((m) => (m.targets[target][type] ?? []).map((f) => [m, f]));
128
+ const url = ([m, f]) => `/mods/${m.id}/${f}?v=${m.version}`;
129
+ const css = pick('css');
130
+ const js = pick('js');
131
+ const files = new Map([...css, ...js].map(([m, f]) => [`/${m.id}/${f}`, path.join(m.root, f)]));
132
+ return { mods: mine.map((m) => ({ id: m.id, version: m.version })), css: css.map(url), js: js.map(url), files };
133
+ }
134
+
135
+ // HUB_MODS for an app: the page's list as the client reads it, and the files the SDK's mount()
136
+ // serves. The SDK uses both as given, so the URL format lives only here. Null when the app has no mod.
137
+ function appModEnv(mods, app) {
138
+ const { files, ...assets } = modAssets(mods, app);
139
+ return assets.mods.length ? JSON.stringify({ assets, files: Object.fromEntries(files) }) : null;
140
+ }
141
+
142
+ const MOD_KIT = path.join(HUB_ROOT, 'packages', 'claude-hub-sdk', 'src', 'mods.js');
143
+
144
+ // /mods/hub.js: the SDK's mod runtime as ClaudeHub.mod on the hub page, which then loads the mod
145
+ // scripts. Empty when no mod has JS for the hub page.
146
+ function hubModScript(assets) {
147
+ if (!assets.js.length) return '';
148
+ return [
149
+ `self.ClaudeHubMods = ${JSON.stringify({ mods: assets.mods, js: assets.js })};`,
150
+ fs.readFileSync(MOD_KIT, 'utf8'),
151
+ 'self.ClaudeHub = { mod: ClaudeHubModKit.createMods(self).mod };',
152
+ '',
153
+ ].join('\n');
154
+ }
155
+
156
+ module.exports = { BUNDLED, readMod, readSurface, modJsHash, 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.28.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 };