claude-code-hub 1.11.0 → 1.12.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 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 hooks:
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
- npx claude-code-kanban --install
83
+ claude-code-hub --install
84
84
  ```
85
85
 
86
- Without the hooks you still get the task board and sessions, but no live agent activity. The hooks and status line are installed per Claude config dir, so run the command again for each dir you use. See the [Kanban docs](https://nikiforovall.blog/claude-code-kanban/) for details.
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) => ({ ...a, port: entryOf(a.id)?.port, path: pathOf(a.id) }));
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 providesError(m.provides) ?? publishesError(m.publishes) ?? keysError(m.keys) ?? pluginError(m.plugin, root);
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 PLUGIN_ID_RE = /^[\w.-]+@[\w.-]+$/;
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 (typeof plugin?.id !== 'string' || !PLUGIN_ID_RE.test(plugin.id)) return 'plugin.id is not <name>@<marketplace>';
77
- if (typeof plugin.path !== 'string') return 'plugin.path is missing';
78
- const dir = path.resolve(root, plugin.path);
79
- if (dir === path.resolve(root) || !isContained(dir, root)) return 'plugin.path is outside the app directory';
80
- if (typeof plugin.install !== 'string' || !plugin.install) return 'plugin.install is missing';
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 (typeof keys !== 'object' || keys === null || Array.isArray(keys)) return 'keys is not an object';
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 (typeof provides !== 'object' || provides === null || Array.isArray(provides)) return 'provides is not an object';
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
- function readManifest(root, id) {
124
- const m = JSON.parse(fs.readFileSync(path.join(root, 'hub-app.json'), 'utf8'));
125
- const err = manifestError(m, id, root);
126
- if (err) throw new Error(err);
127
- return m;
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 = readManifest(root, app.id);
259
+ ({ manifest, plugin } = readApp(root, app.id));
157
260
  } catch (e) {
158
- const reason = e.code === 'ENOENT' ? 'not found' : e.message;
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 };