claude-code-hub 1.11.0 → 1.13.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 +3 -3
- package/lib/apps.js +125 -25
- package/lib/ext.js +470 -0
- package/lib/hub-config.js +15 -0
- package/lib/install.js +60 -19
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +25 -10
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/SKILL.md +2 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensions.md +56 -0
- package/public/app.js +231 -76
- package/public/index.html +20 -13
- package/server.js +90 -43
package/README.md
CHANGED
|
@@ -77,13 +77,13 @@ npm start # http://localhost:3540
|
|
|
77
77
|
|
|
78
78
|
## Agent observability (one-time setup)
|
|
79
79
|
|
|
80
|
-
For the full Kanban view (agent log, live subagent tracking, waiting-for-user indicators, and context window use), install the
|
|
80
|
+
For the full Kanban view (agent log, live subagent tracking, waiting-for-user indicators, and context window use), install the plugins:
|
|
81
81
|
|
|
82
82
|
```bash
|
|
83
|
-
|
|
83
|
+
claude-code-hub --install
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
This installs Kanban's plugin and the hub's own plugin. Without Kanban's plugin you still get the task board and sessions, but no live agent activity. The plugins are installed per Claude config dir, so run the command again with `--dir <config dir>` for each dir you use. After a hub update, run the command again to update the plugins. See the [Kanban docs](https://nikiforovall.blog/claude-code-kanban/) for details.
|
|
87
87
|
|
|
88
88
|
## Hub token
|
|
89
89
|
|
package/lib/apps.js
CHANGED
|
@@ -18,6 +18,7 @@ const ID_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
18
18
|
const HUB_ROOT = path.join(__dirname, '..');
|
|
19
19
|
|
|
20
20
|
const validPort = (p) => Number.isInteger(p) && p > 0 && p < 65536;
|
|
21
|
+
const isObject = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
21
22
|
|
|
22
23
|
// `entries` is the hand-edited `apps` list in config.json. Listed apps come first in list order and
|
|
23
24
|
// unlisted ones follow in default order, so an app added in a later release shows up without an edit.
|
|
@@ -37,7 +38,17 @@ function selectApps(entries = [], base = process.cwd()) {
|
|
|
37
38
|
const apps = [...order]
|
|
38
39
|
.map(byId)
|
|
39
40
|
.filter((a) => a && !off.has(a.id))
|
|
40
|
-
.map((a) =>
|
|
41
|
+
.map((a) => {
|
|
42
|
+
const e = entryOf(a.id);
|
|
43
|
+
return {
|
|
44
|
+
...a,
|
|
45
|
+
port: e?.port,
|
|
46
|
+
path: pathOf(a.id),
|
|
47
|
+
hidden: e?.hidden === true,
|
|
48
|
+
deferredStart: e?.deferredStart === true,
|
|
49
|
+
linked: e?.source === 'local',
|
|
50
|
+
};
|
|
51
|
+
});
|
|
41
52
|
return { apps, unknown };
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -66,24 +77,109 @@ function manifestError(m, id, root) {
|
|
|
66
77
|
if (typeof m.run?.entry !== 'string') return 'run.entry is missing';
|
|
67
78
|
const entry = path.resolve(root, m.run.entry);
|
|
68
79
|
if (entry === path.resolve(root) || !isContained(entry, root)) return 'run.entry is outside the app directory';
|
|
69
|
-
return
|
|
80
|
+
return (
|
|
81
|
+
providesError(m.provides) ??
|
|
82
|
+
publishesError(m.publishes) ??
|
|
83
|
+
keysError(m.keys) ??
|
|
84
|
+
pluginError(m.plugin, root) ??
|
|
85
|
+
requiresError(m.requires) ??
|
|
86
|
+
listingError(m.listing, root)
|
|
87
|
+
);
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
const
|
|
90
|
+
const PLUGIN_NAME_RE = /^[\w.-]+$/;
|
|
91
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
73
92
|
|
|
74
93
|
function pluginError(plugin, root) {
|
|
75
94
|
if (plugin === undefined) return null;
|
|
76
|
-
if (
|
|
77
|
-
if (typeof plugin.
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
if (typeof plugin.install !== 'string' || !plugin.install)
|
|
95
|
+
if (!isObject(plugin)) return 'plugin is not an object';
|
|
96
|
+
if (typeof plugin.marketplace !== 'string') return 'plugin.marketplace is not a path';
|
|
97
|
+
if (!isContained(path.resolve(root, plugin.marketplace), root)) return 'plugin.marketplace is outside the app directory';
|
|
98
|
+
if (typeof plugin.name !== 'string' || !PLUGIN_NAME_RE.test(plugin.name)) return 'plugin.name is not a plugin name';
|
|
99
|
+
if (plugin.install !== undefined && (typeof plugin.install !== 'string' || !plugin.install))
|
|
100
|
+
return 'plugin.install is not a command';
|
|
81
101
|
return null;
|
|
82
102
|
}
|
|
83
103
|
|
|
104
|
+
function requiresError(requires) {
|
|
105
|
+
if (requires === undefined) return null;
|
|
106
|
+
if (!isObject(requires)) return 'requires is not an object';
|
|
107
|
+
for (const key of ['hub', 'node']) {
|
|
108
|
+
const v = requires[key];
|
|
109
|
+
if (v !== undefined && (typeof v !== 'string' || !VERSION_RE.test(v))) return `requires.${key} is not a version x.y.z`;
|
|
110
|
+
}
|
|
111
|
+
const { apps } = requires;
|
|
112
|
+
if (apps !== undefined && (!Array.isArray(apps) || !apps.every((id) => typeof id === 'string' && ID_RE.test(id))))
|
|
113
|
+
return 'requires.apps is not a list of app ids';
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const TAG_RE = /^[a-z0-9-]+$/;
|
|
118
|
+
const IMAGE_RE = /\.(png|jpe?g|webp)$/i;
|
|
119
|
+
const SPDX_RE = /^[A-Za-z0-9.+-]+$/;
|
|
120
|
+
const isHttps = (u) => typeof u === 'string' && /^https:\/\/[^\s/]+/.test(u);
|
|
121
|
+
const textError = (key, v, max) =>
|
|
122
|
+
v === undefined || (typeof v === 'string' && v.length > 0 && v.length <= max && !/[<>]/.test(v))
|
|
123
|
+
? null
|
|
124
|
+
: `listing.${key} is not plain text of ${max} characters or fewer`;
|
|
125
|
+
|
|
126
|
+
function listingError(listing, root) {
|
|
127
|
+
if (listing === undefined) return null;
|
|
128
|
+
if (!isObject(listing)) return 'listing is not an object';
|
|
129
|
+
const err = textError('tagline', listing.tagline, 80) ?? textError('description', listing.description, 500) ?? textError('author', listing.author, 60);
|
|
130
|
+
if (err) return err;
|
|
131
|
+
const { tags, screenshots, homepage, license } = listing;
|
|
132
|
+
if (tags !== undefined && (!Array.isArray(tags) || tags.length > 5 || !tags.every((t) => typeof t === 'string' && TAG_RE.test(t))))
|
|
133
|
+
return 'listing.tags is not a list of up to 5 lowercase tags';
|
|
134
|
+
if (screenshots !== undefined) {
|
|
135
|
+
if (!Array.isArray(screenshots) || screenshots.length > 4) return 'listing.screenshots is not a list of up to 4';
|
|
136
|
+
for (const s of screenshots) {
|
|
137
|
+
if (!isObject(s) || typeof s.alt !== 'string' || !s.alt) return 'listing.screenshots entry has no alt';
|
|
138
|
+
const text = textError('screenshots.title', s.title, 40) ?? textError('screenshots.caption', s.caption, 200);
|
|
139
|
+
if (text) return text;
|
|
140
|
+
if ((s.path === undefined) === (s.url === undefined)) return 'listing.screenshots entry needs one of path or url';
|
|
141
|
+
const file = s.path ?? s.url;
|
|
142
|
+
if (typeof file !== 'string' || !IMAGE_RE.test(file)) return 'listing.screenshots entry is not a PNG, JPEG or WebP file';
|
|
143
|
+
if (s.url !== undefined && !isHttps(s.url)) return 'listing.screenshots url is not https';
|
|
144
|
+
if (s.path !== undefined && !isContained(path.resolve(root, s.path), root)) return 'listing.screenshots path is outside the app directory';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (homepage !== undefined && !isHttps(homepage)) return 'listing.homepage is not an https URL';
|
|
148
|
+
if (license !== undefined && (typeof license !== 'string' || !SPDX_RE.test(license))) return 'listing.license is not an SPDX id';
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The plugin id, its folder, the hub command a user runs to install it, and `custom`, the manifest's own install
|
|
153
|
+
// command that the hub command runs. The id and folder come from marketplace.json, so this throws when it does not
|
|
154
|
+
// list the plugin as a folder inside the app directory.
|
|
155
|
+
function resolvePlugin(plugin, root, appId) {
|
|
156
|
+
const dir = path.resolve(root, plugin.marketplace);
|
|
157
|
+
const file = path.join(dir, '.claude-plugin', 'marketplace.json');
|
|
158
|
+
let market;
|
|
159
|
+
try {
|
|
160
|
+
market = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
161
|
+
} catch (e) {
|
|
162
|
+
throw new Error(`${file}: ${e.code === 'ENOENT' ? 'not found' : e.message}`);
|
|
163
|
+
}
|
|
164
|
+
if (typeof market?.name !== 'string' || !PLUGIN_NAME_RE.test(market.name)) throw new Error(`${file}: name is not a marketplace name`);
|
|
165
|
+
const listed = Array.isArray(market.plugins) ? market.plugins.find((p) => p?.name === plugin.name) : undefined;
|
|
166
|
+
if (!listed) throw new Error(`${file} does not list plugin "${plugin.name}"`);
|
|
167
|
+
if (typeof listed.source !== 'string') throw new Error(`${file}: the source of "${plugin.name}" is not a folder`);
|
|
168
|
+
const pluginDir = path.resolve(dir, listed.source);
|
|
169
|
+
if (!isContained(pluginDir, root)) throw new Error(`${file}: the source of "${plugin.name}" is outside the app directory`);
|
|
170
|
+
return {
|
|
171
|
+
id: `${plugin.name}@${market.name}`,
|
|
172
|
+
dir: pluginDir,
|
|
173
|
+
bundled: bundledVersion(pluginDir),
|
|
174
|
+
marketplace: { dir, name: market.name },
|
|
175
|
+
install: `claude-code-hub ext install ${appId}`,
|
|
176
|
+
custom: plugin.install ?? null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
84
180
|
function keysError(keys) {
|
|
85
181
|
if (keys === undefined) return null;
|
|
86
|
-
if (
|
|
182
|
+
if (!isObject(keys)) return 'keys is not an object';
|
|
87
183
|
if (keys.keeps === undefined) return null;
|
|
88
184
|
if (!Array.isArray(keys.keeps)) return 'keys.keeps is not an array';
|
|
89
185
|
for (const c of keys.keeps) {
|
|
@@ -111,7 +207,7 @@ const CAPABILITY_PATHS = { projects: 'path', terminal: 'liveWork' };
|
|
|
111
207
|
|
|
112
208
|
function providesError(provides) {
|
|
113
209
|
if (provides === undefined) return null;
|
|
114
|
-
if (
|
|
210
|
+
if (!isObject(provides)) return 'provides is not an object';
|
|
115
211
|
for (const [cap, key] of Object.entries(CAPABILITY_PATHS)) {
|
|
116
212
|
if (provides[cap] === undefined) continue;
|
|
117
213
|
const p = provides[cap]?.[key];
|
|
@@ -120,11 +216,17 @@ function providesError(provides) {
|
|
|
120
216
|
return null;
|
|
121
217
|
}
|
|
122
218
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
219
|
+
// The manifest at root and its resolved plugin. With no id, the manifest's own id is checked. Throws with the file and the reason.
|
|
220
|
+
function readApp(root, id) {
|
|
221
|
+
const file = path.join(root, 'hub-app.json');
|
|
222
|
+
try {
|
|
223
|
+
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
224
|
+
const err = manifestError(manifest, id ?? manifest?.id, root);
|
|
225
|
+
if (err) throw new Error(err);
|
|
226
|
+
return { manifest, plugin: manifest.plugin && resolvePlugin(manifest.plugin, root, manifest.id) };
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw new Error(`${file}: ${e.code === 'ENOENT' ? 'not found' : e.message}`);
|
|
229
|
+
}
|
|
128
230
|
}
|
|
129
231
|
|
|
130
232
|
// The entry's path, else the submodule when it is checked out, else the installed package.
|
|
@@ -152,29 +254,27 @@ function userPort(app, flagPort, log) {
|
|
|
152
254
|
function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve, log = console.log } = {}) {
|
|
153
255
|
const root = appRoot(app, hubRoot, resolve);
|
|
154
256
|
let manifest;
|
|
257
|
+
let plugin;
|
|
155
258
|
try {
|
|
156
|
-
manifest =
|
|
259
|
+
({ manifest, plugin } = readApp(root, app.id));
|
|
157
260
|
} catch (e) {
|
|
158
|
-
|
|
159
|
-
log(`${path.join(root, 'hub-app.json')}: ${reason}. ${app.id} is skipped`);
|
|
261
|
+
log(`${e.message}. ${app.id} is skipped`);
|
|
160
262
|
return null;
|
|
161
263
|
}
|
|
162
264
|
let version = null;
|
|
163
265
|
try {
|
|
164
266
|
version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version ?? null;
|
|
165
267
|
} catch {}
|
|
166
|
-
const { plugin } = manifest;
|
|
167
268
|
return {
|
|
168
269
|
id: app.id,
|
|
169
270
|
version,
|
|
170
|
-
plugin: plugin && {
|
|
171
|
-
id: plugin.id,
|
|
172
|
-
bundled: bundledVersion(path.join(root, plugin.path)),
|
|
173
|
-
install: plugin.install,
|
|
174
|
-
},
|
|
271
|
+
plugin: plugin && { id: plugin.id, bundled: plugin.bundled, install: plugin.install },
|
|
175
272
|
entry: path.join(root, manifest.run.entry),
|
|
176
273
|
port: userPort(app, flagPort, log) ?? manifest.run.defaultPort ?? 0,
|
|
177
274
|
name: manifest.name ?? app.id,
|
|
275
|
+
hidden: app.hidden === true,
|
|
276
|
+
deferredStart: app.deferredStart === true,
|
|
277
|
+
linked: app.linked === true,
|
|
178
278
|
icon: manifest.icon,
|
|
179
279
|
loading: manifest.loading ?? {},
|
|
180
280
|
actions: manifest.actions ?? {},
|
|
@@ -184,4 +284,4 @@ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve,
|
|
|
184
284
|
};
|
|
185
285
|
}
|
|
186
286
|
|
|
187
|
-
module.exports = { APPS, selectApps, loadApp, manifestError, conflicts };
|
|
287
|
+
module.exports = { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, loadApp, manifestError, readApp, resolvePlugin, conflicts };
|