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/README.md +1 -0
- package/extensions/xmas/hub.css +33 -0
- package/extensions/xmas/hub.js +199 -0
- package/extensions/xmas/kanban.css +141 -0
- package/extensions/xmas/kanban.js +57 -0
- package/extensions/xmas/mod.json +57 -0
- package/lib/apps.js +1 -0
- package/lib/ext.js +117 -8
- package/lib/mods.js +156 -0
- package/lib/themes.js +24 -8
- package/lib/voice/grammar.js +31 -4
- package/mod-surface.json +10 -0
- package/package.json +4 -2
- package/packages/claude-hub-sdk/src/client.js +27 -1
- package/packages/claude-hub-sdk/src/mods.js +128 -0
- package/packages/claude-hub-sdk/src/server.js +26 -3
- package/packages/claude-hub-sdk/src/stub.js +3 -1
- package/public/app.js +59 -7
- package/public/index.html +11 -1
- package/server.js +45 -16
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
|
|
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) =>
|
|
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) =>
|
|
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 = () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 };
|
package/lib/voice/grammar.js
CHANGED
|
@@ -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;
|
|
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:
|
|
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: '
|
|
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
|
-
|
|
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
|
}
|
package/mod-surface.json
ADDED
|
@@ -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.
|
|
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.
|
|
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 };
|