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 CHANGED
@@ -179,15 +179,19 @@ The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd
179
179
  --memory-port <n> Memory Diagnoser port (default: 3544)
180
180
  --pool-size <n> Config-dir sets kept running after a switch (default: 3)
181
181
  --disable-terminal Turn off the embedded terminal in Kanban
182
+ --no-mods Load no mod (or "modsEnabled": false in config.json)
182
183
  --host <addr> Bind address (default: 127.0.0.1)
183
184
  --allowed-hosts <list> Extra host names to accept, comma-separated
184
185
  --open Open the browser on start
186
+ --show-token Print the URL with its token also when output is not a terminal
185
187
  --detached Keep running when stdin closes (the macOS tray uses it)
186
188
  --tray Windows, macOS: run the hub from a tray icon
187
189
  --autostart Windows, macOS: same as --tray, and start the tray at logon
188
190
  --no-autostart Windows, macOS: stop starting the tray at logon
189
191
  --tray-status Windows, macOS: show whether autostart is on and the tray and hub are running
190
192
  --app-id <id> Windows, macOS, with --tray or --autostart: the installed app that Open Hub starts
193
+ -v, --version Print the version
194
+ -h, --help Print the flags
191
195
  ```
192
196
 
193
197
  If a port is busy, the hub uses a free port and reports it.
@@ -0,0 +1,33 @@
1
+ /* A hairline of holly, pine and gold along the top edge of the window. Drawn once, no animation. */
2
+ body::before {
3
+ content: "";
4
+ position: fixed;
5
+ inset: 0 0 auto 0;
6
+ height: 2px;
7
+ z-index: 50;
8
+ pointer-events: none;
9
+ opacity: 0.7;
10
+ background: repeating-linear-gradient(
11
+ 90deg,
12
+ #b5333c 0 18px,
13
+ transparent 18px 26px,
14
+ #3f6b55 26px 44px,
15
+ transparent 44px 52px,
16
+ #c9a45c 52px 58px,
17
+ transparent 58px 66px
18
+ );
19
+ }
20
+
21
+ :root[data-hub-mode="light"] body::before {
22
+ opacity: 0.55;
23
+ }
24
+
25
+ /* Above the app frames, below the hub's toasts and dialogs. */
26
+ .xmas-snow {
27
+ position: fixed;
28
+ inset: 0;
29
+ width: 100%;
30
+ height: 100%;
31
+ z-index: 12;
32
+ pointer-events: none;
33
+ }
@@ -0,0 +1,199 @@
1
+ // Sparse snow over every tab. A canvas above the app frames that takes no clicks. It draws at
2
+ // 30 fps at most, stops while the window is hidden, and never starts when the OS asks for less motion.
3
+ (() => {
4
+ const motion = matchMedia('(prefers-reduced-motion: reduce)');
5
+ const canvas = Object.assign(document.createElement('canvas'), { className: 'xmas-snow', ariaHidden: 'true' });
6
+ const ctx = canvas.getContext('2d');
7
+ const rand = (a, b) => a + Math.random() * (b - a);
8
+
9
+ // Three depths: far flakes are small, slow and faint; near ones are larger, faster, and some are crystals.
10
+ const LAYERS = [
11
+ { share: 0.58, r: [0.5, 1.1], v: [7, 13], a: [0.18, 0.34], wind: 0.45 },
12
+ { share: 0.32, r: [1.1, 1.9], v: [15, 26], a: [0.32, 0.55], wind: 0.8 },
13
+ { share: 0.1, r: [2.2, 3.4], v: [30, 44], a: [0.45, 0.7], wind: 1.2, crystal: 0.5 },
14
+ ];
15
+
16
+ let w = 0;
17
+ let h = 0;
18
+ let frame = 0;
19
+ let last = 0;
20
+ let clock = rand(0, 1000);
21
+ let light = null;
22
+ let sprites = null;
23
+ let flakes = [];
24
+ let resizing = 0;
25
+ let base = 0;
26
+ let target = 0;
27
+ let level = 0;
28
+ let pace = 1;
29
+ const gust = { v: 0, target: 0, next: rand(6, 14) };
30
+
31
+ function sprite(size, draw) {
32
+ const c = document.createElement('canvas');
33
+ c.width = c.height = size;
34
+ const g = c.getContext('2d');
35
+ draw(g, size / 2);
36
+ return c;
37
+ }
38
+
39
+ function makeSprites() {
40
+ const ink = light ? '127,147,163' : '238,243,240';
41
+ const dot = sprite(16, (g, m) => {
42
+ const grad = g.createRadialGradient(m, m, 0, m, m, m);
43
+ grad.addColorStop(0, `rgba(${ink},1)`);
44
+ grad.addColorStop(0.45, `rgba(${ink},0.85)`);
45
+ grad.addColorStop(1, `rgba(${ink},0)`);
46
+ g.fillStyle = grad;
47
+ g.fillRect(0, 0, m * 2, m * 2);
48
+ });
49
+ const crystal = sprite(24, (g, m) => {
50
+ g.strokeStyle = `rgba(${ink},1)`;
51
+ g.lineWidth = 1;
52
+ g.lineCap = 'round';
53
+ g.translate(m, m);
54
+ for (let i = 0; i < 6; i++) {
55
+ g.rotate(Math.PI / 3);
56
+ g.beginPath();
57
+ g.moveTo(0, 0);
58
+ g.lineTo(0, -m + 2);
59
+ g.moveTo(0, -m * 0.5);
60
+ g.lineTo(-3, -m * 0.5 - 3);
61
+ g.moveTo(0, -m * 0.5);
62
+ g.lineTo(3, -m * 0.5 - 3);
63
+ g.stroke();
64
+ }
65
+ });
66
+ sprites = { dot, crystal };
67
+ }
68
+
69
+ function layerOf() {
70
+ let x = Math.random();
71
+ for (const l of LAYERS) if ((x -= l.share) <= 0) return l;
72
+ return LAYERS[0];
73
+ }
74
+
75
+ function flake(top) {
76
+ const l = layerOf();
77
+ return {
78
+ l,
79
+ x: rand(-20, w + 20),
80
+ y: top ? rand(-60, -8) : rand(0, h),
81
+ r: rand(...l.r),
82
+ v: rand(...l.v),
83
+ a: rand(...l.a),
84
+ crystal: Math.random() < (l.crystal ?? 0),
85
+ amp: rand(4, 14),
86
+ f1: rand(0.25, 0.9),
87
+ f2: rand(1.1, 2.3),
88
+ p1: rand(0, Math.PI * 2),
89
+ p2: rand(0, Math.PI * 2),
90
+ rot: rand(0, Math.PI),
91
+ spin: rand(-0.6, 0.6),
92
+ };
93
+ }
94
+
95
+ // The canvas stays at 1x on a HiDPI screen: soft dots look the same, and each frame clears and
96
+ // composites a quarter of the pixels.
97
+ function size() {
98
+ resizing = 0;
99
+ w = innerWidth;
100
+ h = innerHeight;
101
+ canvas.width = w;
102
+ canvas.height = h;
103
+ base = Math.round(Math.min(40, Math.max(18, (w * h) / 60000)));
104
+ while (flakes.length < base) flakes.push(flake(false));
105
+ retarget();
106
+ }
107
+
108
+ // Busy sessions add up to 80% more flakes. New ones start above the top edge, and spare ones
109
+ // leave only when they fall out at the bottom, so the change never pops.
110
+ function retarget() {
111
+ target = Math.round(base * (1 + level * 0.8));
112
+ }
113
+
114
+ // Wind is two slow waves plus a gust now and then, so the drift never settles into one pattern.
115
+ function wind(dt) {
116
+ gust.next -= dt;
117
+ if (gust.next <= 0) {
118
+ gust.target = gust.target ? 0 : rand(-22, 22);
119
+ gust.next = gust.target ? rand(2, 5) : rand(8, 20);
120
+ }
121
+ gust.v += (gust.target - gust.v) * Math.min(1, dt * 0.8);
122
+ return Math.sin(clock * 0.07) * 5 + Math.sin(clock * 0.023 + 1) * 8 + gust.v;
123
+ }
124
+
125
+ function draw(now) {
126
+ frame = requestAnimationFrame(draw);
127
+ const dt = Math.min((now - last) / 1000, 0.1);
128
+ if (dt < 1 / 30) return;
129
+ last = now;
130
+ clock += dt;
131
+ const isLight = document.documentElement.dataset.hubMode === 'light';
132
+ if (isLight !== light) {
133
+ light = isLight;
134
+ makeSprites();
135
+ }
136
+ const breeze = wind(dt);
137
+ pace += (1 + level * 0.45 - pace) * Math.min(1, dt * 0.3);
138
+ if (flakes.length < target) flakes.push(flake(true));
139
+ ctx.clearRect(0, 0, w, h);
140
+ for (let i = flakes.length - 1; i >= 0; i--) {
141
+ const f = flakes[i];
142
+ const sway = Math.sin(clock * f.f1 + f.p1) * f.amp + Math.sin(clock * f.f2 + f.p2) * f.amp * 0.3;
143
+ f.y += f.v * pace * dt;
144
+ f.x += (breeze * f.l.wind + sway) * dt;
145
+ if (f.x < -30) f.x += w + 60;
146
+ else if (f.x > w + 30) f.x -= w + 60;
147
+ if (f.y > h + 12) {
148
+ if (flakes.length > target) {
149
+ flakes.splice(i, 1);
150
+ continue;
151
+ }
152
+ Object.assign(f, flake(true));
153
+ }
154
+ ctx.globalAlpha = light ? f.a * 0.85 : f.a;
155
+ if (f.crystal) {
156
+ f.rot += f.spin * dt;
157
+ const s = f.r * 3.4;
158
+ ctx.save();
159
+ ctx.translate(f.x, f.y);
160
+ ctx.rotate(f.rot);
161
+ ctx.drawImage(sprites.crystal, -s, -s, s * 2, s * 2);
162
+ ctx.restore();
163
+ } else {
164
+ const s = f.r * 2.2;
165
+ ctx.drawImage(sprites.dot, f.x - s, f.y - s, s * 2, s * 2);
166
+ }
167
+ }
168
+ }
169
+
170
+ function start() {
171
+ if (frame || document.hidden || motion.matches) return;
172
+ last = performance.now();
173
+ frame = requestAnimationFrame(draw);
174
+ }
175
+
176
+ function stop() {
177
+ cancelAnimationFrame(frame);
178
+ frame = 0;
179
+ }
180
+
181
+ window.ClaudeHub?.mod.on('xmas:activity', ({ data }) => {
182
+ if (typeof data !== 'number') return;
183
+ level = Math.min(1, Math.max(0, data));
184
+ retarget();
185
+ });
186
+
187
+ size();
188
+ document.body.appendChild(canvas);
189
+ addEventListener('resize', () => {
190
+ resizing ||= requestAnimationFrame(size);
191
+ });
192
+ document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
193
+ motion.addEventListener('change', () => {
194
+ stop();
195
+ ctx.clearRect(0, 0, w, h);
196
+ start();
197
+ });
198
+ start();
199
+ })();
@@ -0,0 +1,141 @@
1
+ :root {
2
+ --xmas-red: #b5333c;
3
+ --xmas-pine: #3f6b55;
4
+ --xmas-gold: #c9a45c;
5
+ --xmas-trim: #f1ede4;
6
+ }
7
+
8
+ :root[data-hub-mode="light"] {
9
+ --xmas-trim: #d6cbb4;
10
+ }
11
+
12
+ /* A snowflake after a finished task's number, in the board's own muted ink. */
13
+ [data-hub-part="task-card"].completed [data-hub-part="task-number"]::after {
14
+ content: "\2744";
15
+ margin-left: 6px;
16
+ color: var(--text-muted);
17
+ opacity: 0.75;
18
+ font-size: 11px;
19
+ line-height: 1;
20
+ }
21
+
22
+ [data-hub-part="task-card"].completed:hover [data-hub-part="task-number"]::after {
23
+ color: var(--accent-text);
24
+ opacity: 1;
25
+ }
26
+
27
+ /* A small gold star after the Completed title when every task is done. */
28
+ [data-hub-part="board"].xmas-all-done [data-hub-part="done-title"]::after {
29
+ content: " \2605";
30
+ color: var(--xmas-gold);
31
+ letter-spacing: 0;
32
+ }
33
+
34
+ /* Fairy lights: the header bar is a wire of unlit bulbs, and the fill lights them in turn,
35
+ cranberry, gold, pine. The fill's width still comes from the board. */
36
+ /* :root lifts it over the board's own two-class rule for the bar. */
37
+ :root [data-hub-part="progress"] {
38
+ height: 8px;
39
+ border-radius: 0;
40
+ overflow: visible;
41
+ background:
42
+ radial-gradient(circle at 6px 4px, color-mix(in srgb, var(--text-muted) 35%, transparent) 1.6px, transparent 2.2px) 0 0 / 12px 8px repeat-x,
43
+ linear-gradient(var(--border), var(--border)) 0 50% / 100% 1px no-repeat;
44
+ }
45
+
46
+ [data-hub-part="progress"] [data-hub-part="progress-fill"] {
47
+ overflow: hidden;
48
+ background:
49
+ radial-gradient(circle at 6px 4px, #d24c55 2px, transparent 2.6px) 0 0 / 36px 8px repeat-x,
50
+ radial-gradient(circle at 18px 4px, #ddb865 2px, transparent 2.6px) 0 0 / 36px 8px repeat-x,
51
+ radial-gradient(circle at 30px 4px, #5c9a7a 2px, transparent 2.6px) 0 0 / 36px 8px repeat-x;
52
+ filter: drop-shadow(0 0 2px rgba(221, 184, 101, 0.55));
53
+ }
54
+
55
+ /* A few twinkles when the bar appears, not forever, so an idle board draws no frames. */
56
+ @media (prefers-reduced-motion: no-preference) {
57
+ [data-hub-part="progress-fill"] {
58
+ animation: xmas-twinkle 5s ease-in-out 3;
59
+ }
60
+ }
61
+
62
+ @keyframes xmas-twinkle {
63
+ 50% {
64
+ opacity: 0.78;
65
+ }
66
+ }
67
+
68
+ /* The open session's border is a thin garland, like the hub's top line. Diagonal stripes masked
69
+ to a ring put short dashes on all four sides and keep the card's rounded corners. */
70
+ [data-hub-part="session-card"].active {
71
+ border-color: transparent;
72
+ box-shadow: 0 0 14px -8px rgba(221, 184, 101, 0.5);
73
+ }
74
+
75
+ [data-hub-part="session-card"].active::after {
76
+ content: "";
77
+ position: absolute;
78
+ inset: -1px;
79
+ padding: 1.5px;
80
+ border-radius: inherit;
81
+ pointer-events: none;
82
+ opacity: 0.7;
83
+ background: repeating-linear-gradient(
84
+ 45deg,
85
+ var(--xmas-red) 0 9px,
86
+ transparent 9px 14px,
87
+ var(--xmas-pine) 14px 23px,
88
+ transparent 23px 28px,
89
+ var(--xmas-gold) 28px 37px,
90
+ transparent 37px 42px
91
+ );
92
+ -webkit-mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
93
+ mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
94
+ }
95
+
96
+ :root[data-hub-mode="light"] [data-hub-part="session-card"].active::after {
97
+ opacity: 0.6;
98
+ }
99
+
100
+ /* Days to Christmas, set by kanban.js. A pseudo-element, because the board rebuilds the footer's children. */
101
+ [data-hub-part="sidebar-footer"] {
102
+ flex-wrap: wrap;
103
+ row-gap: 6px;
104
+ }
105
+
106
+ [data-hub-part="sidebar-footer"]::before {
107
+ content: var(--xmas-advent, none);
108
+ order: 1;
109
+ flex-basis: 100%;
110
+ color: var(--text-muted);
111
+ font-size: 10px;
112
+ white-space: nowrap;
113
+ }
114
+
115
+ /* The same hat on the footer's Kanbot, 1px per pixel: trim and pompom as gradient layers over
116
+ the red. CSS, because the board rebuilds the footer on each poll, which would drop added nodes. */
117
+ [data-hub-part="sidebar-footer"] button:has(> [data-hub-part="kanbot"]) {
118
+ position: relative;
119
+ }
120
+
121
+ [data-hub-part="sidebar-footer"] button:has(> [data-hub-part="kanbot"])::before {
122
+ content: "";
123
+ position: absolute;
124
+ width: 10px;
125
+ height: 6px;
126
+ margin: -6px 0 0 3px;
127
+ pointer-events: none;
128
+ background:
129
+ linear-gradient(var(--xmas-trim) 0 0) 0 5px / 10px 1px,
130
+ linear-gradient(var(--xmas-trim) 0 0) 8px 0 / 2px 2px,
131
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' shape-rendering='crispEdges'%3E%3Cpath fill='%23b5333c' d='M1 4h8v1H1zM2 3h6v1H2zM3 2h5v1H3zM5 1h3v1H5z'/%3E%3C/svg%3E");
132
+ background-repeat: no-repeat;
133
+ }
134
+
135
+ [data-hub-part="idle-mascot"] .xmas-hat-red {
136
+ fill: var(--xmas-red);
137
+ }
138
+
139
+ [data-hub-part="idle-mascot"] .xmas-hat-trim {
140
+ fill: var(--xmas-trim);
141
+ }
@@ -0,0 +1,57 @@
1
+ (() => {
2
+ const mod = window.ClaudeHub?.mod;
3
+ if (!mod) return;
4
+
5
+ mod.on('board.rendered', () => {
6
+ const board = document.querySelector('[data-hub-part="board"]');
7
+ if (!board) return;
8
+ const card = '[data-hub-part="task-card"]';
9
+ board.classList.toggle('xmas-all-done', !!board.querySelector(card) && !board.querySelector(`${card}:not(.completed)`));
10
+ });
11
+
12
+ // Santa hat in the mascot's own pixel grid (viewBox units), inside its svg so it floats with it.
13
+ const HAT = [
14
+ ['red', 4, -2, 10, 1],
15
+ ['red', 5, -3, 8, 1],
16
+ ['red', 7, -4, 6, 1],
17
+ ['red', 10, -5, 4, 1],
18
+ ['trim', 3, -1, 12, 1],
19
+ ['trim', 14, -6, 2, 2],
20
+ ];
21
+ const svg = document.querySelector('[data-hub-part="idle-mascot"] svg');
22
+ if (svg) {
23
+ const ns = 'http://www.w3.org/2000/svg';
24
+ const g = document.createElementNS(ns, 'g');
25
+ for (const [part, x, y, width, height] of HAT) {
26
+ const r = document.createElementNS(ns, 'rect');
27
+ for (const [k, v] of Object.entries({ x, y, width, height })) r.setAttribute(k, v);
28
+ r.setAttribute('class', `xmas-hat-${part}`);
29
+ g.append(r);
30
+ }
31
+ svg.append(g);
32
+ }
33
+
34
+ const footer = document.querySelector('[data-hub-part="sidebar-footer"]');
35
+ function advent() {
36
+ const now = new Date();
37
+ let xmas = new Date(now.getFullYear(), 11, 25);
38
+ if (now.getMonth() === 11 && now.getDate() > 26) xmas = new Date(now.getFullYear() + 1, 11, 25);
39
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
40
+ const days = Math.round((xmas - today) / 86400000);
41
+ const text = days <= 0 ? '★ Merry Christmas' : `★ ${days} ${days === 1 ? 'day' : 'days'} to Christmas`;
42
+ footer?.style.setProperty('--xmas-advent', JSON.stringify(text));
43
+ }
44
+ advent();
45
+ setInterval(advent, 3600000);
46
+
47
+ // The hub's snow falls a little faster and thicker while sessions work. The level is a number
48
+ // from 0 to 1 and goes to the hub page only when it changes.
49
+ let level = -1;
50
+ mod.on('activity.rendered', (counts) => {
51
+ if (!counts) return;
52
+ const next = Math.min(counts.active, 4) / 4;
53
+ if (next === level) return;
54
+ level = next;
55
+ mod.send('xmas:activity', level);
56
+ });
57
+ })();
@@ -0,0 +1,57 @@
1
+ {
2
+ "manifest": 1,
3
+ "kind": "mod",
4
+ "id": "xmas",
5
+ "title": "Christmas",
6
+ "themes": [
7
+ {
8
+ "id": "xmas",
9
+ "label": "Christmas",
10
+ "extends": "ember",
11
+ "dark": {
12
+ "ember": "#c8424a",
13
+ "emberGlow": "#d9b46a",
14
+ "emberDim": "rgba(200,66,74,0.22)",
15
+ "field": "#0f1513",
16
+ "surface": "#141b18",
17
+ "elevated": "#1a2320",
18
+ "hover": "#222d29",
19
+ "border": "#2a3631"
20
+ },
21
+ "light": {
22
+ "ember": "#a8323a",
23
+ "emberGlow": "#7d2329",
24
+ "emberDim": "rgba(168,50,58,0.14)",
25
+ "field": "#eceee9",
26
+ "surface": "#f2f3ef",
27
+ "elevated": "#fbfbf8",
28
+ "hover": "#d9ddd5",
29
+ "border": "#cdd3c9"
30
+ }
31
+ }
32
+ ],
33
+ "targets": {
34
+ "hub": { "surface": 1, "css": ["hub.css"], "js": ["hub.js"] },
35
+ "kanban": { "surface": 1, "css": ["kanban.css"], "js": ["kanban.js"] }
36
+ },
37
+ "listing": {
38
+ "tagline": "A quiet Christmas look for the hub and Kanban",
39
+ "description": "Pine and cranberry colors, light snow that falls faster while sessions work, fairy lights on the progress bar, a garland around the open session, a Santa hat on the mascot and a countdown to Christmas. It runs a little JS, so the install asks you first.",
40
+ "tags": ["theme", "seasonal", "mod"],
41
+ "screenshots": [
42
+ {
43
+ "url": "https://nikiforovall.blog/claude-code-hub/shots/extensions/xmas-demo.webp",
44
+ "alt": "Animation: the hub with the Christmas mod in dark mode. Snow falls over the board, a garland of red, green and gold dashes moves to each session the user opens, and the progress bar in the header twinkles as fairy lights. Then the same board in light mode",
45
+ "title": "Snow, lights and a garland",
46
+ "caption": "The garland follows the open session, and the snow falls faster while sessions work.",
47
+ "card": true
48
+ },
49
+ {
50
+ "url": "https://nikiforovall.blog/claude-code-hub/shots/extensions/xmas-hub-light.webp",
51
+ "alt": "The hub in light mode with the Christmas mod: a garland around the open session card, fairy lights on the header progress bar, a red and green dashed line at the top and grey snowflakes",
52
+ "title": "Light mode",
53
+ "caption": "Each part has its own light-mode colors."
54
+ }
55
+ ]
56
+ }
57
+ }
package/lib/apps.js CHANGED
@@ -311,6 +311,7 @@ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve,
311
311
  } catch {}
312
312
  return {
313
313
  id: app.id,
314
+ root,
314
315
  version,
315
316
  plugin: plugin && { id: plugin.id, bundled: plugin.bundled, install: plugin.install },
316
317
  entry: path.join(root, manifest.run.entry),
package/lib/cli.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const { version: HUB_VERSION } = require('../package.json');
4
+
5
+ // Every flag the hub reads, and whether it takes a value. `--<app id>-port` is open-ended, because
6
+ // config.json can add apps.
7
+ const FLAGS = {
8
+ port: true,
9
+ 'hub-dir': true,
10
+ 'pool-size': true,
11
+ 'disable-terminal': false,
12
+ 'no-mods': false,
13
+ host: true,
14
+ 'allowed-hosts': true,
15
+ open: false,
16
+ 'show-token': false,
17
+ detached: false,
18
+ install: false,
19
+ uninstall: false,
20
+ dir: true,
21
+ tray: false,
22
+ autostart: false,
23
+ 'no-autostart': false,
24
+ 'tray-status': false,
25
+ 'app-id': true,
26
+ version: false,
27
+ help: false,
28
+ };
29
+ const SHORT = { '-v': 'version', '-h': 'help' };
30
+ const APP_PORT_RE = /^[a-z0-9-]+-port$/;
31
+
32
+ const HELP = `Claude Code Hub ${HUB_VERSION}
33
+
34
+ Usage:
35
+ claude-code-hub [options] Start the hub, or print the URL of the one that runs
36
+ claude-code-hub ext <command> Manage extensions (claude-code-hub ext --help)
37
+
38
+ Options:
39
+ --port <n> Hub port (default 3540)
40
+ --<app>-port <n> Port of an app: kanban, marketplace, cost, memory
41
+ --hub-dir <path> Hub dir (default ~/.claude-hub)
42
+ --pool-size <n> Config dirs kept running (default 3)
43
+ --disable-terminal Turn off the embedded terminal in Kanban
44
+ --no-mods Start without mods
45
+ --host <addr> Bind address (default 127.0.0.1)
46
+ --allowed-hosts <list> Extra host names to accept, comma-separated
47
+ --open Open the hub in the browser
48
+ --show-token Print the hub URL with its token also when output is not a terminal
49
+ --detached Keep running when stdin closes
50
+ --install, --uninstall Install or remove the hub plugin (--dir <config dir>)
51
+ --tray, --autostart, --no-autostart, --tray-status, --app-id <id>
52
+ Tray and start at login (Windows and macOS)
53
+ -v, --version Print the version
54
+ -h, --help Print this help`;
55
+
56
+ // {exit, out} when the command line is answered here: the version, the help, or a usage error.
57
+ // {} when the hub should go on. `ext` keeps its own parser.
58
+ function checkArgs(args) {
59
+ if (args[0] === 'ext') return {};
60
+ const bad = (what) => ({ exit: 1, out: `${what}\n\n${HELP}` });
61
+ for (let i = 0; i < args.length; i++) {
62
+ const arg = args[i];
63
+ if (!arg.startsWith('-')) return bad(i === 0 ? `Unknown command: ${arg}` : `Unexpected argument: ${arg}`);
64
+ const [flag, value] = arg.startsWith('--') ? [arg.slice(2).split('=')[0], arg.includes('=')] : [SHORT[arg], false];
65
+ const takes = FLAGS[flag] ?? (APP_PORT_RE.test(flag ?? '') || undefined);
66
+ if (takes === undefined) return bad(`Unknown option: ${arg}`);
67
+ if (flag === 'version') return { exit: 0, out: HUB_VERSION };
68
+ if (flag === 'help') return { exit: 0, out: HELP };
69
+ if (takes && !value) i++;
70
+ }
71
+ return {};
72
+ }
73
+
74
+ // The hub URL as printed. Off a terminal (an agent's shell, a pipe, the tray's hub.log) the output is
75
+ // kept or read by others, so the token is hidden unless --show-token asks for it.
76
+ function printedUrl(line, url, { show, tokenFile }) {
77
+ if (show) return `${line} ${url}`;
78
+ const hidden = url.replace(/([?&]token=)[^&]*/, '$1<hidden>');
79
+ return `${line} ${hidden}\n The token is hidden. Open the hub with --open, print it with --show-token, or read ${tokenFile}`;
80
+ }
81
+
82
+ module.exports = { HUB_VERSION, FLAGS, HELP, checkArgs, printedUrl };