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/README.md +4 -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/cli.js +82 -0
- package/lib/ext.js +122 -11
- package/lib/mods.js +148 -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 +62 -19
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
|
|
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) =>
|
|
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
|
|
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 = () =>
|
|
583
|
+
const back = () => was !== sha && checkout(clone, was);
|
|
584
|
+
let allowed = true;
|
|
483
585
|
try {
|
|
484
|
-
|
|
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
|
-
|
|
602
|
+
ext = kind.read(root, id);
|
|
603
|
+
checkRequires(ctx, ext.manifest, config);
|
|
495
604
|
}
|
|
496
|
-
if (!kind.step(ctx, id,
|
|
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
|
-
|
|
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
|
-
|
|
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.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.
|
|
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 };
|