@kivimedia/kmhub 2.9.1 → 2.10.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 +170 -170
- package/bin/kmhub.mjs +896 -896
- package/coach-book-output-guard.mjs +760 -760
- package/index.mjs +57 -57
- package/package.json +56 -56
- package/prompts/briefing.md +29 -29
- package/prompts/luxury.md +70 -70
- package/prompts/play.md +49 -49
- package/prompts/run.md +36 -36
- package/prompts/setup.md +33 -33
- package/prompts/vs-booked.md +46 -46
- package/prompts/what-can-you-do.md +40 -40
- package/prompts.mjs +110 -110
- package/read-only-tools.json +143 -142
- package/remote.mjs +929 -929
- package/tools/balloon-costing.mjs +80 -80
- package/tools/booking-equipment.mjs +110 -110
- package/tools/bridges.mjs +54 -54
- package/tools/briefing.mjs +91 -91
- package/tools/calendar.mjs +170 -170
- package/tools/capabilities.mjs +155 -155
- package/tools/catalog.mjs +288 -288
- package/tools/clubs.mjs +176 -176
- package/tools/coach.mjs +771 -771
- package/tools/compare.mjs +76 -76
- package/tools/core.mjs +244 -244
- package/tools/crm.mjs +209 -209
- package/tools/dubsado.mjs +137 -137
- package/tools/exports.mjs +128 -128
- package/tools/fact-review.mjs +125 -125
- package/tools/flows.mjs +261 -261
- package/tools/forms.mjs +158 -158
- package/tools/gols.mjs +134 -134
- package/tools/hr.mjs +162 -162
- package/tools/knowledge.mjs +125 -125
- package/tools/marketing.mjs +396 -396
- package/tools/meta.mjs +245 -245
- package/tools/military.mjs +244 -244
- package/tools/money.mjs +235 -197
- package/tools/outreach.mjs +238 -238
- package/tools/pending.mjs +122 -122
- package/tools/photos.mjs +140 -140
- package/tools/plays.mjs +244 -244
- package/tools/profile.mjs +118 -118
- package/tools/radar.mjs +173 -173
- package/tools/recurring-invoices.mjs +149 -149
- package/tools/reengage.mjs +434 -434
- package/tools/schedules.mjs +55 -55
- package/tools/setup.mjs +168 -168
- package/tools/sops-bridges.mjs +86 -86
- package/tools/sops.mjs +314 -314
- package/tools/sourcing.mjs +268 -268
- package/tools/strategy.mjs +146 -146
- package/tools/studio.mjs +132 -132
- package/tools/venueradar.mjs +151 -151
- package/tools/voice.mjs +134 -134
- package/tools.mjs +407 -407
package/bin/kmhub.mjs
CHANGED
|
@@ -1,896 +1,896 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* kmhub - the Terminal Mode command line tool.
|
|
4
|
-
*
|
|
5
|
-
* Three jobs, and only three:
|
|
6
|
-
*
|
|
7
|
-
* kmhub install <key> [name] register the connector in this machine's Claude Code
|
|
8
|
-
* kmhub update [key] [name] is this build still one KM Hub serves
|
|
9
|
-
* kmhub doctor [key] [name] why is it not working
|
|
10
|
-
*
|
|
11
|
-
* The whole point of this file is that a client never types an absolute path
|
|
12
|
-
* again. The old instruction was `claude mcp add kmhub -e KEY=... -- node
|
|
13
|
-
* /absolute/path/to/kmhub/mcp-server/index.mjs`, which only works if they cloned
|
|
14
|
-
* a private repo first and then got the path right. Published on npm, the same
|
|
15
|
-
* job is `npx @kivimedia/kmhub install kmh_live_...` and the connector is
|
|
16
|
-
* launched by npx from then on, so it updates itself when Claude Code restarts.
|
|
17
|
-
*
|
|
18
|
-
* Where the work actually happens is worth stating plainly, because it decides
|
|
19
|
-
* what this tool is allowed to do. Terminal Mode runs on the CLIENT machine,
|
|
20
|
-
* inside THEIR Claude Code, on THEIR Claude subscription. Nothing here schedules
|
|
21
|
-
* anything, runs an agent, or phones home. It registers a connector, reads two
|
|
22
|
-
* public-to-the-key endpoints (GET /me and GET /version), and reports.
|
|
23
|
-
*
|
|
24
|
-
* Deliberately dependency free. It imports node builtins and, only where it has
|
|
25
|
-
* something to say about them, the connector's own modules. `doctor` has to be
|
|
26
|
-
* the command that works when everything else is broken, so it cannot rely on
|
|
27
|
-
* the thing it is diagnosing being importable.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { spawnSync } from 'node:child_process';
|
|
31
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
-
import { homedir } from 'node:os';
|
|
33
|
-
import { join } from 'node:path';
|
|
34
|
-
|
|
35
|
-
// The published package name. Every registration points at this, never at a path
|
|
36
|
-
// on the client's disk, which is the entire reason this file exists.
|
|
37
|
-
const PKG_NAME = '@kivimedia/kmhub';
|
|
38
|
-
|
|
39
|
-
/** The MCP server name a connector is registered under unless the user picks another. */
|
|
40
|
-
const DEFAULT_CONNECTOR = 'kmhub';
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Mirrors DEFAULT_BASE in tools.mjs. Duplicated on purpose: `doctor` has to be
|
|
44
|
-
* able to reach KM Hub even when tools.mjs is the thing that will not load, and
|
|
45
|
-
* "I cannot tell you anything because my own import failed" is a useless answer
|
|
46
|
-
* to a support ticket. When tools.mjs does load, its value wins.
|
|
47
|
-
*/
|
|
48
|
-
const FALLBACK_BASE = 'https://jpwbosrmkibsdgckowsm.supabase.co/functions/v1/kmhub-api';
|
|
49
|
-
|
|
50
|
-
/** Keys are minted as kmh_live_ plus hex. The API matches the same shape before it looks anything up. */
|
|
51
|
-
const KEY_RE = /^kmh_live_[0-9a-f]+$/i;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Where people who use Codex, Cursor or Grok Build are sent. This tool only knows how to
|
|
55
|
-
* register into Claude Code; the installers behind this page wire all four apps.
|
|
56
|
-
*/
|
|
57
|
-
const SETUP_URL = 'https://hub.kivimedia.co/terminal/';
|
|
58
|
-
|
|
59
|
-
/** A connector name has to survive being an argv element and a JSON key. */
|
|
60
|
-
const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
61
|
-
|
|
62
|
-
const HELP = `kmhub, the KM Hub Terminal Mode command line tool.
|
|
63
|
-
|
|
64
|
-
npx ${PKG_NAME} install <kmh_live_key> [connector-name]
|
|
65
|
-
Register the KM Hub connector in Claude Code on this machine.
|
|
66
|
-
Verifies the key against KM Hub first, so a bad key never gets registered.
|
|
67
|
-
|
|
68
|
-
npx ${PKG_NAME} update [kmh_live_key] [connector-name]
|
|
69
|
-
Ask KM Hub what it publishes today and say whether this build is still one
|
|
70
|
-
it serves.
|
|
71
|
-
|
|
72
|
-
npx ${PKG_NAME} doctor [kmh_live_key] [connector-name]
|
|
73
|
-
Diagnose a broken install: Claude Code, the registration, the key, the
|
|
74
|
-
subscription, and whether the connector files load at all.
|
|
75
|
-
|
|
76
|
-
The key is optional for update and doctor. They look, in order, at the argument
|
|
77
|
-
you passed, then KMHUB_API_KEY in the environment, then the key stored in the
|
|
78
|
-
registration itself.
|
|
79
|
-
|
|
80
|
-
Create a key in KM Hub under Settings > Connect > Developer & API.
|
|
81
|
-
Set KMHUB_API_BASE to point at a different KM Hub.
|
|
82
|
-
|
|
83
|
-
This tool registers Claude Code only. For Codex, Cursor or Grok Build, use the
|
|
84
|
-
KM Hub setup instead, which connects the app you pick and needs no key:
|
|
85
|
-
${SETUP_URL}
|
|
86
|
-
`;
|
|
87
|
-
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// small output helpers
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
function say(line = '') {
|
|
93
|
-
process.stdout.write(`${line}\n`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function fail(line = '') {
|
|
97
|
-
process.stderr.write(`${line}\n`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Fixed width labels so a doctor report reads as a column, not a paragraph. */
|
|
101
|
-
function statusLine(status, label, detail) {
|
|
102
|
-
const tag = { ok: 'ok ', warn: 'warn', fail: 'FAIL' }[status] || ' ';
|
|
103
|
-
return ` ${tag} ${label}${detail ? `: ${detail}` : ''}`;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Never print a whole key. Enough to recognise it, not enough to use it. */
|
|
107
|
-
function maskKey(key) {
|
|
108
|
-
if (typeof key !== 'string' || key.length < 16) return '(unreadable)';
|
|
109
|
-
return `${key.slice(0, 13)}...${key.slice(-4)}`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ---------------------------------------------------------------------------
|
|
113
|
-
// the connector's own modules
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
/** This package's own package.json, read from disk rather than imported. */
|
|
117
|
-
function readPkg() {
|
|
118
|
-
try {
|
|
119
|
-
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
120
|
-
} catch {
|
|
121
|
-
return {};
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Load tools.mjs, which is where the connector's real identity lives
|
|
127
|
-
* (SERVER_VERSION, the families that loaded, the API base).
|
|
128
|
-
*
|
|
129
|
-
* This is a genuine diagnostic and not just a version read. tools.mjs scans
|
|
130
|
-
* tools/ at import time, so if a family file is missing or a dependency did not
|
|
131
|
-
* install, the import is where that shows up. Failure is captured, never thrown:
|
|
132
|
-
* `doctor` reporting "your install is broken, here is the error" is the answer
|
|
133
|
-
* the support ticket needed.
|
|
134
|
-
*/
|
|
135
|
-
async function loadServer() {
|
|
136
|
-
try {
|
|
137
|
-
const mod = await import(new URL('../tools.mjs', import.meta.url));
|
|
138
|
-
return { ok: true, mod };
|
|
139
|
-
} catch (e) {
|
|
140
|
-
return { ok: false, error: String(e?.message || e) };
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function apiBase(server) {
|
|
145
|
-
const fromEnv = typeof process.env.KMHUB_API_BASE === 'string' ? process.env.KMHUB_API_BASE.trim() : '';
|
|
146
|
-
if (fromEnv) return fromEnv;
|
|
147
|
-
const fromServer = server?.ok ? server.mod?.DEFAULT_BASE : '';
|
|
148
|
-
return typeof fromServer === 'string' && fromServer ? fromServer : FALLBACK_BASE;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ---------------------------------------------------------------------------
|
|
152
|
-
// KM Hub
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* One GET against kmhub-api with the org key. Never throws: a dead network is a
|
|
157
|
-
* result to report, not a stack trace to print at somebody who wanted an answer.
|
|
158
|
-
*/
|
|
159
|
-
async function api(base, key, path, timeoutMs = 15000) {
|
|
160
|
-
const controller = new AbortController();
|
|
161
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
162
|
-
try {
|
|
163
|
-
const r = await fetch(`${base}${path}`, {
|
|
164
|
-
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
165
|
-
signal: controller.signal,
|
|
166
|
-
});
|
|
167
|
-
const raw = await r.text();
|
|
168
|
-
let data;
|
|
169
|
-
try {
|
|
170
|
-
data = JSON.parse(raw);
|
|
171
|
-
} catch {
|
|
172
|
-
data = raw;
|
|
173
|
-
}
|
|
174
|
-
return { ok: r.ok, status: r.status, data };
|
|
175
|
-
} catch (e) {
|
|
176
|
-
const aborted = e?.name === 'AbortError';
|
|
177
|
-
return {
|
|
178
|
-
ok: false,
|
|
179
|
-
status: 0,
|
|
180
|
-
data: null,
|
|
181
|
-
error: aborted ? `no answer within ${Math.round(timeoutMs / 1000)}s` : String(e?.message || e),
|
|
182
|
-
};
|
|
183
|
-
} finally {
|
|
184
|
-
clearTimeout(timer);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Turn a failed call into a sentence a working performer can act on.
|
|
190
|
-
*
|
|
191
|
-
* The 402 case is the one that matters most. kmhub-api runs the entitlement gate
|
|
192
|
-
* before every route, so a lapsed subscription answers 402 on everything,
|
|
193
|
-
* including GET /version. Left as a status code that reads like the software
|
|
194
|
-
* broke. It did not, and the wording says so: nothing changed, nothing is gone,
|
|
195
|
-
* here is the one thing to do.
|
|
196
|
-
*/
|
|
197
|
-
function explain(res, base) {
|
|
198
|
-
const body = res && res.data && typeof res.data === 'object' ? res.data : {};
|
|
199
|
-
const message = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : '';
|
|
200
|
-
|
|
201
|
-
if (res.status === 0) {
|
|
202
|
-
return [
|
|
203
|
-
`Could not reach KM Hub at ${base} (${res.error || 'no answer'}).`,
|
|
204
|
-
'That is a network or DNS problem on this machine, not your key. Check the connection and try again.',
|
|
205
|
-
].join('\n');
|
|
206
|
-
}
|
|
207
|
-
if (res.status === 401) {
|
|
208
|
-
return [
|
|
209
|
-
'KM Hub did not accept that key. It is either mistyped or it has been revoked.',
|
|
210
|
-
'Create a fresh one in KM Hub under Settings > Connect > Developer & API, then run this again with the new key.',
|
|
211
|
-
].join('\n');
|
|
212
|
-
}
|
|
213
|
-
if (res.status === 402) {
|
|
214
|
-
return [
|
|
215
|
-
'Your KM Hub subscription is not active, so the connector is switched off at the server.',
|
|
216
|
-
'Nothing in your workspace has changed, and none of your data has gone anywhere.',
|
|
217
|
-
message,
|
|
218
|
-
'Sign in at https://hub.kivimedia.co and restart the subscription under Settings > Billing. Once that is done this works again on the next call, with nothing to reinstall.',
|
|
219
|
-
]
|
|
220
|
-
.filter(Boolean)
|
|
221
|
-
.join('\n');
|
|
222
|
-
}
|
|
223
|
-
if (res.status === 403) {
|
|
224
|
-
return [
|
|
225
|
-
`This key does not carry the scope that call needs${message ? ` (${message})` : ''}.`,
|
|
226
|
-
'A read-only key can read your workspace but cannot create anything. Mint a key with write scope in Settings > Connect > Developer & API if you need the create tools.',
|
|
227
|
-
].join('\n');
|
|
228
|
-
}
|
|
229
|
-
if (res.status === 429) {
|
|
230
|
-
return `KM Hub rate limited this key${message ? `: ${message}` : '.'} Wait a minute and try again.`;
|
|
231
|
-
}
|
|
232
|
-
if (res.status >= 500) {
|
|
233
|
-
return `KM Hub answered ${res.status}${message ? `: ${message}` : '.'} That is a problem on the KM Hub side, not on this machine. Try again shortly.`;
|
|
234
|
-
}
|
|
235
|
-
return `KM Hub answered ${res.status}${message ? `: ${message}` : ''}.`;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// ---------------------------------------------------------------------------
|
|
239
|
-
// finding an existing registration
|
|
240
|
-
// ---------------------------------------------------------------------------
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* The files a Claude client stores MCP server registrations in. Read only, and
|
|
244
|
-
* every one of them is optional: a machine with none of these is simply a
|
|
245
|
-
* machine where nothing is registered yet, which is a finding, not an error.
|
|
246
|
-
*/
|
|
247
|
-
function candidateConfigs() {
|
|
248
|
-
const home = homedir();
|
|
249
|
-
const files = [
|
|
250
|
-
{ file: join(home, '.claude.json'), label: 'Claude Code (user)' },
|
|
251
|
-
{ file: join(process.cwd(), '.mcp.json'), label: 'Claude Code (this project)' },
|
|
252
|
-
];
|
|
253
|
-
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
254
|
-
files.push({ file: join(process.env.APPDATA, 'Claude', 'claude_desktop_config.json'), label: 'Claude Desktop' });
|
|
255
|
-
} else if (process.platform === 'darwin') {
|
|
256
|
-
files.push({
|
|
257
|
-
file: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
258
|
-
label: 'Claude Desktop',
|
|
259
|
-
});
|
|
260
|
-
} else {
|
|
261
|
-
files.push({ file: join(home, '.config', 'Claude', 'claude_desktop_config.json'), label: 'Claude Desktop' });
|
|
262
|
-
}
|
|
263
|
-
return files;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Every place a config file can hold a server entry under this name. The file it
|
|
268
|
-
* came from already says which client and which scope, so a top level entry adds
|
|
269
|
-
* nothing and says nothing. Only a per-project entry needs naming, because there
|
|
270
|
-
* can be many and only one of them is the directory the client is working in.
|
|
271
|
-
*/
|
|
272
|
-
function entriesInConfig(json, name) {
|
|
273
|
-
const found = [];
|
|
274
|
-
const top = json?.mcpServers?.[name];
|
|
275
|
-
if (top && typeof top === 'object') found.push({ entry: top, where: '' });
|
|
276
|
-
const projects = json?.projects;
|
|
277
|
-
if (projects && typeof projects === 'object') {
|
|
278
|
-
for (const [path, project] of Object.entries(projects)) {
|
|
279
|
-
const entry = project?.mcpServers?.[name];
|
|
280
|
-
if (entry && typeof entry === 'object') found.push({ entry, where: `project ${path}` });
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
return found;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function findRegistrations(name) {
|
|
287
|
-
const found = [];
|
|
288
|
-
for (const candidate of candidateConfigs()) {
|
|
289
|
-
if (!existsSync(candidate.file)) continue;
|
|
290
|
-
let json;
|
|
291
|
-
try {
|
|
292
|
-
json = JSON.parse(readFileSync(candidate.file, 'utf8'));
|
|
293
|
-
} catch {
|
|
294
|
-
found.push({ ...candidate, unreadable: true });
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
for (const hit of entriesInConfig(json, name)) found.push({ ...candidate, ...hit });
|
|
298
|
-
}
|
|
299
|
-
return found;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** A registration can carry the key as a stdio env var or as a remote Bearer header. */
|
|
303
|
-
function keyFromEntry(entry) {
|
|
304
|
-
const fromEnv = entry?.env?.KMHUB_API_KEY;
|
|
305
|
-
if (typeof fromEnv === 'string' && KEY_RE.test(fromEnv.trim())) return fromEnv.trim();
|
|
306
|
-
const headers = entry?.headers && typeof entry.headers === 'object' ? entry.headers : {};
|
|
307
|
-
for (const value of Object.values(headers)) {
|
|
308
|
-
if (typeof value !== 'string') continue;
|
|
309
|
-
const m = value.match(/Bearer\s+(kmh_live_[0-9a-f]+)/i);
|
|
310
|
-
if (m) return m[1];
|
|
311
|
-
}
|
|
312
|
-
return '';
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Where the key comes from, most explicit first: the argument, then the
|
|
317
|
-
* environment, then whatever the existing registration already holds. That last
|
|
318
|
-
* one is what makes `doctor` answer a support ticket without asking the client
|
|
319
|
-
* to go and find their key.
|
|
320
|
-
*/
|
|
321
|
-
function resolveKey(argKey, registrations) {
|
|
322
|
-
if (argKey) return { key: argKey, source: 'the key you passed' };
|
|
323
|
-
const fromEnv = typeof process.env.KMHUB_API_KEY === 'string' ? process.env.KMHUB_API_KEY.trim() : '';
|
|
324
|
-
if (fromEnv) return { key: fromEnv, source: 'KMHUB_API_KEY in this environment' };
|
|
325
|
-
for (const reg of registrations) {
|
|
326
|
-
const key = keyFromEntry(reg.entry);
|
|
327
|
-
if (key) return { key, source: `the registration in ${reg.label}` };
|
|
328
|
-
}
|
|
329
|
-
return { key: '', source: '' };
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
// running the claude CLI
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Windows needs a shell to launch the `claude` .cmd shim, and a shell means argv
|
|
338
|
-
* is re-parsed. Everything this file passes (a key, a connector name, a package
|
|
339
|
-
* name, npx flags) is validated before it gets here rather than escaped after,
|
|
340
|
-
* so quoting is only ever a safety net.
|
|
341
|
-
*/
|
|
342
|
-
function quoteArg(arg) {
|
|
343
|
-
if (/^[A-Za-z0-9_@:.,/=+-]+$/.test(arg)) return arg;
|
|
344
|
-
return `"${String(arg).replace(/"/g, '\\"')}"`;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* On Windows the whole thing goes in as ONE already-quoted string. Handing
|
|
349
|
-
* spawnSync an args array together with shell:true is deprecated from Node 22
|
|
350
|
-
* (DEP0190) precisely because the args are concatenated rather than escaped, and
|
|
351
|
-
* it prints a warning at the client that has nothing to do with their problem.
|
|
352
|
-
* Everywhere else there is no shell at all, which is the safer path anyway.
|
|
353
|
-
*/
|
|
354
|
-
function runCli(cmd, args, timeoutMs = 120000) {
|
|
355
|
-
const useShell = process.platform === 'win32';
|
|
356
|
-
const r = useShell
|
|
357
|
-
? spawnSync([cmd, ...args].map(quoteArg).join(' '), {
|
|
358
|
-
encoding: 'utf8',
|
|
359
|
-
shell: true,
|
|
360
|
-
timeout: timeoutMs,
|
|
361
|
-
})
|
|
362
|
-
: spawnSync(cmd, args, {
|
|
363
|
-
encoding: 'utf8',
|
|
364
|
-
timeout: timeoutMs,
|
|
365
|
-
});
|
|
366
|
-
const stdout = (r.stdout || '').trim();
|
|
367
|
-
const stderr = (r.stderr || '').trim();
|
|
368
|
-
// ENOENT is how a missing binary surfaces without a shell. With cmd.exe in the
|
|
369
|
-
// way it comes back as an exit code plus a message instead, so test for both.
|
|
370
|
-
const missing =
|
|
371
|
-
(r.error && (r.error.code === 'ENOENT' || /ENOENT/.test(String(r.error.message || '')))) ||
|
|
372
|
-
/is not recognized as an internal or external command|command not found|no such file or directory/i.test(
|
|
373
|
-
`${stderr}\n${stdout}`,
|
|
374
|
-
);
|
|
375
|
-
return {
|
|
376
|
-
ok: !r.error && r.status === 0,
|
|
377
|
-
status: r.status,
|
|
378
|
-
stdout,
|
|
379
|
-
stderr,
|
|
380
|
-
missing: Boolean(missing),
|
|
381
|
-
error: r.error ? String(r.error.message || r.error) : '',
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** The argv the connector is registered with. npx resolves the package at launch, never a local path. */
|
|
386
|
-
function connectorCommand() {
|
|
387
|
-
return ['npx', '-y', '--package', PKG_NAME, 'kmhub-mcp'];
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function desktopConfigBlock(name, key) {
|
|
391
|
-
return JSON.stringify(
|
|
392
|
-
{
|
|
393
|
-
mcpServers: {
|
|
394
|
-
[name]: {
|
|
395
|
-
command: 'npx',
|
|
396
|
-
args: ['-y', '--package', PKG_NAME, 'kmhub-mcp'],
|
|
397
|
-
env: { KMHUB_API_KEY: key },
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
},
|
|
401
|
-
null,
|
|
402
|
-
2,
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// ---------------------------------------------------------------------------
|
|
407
|
-
// version comparison
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Compare two semver-ish strings. Build metadata after a plus is dropped before
|
|
412
|
-
* comparing, because the rules pack stamps itself as base plus a content hash
|
|
413
|
-
* and the hash is not an ordering. Returns negative, zero or positive.
|
|
414
|
-
*/
|
|
415
|
-
function cmpVersion(a, b) {
|
|
416
|
-
const parts = (v) =>
|
|
417
|
-
String(v || '')
|
|
418
|
-
.trim()
|
|
419
|
-
.split('+')[0]
|
|
420
|
-
.split('-')[0]
|
|
421
|
-
.split('.')
|
|
422
|
-
.map((n) => Number.parseInt(n, 10) || 0);
|
|
423
|
-
const left = parts(a);
|
|
424
|
-
const right = parts(b);
|
|
425
|
-
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
426
|
-
const d = (left[i] || 0) - (right[i] || 0);
|
|
427
|
-
if (d !== 0) return d;
|
|
428
|
-
}
|
|
429
|
-
return 0;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// ---------------------------------------------------------------------------
|
|
433
|
-
// install
|
|
434
|
-
// ---------------------------------------------------------------------------
|
|
435
|
-
|
|
436
|
-
async function cmdInstall(key, name) {
|
|
437
|
-
if (!key) {
|
|
438
|
-
fail('kmhub install needs your KM Hub API key.');
|
|
439
|
-
fail('');
|
|
440
|
-
fail(` npx ${PKG_NAME} install kmh_live_xxxxxxxx`);
|
|
441
|
-
fail('');
|
|
442
|
-
// Say what was done with what they did type. Anything not shaped like a key
|
|
443
|
-
// is read as a connector name, and silently doing that to a mistyped key is
|
|
444
|
-
// how somebody spends ten minutes on a message that looks wrong.
|
|
445
|
-
if (name !== DEFAULT_CONNECTOR) {
|
|
446
|
-
fail(`I read "${name}" as the connector name, not as a key. A key starts with kmh_live_.`);
|
|
447
|
-
fail('');
|
|
448
|
-
}
|
|
449
|
-
fail('Create one in KM Hub under Settings > Connect > Developer & API.');
|
|
450
|
-
return 2;
|
|
451
|
-
}
|
|
452
|
-
if (!KEY_RE.test(key)) {
|
|
453
|
-
fail('That does not look like a KM Hub API key.');
|
|
454
|
-
fail('A key starts with kmh_live_ and is followed by hex. Copy it again from Settings > Connect > Developer & API.');
|
|
455
|
-
return 2;
|
|
456
|
-
}
|
|
457
|
-
if (!NAME_RE.test(name)) {
|
|
458
|
-
fail(`"${name}" is not a usable connector name. Use letters, numbers, dash or underscore.`);
|
|
459
|
-
return 2;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const server = await loadServer();
|
|
463
|
-
const base = apiBase(server);
|
|
464
|
-
|
|
465
|
-
// 1. The key, before anything is written anywhere. Registering a dead key just
|
|
466
|
-
// moves the failure to the first tool call, where it is much harder to read.
|
|
467
|
-
say('Checking the key with KM Hub...');
|
|
468
|
-
const me = await api(base, key, '/me');
|
|
469
|
-
if (!me.ok) {
|
|
470
|
-
fail('');
|
|
471
|
-
fail(explain(me, base));
|
|
472
|
-
fail('');
|
|
473
|
-
fail('Nothing was registered. Your Claude Code configuration is untouched.');
|
|
474
|
-
return 1;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const org = me.data?.org && typeof me.data.org === 'object' ? me.data.org : {};
|
|
478
|
-
const scopes = Array.isArray(me.data?.scopes) ? me.data.scopes : [];
|
|
479
|
-
const workspace = typeof org.name === 'string' && org.name.trim() ? org.name.trim() : 'your workspace';
|
|
480
|
-
const plan = typeof org.plan === 'string' && org.plan.trim() ? org.plan.trim() : '';
|
|
481
|
-
|
|
482
|
-
say(`Key accepted. Workspace: ${workspace}${plan ? ` (plan ${plan})` : ''}.`);
|
|
483
|
-
say(`Scopes on this key: ${scopes.length ? scopes.join(', ') : 'none reported'}.`);
|
|
484
|
-
if (scopes.length && !scopes.includes('write')) {
|
|
485
|
-
say('');
|
|
486
|
-
say('Note: this key is read only. The connector will read your workspace, and any tool that');
|
|
487
|
-
say('creates something will be refused by KM Hub. Mint a key with write scope if you want those.');
|
|
488
|
-
}
|
|
489
|
-
say('');
|
|
490
|
-
|
|
491
|
-
// 2. Claude Code has to exist before there is anywhere to register.
|
|
492
|
-
const claude = runCli('claude', ['--version'], 30000);
|
|
493
|
-
if (claude.missing) {
|
|
494
|
-
fail('Claude Code is not on this machine, or its `claude` command is not on your PATH.');
|
|
495
|
-
fail('Install it from https://claude.com/claude-code and run this again.');
|
|
496
|
-
fail('');
|
|
497
|
-
fail('If you are setting up Claude Desktop instead, put this in your claude_desktop_config.json:');
|
|
498
|
-
fail('');
|
|
499
|
-
fail(desktopConfigBlock(name, key));
|
|
500
|
-
fail('');
|
|
501
|
-
fail('Using Codex, Cursor or Grok Build instead? This tool only registers Claude Code.');
|
|
502
|
-
fail(`The KM Hub setup connects the app you pick: ${SETUP_URL}`);
|
|
503
|
-
return 1;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// 3. Register. The command is npx and the package name, never a path on disk,
|
|
507
|
-
// so restarting Claude Code is all it takes to pick up a newer build.
|
|
508
|
-
const args = ['mcp', 'add', name, '--scope', 'user', '-e', `KMHUB_API_KEY=${key}`, '--', ...connectorCommand()];
|
|
509
|
-
say(`Registering the connector as "${name}"...`);
|
|
510
|
-
const add = runCli('claude', args);
|
|
511
|
-
if (!add.ok) {
|
|
512
|
-
const output = `${add.stdout}\n${add.stderr}`.trim();
|
|
513
|
-
fail('');
|
|
514
|
-
fail(`Claude Code refused to add the connector${add.status ? ` (exit ${add.status})` : ''}.`);
|
|
515
|
-
if (output) fail(output);
|
|
516
|
-
if (/already exists|already configured/i.test(output)) {
|
|
517
|
-
fail('');
|
|
518
|
-
fail(`A connector called "${name}" is already registered. Remove it and run this again:`);
|
|
519
|
-
fail('');
|
|
520
|
-
fail(` claude mcp remove ${name} --scope user`);
|
|
521
|
-
fail(` npx ${PKG_NAME} install ${maskKey(key)} ${name}`);
|
|
522
|
-
fail('');
|
|
523
|
-
fail('(use the real key, not the masked one above)');
|
|
524
|
-
}
|
|
525
|
-
return 1;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
say('');
|
|
529
|
-
say(`Done. ${workspace} is connected to Claude Code on this machine.`);
|
|
530
|
-
say('');
|
|
531
|
-
say('Open Claude Code and type this:');
|
|
532
|
-
say('');
|
|
533
|
-
say(' Ask KM Hub what needs me today.');
|
|
534
|
-
say('');
|
|
535
|
-
say('Two things worth knowing:');
|
|
536
|
-
say(` 1. The key is stored in your Claude Code config. If it ever leaks, revoke it in KM Hub`);
|
|
537
|
-
say(' under Settings > Connect > Developer & API and run this install again with a fresh one.');
|
|
538
|
-
say(` 2. The connector is launched with npx, so restarting Claude Code picks up new builds.`);
|
|
539
|
-
say(` Run npx ${PKG_NAME} doctor if anything ever looks wrong.`);
|
|
540
|
-
return 0;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// ---------------------------------------------------------------------------
|
|
544
|
-
// update
|
|
545
|
-
// ---------------------------------------------------------------------------
|
|
546
|
-
|
|
547
|
-
async function cmdUpdate(argKey, name) {
|
|
548
|
-
const server = await loadServer();
|
|
549
|
-
const base = apiBase(server);
|
|
550
|
-
const pkg = readPkg();
|
|
551
|
-
const installed = server.ok && typeof server.mod?.SERVER_VERSION === 'string' ? server.mod.SERVER_VERSION : '';
|
|
552
|
-
|
|
553
|
-
say('KM Hub connector');
|
|
554
|
-
say('');
|
|
555
|
-
if (!server.ok) {
|
|
556
|
-
say(statusLine('fail', 'this build', 'the connector modules would not load'));
|
|
557
|
-
say(` ${server.error}`);
|
|
558
|
-
say(` Run npx ${PKG_NAME} doctor for the full picture.`);
|
|
559
|
-
} else {
|
|
560
|
-
say(statusLine('ok', 'this build', installed || 'unknown'));
|
|
561
|
-
if (pkg.version && installed && pkg.version !== installed) {
|
|
562
|
-
say(
|
|
563
|
-
statusLine(
|
|
564
|
-
'warn',
|
|
565
|
-
'version identity',
|
|
566
|
-
`package says ${pkg.version} and the server says ${installed}. Report that, it is a packaging bug.`,
|
|
567
|
-
),
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
const registrations = findRegistrations(name).filter((r) => r.entry);
|
|
573
|
-
const { key, source } = resolveKey(argKey, registrations);
|
|
574
|
-
if (!key) {
|
|
575
|
-
say('');
|
|
576
|
-
fail('No KM Hub API key to ask with.');
|
|
577
|
-
fail(`Pass one, or set KMHUB_API_KEY, or install the connector first: npx ${PKG_NAME} install kmh_live_...`);
|
|
578
|
-
return 2;
|
|
579
|
-
}
|
|
580
|
-
if (!KEY_RE.test(key)) {
|
|
581
|
-
say('');
|
|
582
|
-
fail(`The key from ${source} is not shaped like a KM Hub key (kmh_live_ then hex).`);
|
|
583
|
-
return 2;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
say('');
|
|
587
|
-
say(`Asking KM Hub what it publishes today (key from ${source})...`);
|
|
588
|
-
const v = await api(base, key, '/version');
|
|
589
|
-
if (!v.ok) {
|
|
590
|
-
say('');
|
|
591
|
-
fail(explain(v, base));
|
|
592
|
-
return 1;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const d = v.data && typeof v.data === 'object' ? v.data : {};
|
|
596
|
-
const minClient = typeof d.min_client === 'string' ? d.min_client : '';
|
|
597
|
-
const rulesVersion = typeof d.rules_version === 'string' ? d.rules_version : '';
|
|
598
|
-
|
|
599
|
-
say('');
|
|
600
|
-
say('KM Hub publishes:');
|
|
601
|
-
say(statusLine('ok', 'API', d.api_version || 'not reported'));
|
|
602
|
-
say(statusLine('ok', 'tool catalogue', d.tools_version || 'not reported'));
|
|
603
|
-
say(statusLine('ok', 'rules pack', rulesVersion || 'not reported'));
|
|
604
|
-
say(statusLine('ok', 'oldest client it serves', minClient || 'not reported'));
|
|
605
|
-
say('');
|
|
606
|
-
|
|
607
|
-
// The only hard judgement available. min_client is the floor KM Hub refuses to
|
|
608
|
-
// serve below; there is no "latest client" field, so anything at or above the
|
|
609
|
-
// floor is reported as still served rather than dressed up as up to date.
|
|
610
|
-
if (!installed) {
|
|
611
|
-
say('This build could not be identified, so whether it is out of date cannot be answered here.');
|
|
612
|
-
say(`Run npx ${PKG_NAME} doctor first: something about the install is wrong.`);
|
|
613
|
-
return 1;
|
|
614
|
-
}
|
|
615
|
-
if (minClient && cmpVersion(installed, minClient) < 0) {
|
|
616
|
-
say(`OUT OF DATE. This connector is ${installed} and KM Hub no longer serves anything below ${minClient}.`);
|
|
617
|
-
say('It will stop working. Update it now:');
|
|
618
|
-
say('');
|
|
619
|
-
say(' 1. Quit Claude Code completely and open it again. The connector is launched with npx,');
|
|
620
|
-
say(' so a restart fetches the current build on its own.');
|
|
621
|
-
say(' 2. If it is still on the old version after that, re-register it:');
|
|
622
|
-
say('');
|
|
623
|
-
say(` claude mcp remove ${name} --scope user`);
|
|
624
|
-
say(` npx ${PKG_NAME}@latest install <your kmh_live_ key> ${name}`);
|
|
625
|
-
if (d.changelog_url) say(`\n What changed: ${d.changelog_url}`);
|
|
626
|
-
return 1;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
say(`Up to date enough. KM Hub still serves ${installed}${minClient ? ` (its floor is ${minClient})` : ''}.`);
|
|
630
|
-
say('Nothing to run.');
|
|
631
|
-
say('');
|
|
632
|
-
say(`The rules pack KM Hub publishes today is ${rulesVersion || 'not reported'}. This tool does not touch`);
|
|
633
|
-
say('your rules file. To refresh it, open Claude Code and say: check for KM Hub updates.');
|
|
634
|
-
say('That runs the connector\'s own update tools, which rewrite the KM Hub block in your CLAUDE.md.');
|
|
635
|
-
if (d.changelog_url) say(`\nChangelog: ${d.changelog_url}`);
|
|
636
|
-
return 0;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// ---------------------------------------------------------------------------
|
|
640
|
-
// doctor
|
|
641
|
-
// ---------------------------------------------------------------------------
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* The command that answers a support ticket without a call.
|
|
645
|
-
*
|
|
646
|
-
* Every check reports one of three things and, when it is not ok, the single
|
|
647
|
-
* next action. It never stops at the first failure: a client who has three
|
|
648
|
-
* things wrong should learn all three in one paste, not across three emails.
|
|
649
|
-
* Exit code is 1 if anything failed, so it is also usable as a gate.
|
|
650
|
-
*/
|
|
651
|
-
async function cmdDoctor(argKey, name) {
|
|
652
|
-
const checks = [];
|
|
653
|
-
const record = (status, label, detail, fix) => {
|
|
654
|
-
checks.push({ status, label, detail, fix });
|
|
655
|
-
say(statusLine(status, label, detail));
|
|
656
|
-
if (fix && status !== 'ok') {
|
|
657
|
-
for (const line of String(fix).split('\n')) say(` ${line}`);
|
|
658
|
-
}
|
|
659
|
-
};
|
|
660
|
-
|
|
661
|
-
const pkg = readPkg();
|
|
662
|
-
say(`kmhub doctor (${PKG_NAME}${pkg.version ? ` ${pkg.version}` : ''}, connector name "${name}")`);
|
|
663
|
-
say('');
|
|
664
|
-
|
|
665
|
-
// 1. The runtime underneath everything else.
|
|
666
|
-
const major = Number.parseInt(String(process.versions.node).split('.')[0], 10) || 0;
|
|
667
|
-
if (major >= 18) {
|
|
668
|
-
record('ok', 'Node', `${process.version} on ${process.platform}`);
|
|
669
|
-
} else {
|
|
670
|
-
record(
|
|
671
|
-
'fail',
|
|
672
|
-
'Node',
|
|
673
|
-
`${process.version} is too old`,
|
|
674
|
-
'The connector needs Node 18.17 or newer. Install a current Node and run this again.',
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// 2. Do the connector's own files load. This is the check that catches a
|
|
679
|
-
// half-finished install, a missing dependency, or a broken family file.
|
|
680
|
-
const server = await loadServer();
|
|
681
|
-
const installed = server.ok && typeof server.mod?.SERVER_VERSION === 'string' ? server.mod.SERVER_VERSION : '';
|
|
682
|
-
if (server.ok) {
|
|
683
|
-
const families = Array.isArray(server.mod?.FAMILY_NAMES) ? server.mod.FAMILY_NAMES : [];
|
|
684
|
-
const tools = Array.isArray(server.mod?.TOOL_NAMES) ? server.mod.TOOL_NAMES : [];
|
|
685
|
-
record('ok', 'connector files', `version ${installed || 'unknown'}, ${families.length} families, ${tools.length} tools`);
|
|
686
|
-
} else {
|
|
687
|
-
record(
|
|
688
|
-
'fail',
|
|
689
|
-
'connector files',
|
|
690
|
-
'they would not load',
|
|
691
|
-
`${server.error}\nReinstall the connector: npx ${PKG_NAME}@latest install <your kmh_live_ key> ${name}`,
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// 3. One process, two version numbers. They have disagreed before, so it is a check.
|
|
696
|
-
if (server.ok && pkg.version && installed) {
|
|
697
|
-
if (pkg.version === installed) {
|
|
698
|
-
record('ok', 'version identity', `package and server both say ${installed}`);
|
|
699
|
-
} else {
|
|
700
|
-
record(
|
|
701
|
-
'warn',
|
|
702
|
-
'version identity',
|
|
703
|
-
`package says ${pkg.version}, server says ${installed}`,
|
|
704
|
-
'That is a packaging bug on our side, not something you can fix. Send this report to KM Hub.',
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// 4. Is there a Claude Code to be registered in.
|
|
710
|
-
const claude = runCli('claude', ['--version'], 30000);
|
|
711
|
-
if (claude.missing) {
|
|
712
|
-
record(
|
|
713
|
-
'fail',
|
|
714
|
-
'Claude Code',
|
|
715
|
-
'the `claude` command was not found',
|
|
716
|
-
'Install it from https://claude.com/claude-code, then run the install again.',
|
|
717
|
-
);
|
|
718
|
-
} else if (claude.ok) {
|
|
719
|
-
record('ok', 'Claude Code', claude.stdout.split('\n')[0] || 'installed');
|
|
720
|
-
} else {
|
|
721
|
-
record('warn', 'Claude Code', `\`claude --version\` exited ${claude.status ?? 'oddly'}`, claude.stderr || claude.error);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// 5. Is the connector actually registered, and where.
|
|
725
|
-
const registrations = findRegistrations(name);
|
|
726
|
-
const usable = registrations.filter((r) => r.entry);
|
|
727
|
-
if (usable.length) {
|
|
728
|
-
for (const reg of usable) {
|
|
729
|
-
const cmd = Array.isArray(reg.entry.args)
|
|
730
|
-
? `${reg.entry.command} ${reg.entry.args.join(' ')}`
|
|
731
|
-
: reg.entry.command || reg.entry.url || 'no command recorded';
|
|
732
|
-
record('ok', 'registration', `${reg.label}${reg.where ? `, ${reg.where}` : ''}: ${cmd}`);
|
|
733
|
-
}
|
|
734
|
-
} else {
|
|
735
|
-
const unreadable = registrations.filter((r) => r.unreadable);
|
|
736
|
-
record(
|
|
737
|
-
'fail',
|
|
738
|
-
'registration',
|
|
739
|
-
`no connector called "${name}" in any Claude config on this machine`,
|
|
740
|
-
`${unreadable.length ? `${unreadable.map((u) => u.file).join(', ')} could not be parsed as JSON.\n` : ''}Register it: npx ${PKG_NAME} install <your kmh_live_ key> ${name}`,
|
|
741
|
-
);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// 6. A key to test with.
|
|
745
|
-
const { key, source } = resolveKey(argKey, usable);
|
|
746
|
-
const base = apiBase(server);
|
|
747
|
-
if (!key) {
|
|
748
|
-
record(
|
|
749
|
-
'fail',
|
|
750
|
-
'API key',
|
|
751
|
-
'none found',
|
|
752
|
-
`Pass it: npx ${PKG_NAME} doctor <your kmh_live_ key>\nOr set KMHUB_API_KEY in this shell.`,
|
|
753
|
-
);
|
|
754
|
-
} else if (!KEY_RE.test(key)) {
|
|
755
|
-
record(
|
|
756
|
-
'fail',
|
|
757
|
-
'API key',
|
|
758
|
-
`the value from ${source} is not shaped like a KM Hub key`,
|
|
759
|
-
'A key starts with kmh_live_ and is followed by hex. Copy it again from Settings > Connect > Developer & API.',
|
|
760
|
-
);
|
|
761
|
-
} else {
|
|
762
|
-
record('ok', 'API key', `${maskKey(key)} from ${source}`);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// 7 and 8. Authentication and entitlement, in one call. kmhub-api checks the
|
|
766
|
-
// key first and the subscription second, so a 401 says nothing about
|
|
767
|
-
// billing and a 402 says the key is fine and the subscription is not.
|
|
768
|
-
if (key && KEY_RE.test(key)) {
|
|
769
|
-
const me = await api(base, key, '/me');
|
|
770
|
-
if (me.ok) {
|
|
771
|
-
const org = me.data?.org && typeof me.data.org === 'object' ? me.data.org : {};
|
|
772
|
-
const scopes = Array.isArray(me.data?.scopes) ? me.data.scopes : [];
|
|
773
|
-
const workspace = typeof org.name === 'string' && org.name.trim() ? org.name.trim() : 'unnamed workspace';
|
|
774
|
-
record('ok', 'key authenticates', `${workspace}${org.plan ? ` (plan ${org.plan})` : ''}`);
|
|
775
|
-
record('ok', 'subscription', 'active, KM Hub is serving this workspace');
|
|
776
|
-
if (scopes.length && !scopes.includes('write')) {
|
|
777
|
-
record(
|
|
778
|
-
'warn',
|
|
779
|
-
'scopes',
|
|
780
|
-
`${scopes.join(', ')} only`,
|
|
781
|
-
'Read tools work. Anything that creates something will be refused. Mint a key with write scope if you need those.',
|
|
782
|
-
);
|
|
783
|
-
} else {
|
|
784
|
-
record('ok', 'scopes', scopes.length ? scopes.join(', ') : 'none reported');
|
|
785
|
-
}
|
|
786
|
-
} else if (me.status === 402) {
|
|
787
|
-
record('ok', 'key authenticates', 'the key itself is valid, KM Hub recognised it');
|
|
788
|
-
record('fail', 'subscription', 'not active', explain(me, base));
|
|
789
|
-
} else {
|
|
790
|
-
record('fail', 'key authenticates', `KM Hub answered ${me.status || 'nothing'}`, explain(me, base));
|
|
791
|
-
record('warn', 'subscription', 'cannot be checked until the key works');
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// 9. Only meaningful once the key works.
|
|
795
|
-
if (me.ok) {
|
|
796
|
-
const v = await api(base, key, '/version');
|
|
797
|
-
if (!v.ok) {
|
|
798
|
-
record('warn', 'client version floor', 'KM Hub did not answer GET /version', explain(v, base));
|
|
799
|
-
} else {
|
|
800
|
-
const minClient = typeof v.data?.min_client === 'string' ? v.data.min_client : '';
|
|
801
|
-
if (!installed || !minClient) {
|
|
802
|
-
record('warn', 'client version floor', 'not enough information to judge');
|
|
803
|
-
} else if (cmpVersion(installed, minClient) < 0) {
|
|
804
|
-
record(
|
|
805
|
-
'fail',
|
|
806
|
-
'client version floor',
|
|
807
|
-
`this build is ${installed} and KM Hub serves nothing below ${minClient}`,
|
|
808
|
-
`Restart Claude Code to pull the current build, or re-register:\n claude mcp remove ${name} --scope user\n npx ${PKG_NAME}@latest install <your kmh_live_ key> ${name}`,
|
|
809
|
-
);
|
|
810
|
-
} else {
|
|
811
|
-
record('ok', 'client version floor', `${installed} is at or above ${minClient}`);
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
const failed = checks.filter((c) => c.status === 'fail');
|
|
818
|
-
const warned = checks.filter((c) => c.status === 'warn');
|
|
819
|
-
say('');
|
|
820
|
-
if (!failed.length && !warned.length) {
|
|
821
|
-
say('Everything checks out. If a tool still misbehaves, the problem is in the conversation,');
|
|
822
|
-
say('not the connection: say what you asked for and what came back.');
|
|
823
|
-
return 0;
|
|
824
|
-
}
|
|
825
|
-
say(`${failed.length} failed, ${warned.length} to be aware of.`);
|
|
826
|
-
if (failed.length) {
|
|
827
|
-
say('');
|
|
828
|
-
say(`Next: ${failed[0].label}.`);
|
|
829
|
-
if (failed[0].fix) for (const line of String(failed[0].fix).split('\n')) say(` ${line}`);
|
|
830
|
-
return 1;
|
|
831
|
-
}
|
|
832
|
-
return 0;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// ---------------------------------------------------------------------------
|
|
836
|
-
// argv
|
|
837
|
-
// ---------------------------------------------------------------------------
|
|
838
|
-
|
|
839
|
-
/**
|
|
840
|
-
* No flags to learn. A KM Hub key is unmistakable, so any argument that looks
|
|
841
|
-
* like one is the key and anything else is the connector name, whatever order
|
|
842
|
-
* they arrive in.
|
|
843
|
-
*/
|
|
844
|
-
function parseArgs(argv) {
|
|
845
|
-
const [command, ...rest] = argv;
|
|
846
|
-
let key = '';
|
|
847
|
-
let name = '';
|
|
848
|
-
for (const arg of rest) {
|
|
849
|
-
if (!arg) continue;
|
|
850
|
-
if (/^kmh_live_/i.test(arg)) {
|
|
851
|
-
if (!key) key = arg.trim();
|
|
852
|
-
} else if (!name) {
|
|
853
|
-
name = arg.trim();
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
return { command: (command || '').toLowerCase(), key, name: name || DEFAULT_CONNECTOR };
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
async function main() {
|
|
860
|
-
const { command, key, name } = parseArgs(process.argv.slice(2));
|
|
861
|
-
|
|
862
|
-
if (!command || command === 'help' || command === '-h' || command === '--help') {
|
|
863
|
-
say(HELP);
|
|
864
|
-
return command ? 0 : 1;
|
|
865
|
-
}
|
|
866
|
-
if (command === 'version' || command === '-v' || command === '--version') {
|
|
867
|
-
const pkg = readPkg();
|
|
868
|
-
const server = await loadServer();
|
|
869
|
-
say(pkg.version || 'unknown');
|
|
870
|
-
if (server.ok && server.mod?.SERVER_VERSION && server.mod.SERVER_VERSION !== pkg.version) {
|
|
871
|
-
fail(`warning: the connector inside this package reports ${server.mod.SERVER_VERSION}. Those should match.`);
|
|
872
|
-
return 1;
|
|
873
|
-
}
|
|
874
|
-
return 0;
|
|
875
|
-
}
|
|
876
|
-
if (command === 'install') return cmdInstall(key, name);
|
|
877
|
-
if (command === 'update') return cmdUpdate(key, name);
|
|
878
|
-
if (command === 'doctor') return cmdDoctor(key, name);
|
|
879
|
-
|
|
880
|
-
fail(`kmhub: "${command}" is not a command.`);
|
|
881
|
-
fail('');
|
|
882
|
-
fail(HELP);
|
|
883
|
-
return 2;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
main()
|
|
887
|
-
.then((code) => {
|
|
888
|
-
process.exitCode = code || 0;
|
|
889
|
-
})
|
|
890
|
-
.catch((e) => {
|
|
891
|
-
// Nothing above is supposed to throw. If something does, say so plainly and
|
|
892
|
-
// point at the one command that is built to survive a broken install.
|
|
893
|
-
fail(`kmhub: unexpected failure: ${String(e?.stack || e?.message || e)}`);
|
|
894
|
-
fail(`If this was install or update, try: npx ${PKG_NAME} doctor`);
|
|
895
|
-
process.exitCode = 1;
|
|
896
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kmhub - the Terminal Mode command line tool.
|
|
4
|
+
*
|
|
5
|
+
* Three jobs, and only three:
|
|
6
|
+
*
|
|
7
|
+
* kmhub install <key> [name] register the connector in this machine's Claude Code
|
|
8
|
+
* kmhub update [key] [name] is this build still one KM Hub serves
|
|
9
|
+
* kmhub doctor [key] [name] why is it not working
|
|
10
|
+
*
|
|
11
|
+
* The whole point of this file is that a client never types an absolute path
|
|
12
|
+
* again. The old instruction was `claude mcp add kmhub -e KEY=... -- node
|
|
13
|
+
* /absolute/path/to/kmhub/mcp-server/index.mjs`, which only works if they cloned
|
|
14
|
+
* a private repo first and then got the path right. Published on npm, the same
|
|
15
|
+
* job is `npx @kivimedia/kmhub install kmh_live_...` and the connector is
|
|
16
|
+
* launched by npx from then on, so it updates itself when Claude Code restarts.
|
|
17
|
+
*
|
|
18
|
+
* Where the work actually happens is worth stating plainly, because it decides
|
|
19
|
+
* what this tool is allowed to do. Terminal Mode runs on the CLIENT machine,
|
|
20
|
+
* inside THEIR Claude Code, on THEIR Claude subscription. Nothing here schedules
|
|
21
|
+
* anything, runs an agent, or phones home. It registers a connector, reads two
|
|
22
|
+
* public-to-the-key endpoints (GET /me and GET /version), and reports.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately dependency free. It imports node builtins and, only where it has
|
|
25
|
+
* something to say about them, the connector's own modules. `doctor` has to be
|
|
26
|
+
* the command that works when everything else is broken, so it cannot rely on
|
|
27
|
+
* the thing it is diagnosing being importable.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
|
|
35
|
+
// The published package name. Every registration points at this, never at a path
|
|
36
|
+
// on the client's disk, which is the entire reason this file exists.
|
|
37
|
+
const PKG_NAME = '@kivimedia/kmhub';
|
|
38
|
+
|
|
39
|
+
/** The MCP server name a connector is registered under unless the user picks another. */
|
|
40
|
+
const DEFAULT_CONNECTOR = 'kmhub';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mirrors DEFAULT_BASE in tools.mjs. Duplicated on purpose: `doctor` has to be
|
|
44
|
+
* able to reach KM Hub even when tools.mjs is the thing that will not load, and
|
|
45
|
+
* "I cannot tell you anything because my own import failed" is a useless answer
|
|
46
|
+
* to a support ticket. When tools.mjs does load, its value wins.
|
|
47
|
+
*/
|
|
48
|
+
const FALLBACK_BASE = 'https://jpwbosrmkibsdgckowsm.supabase.co/functions/v1/kmhub-api';
|
|
49
|
+
|
|
50
|
+
/** Keys are minted as kmh_live_ plus hex. The API matches the same shape before it looks anything up. */
|
|
51
|
+
const KEY_RE = /^kmh_live_[0-9a-f]+$/i;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where people who use Codex, Cursor or Grok Build are sent. This tool only knows how to
|
|
55
|
+
* register into Claude Code; the installers behind this page wire all four apps.
|
|
56
|
+
*/
|
|
57
|
+
const SETUP_URL = 'https://hub.kivimedia.co/terminal/';
|
|
58
|
+
|
|
59
|
+
/** A connector name has to survive being an argv element and a JSON key. */
|
|
60
|
+
const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
61
|
+
|
|
62
|
+
const HELP = `kmhub, the KM Hub Terminal Mode command line tool.
|
|
63
|
+
|
|
64
|
+
npx ${PKG_NAME} install <kmh_live_key> [connector-name]
|
|
65
|
+
Register the KM Hub connector in Claude Code on this machine.
|
|
66
|
+
Verifies the key against KM Hub first, so a bad key never gets registered.
|
|
67
|
+
|
|
68
|
+
npx ${PKG_NAME} update [kmh_live_key] [connector-name]
|
|
69
|
+
Ask KM Hub what it publishes today and say whether this build is still one
|
|
70
|
+
it serves.
|
|
71
|
+
|
|
72
|
+
npx ${PKG_NAME} doctor [kmh_live_key] [connector-name]
|
|
73
|
+
Diagnose a broken install: Claude Code, the registration, the key, the
|
|
74
|
+
subscription, and whether the connector files load at all.
|
|
75
|
+
|
|
76
|
+
The key is optional for update and doctor. They look, in order, at the argument
|
|
77
|
+
you passed, then KMHUB_API_KEY in the environment, then the key stored in the
|
|
78
|
+
registration itself.
|
|
79
|
+
|
|
80
|
+
Create a key in KM Hub under Settings > Connect > Developer & API.
|
|
81
|
+
Set KMHUB_API_BASE to point at a different KM Hub.
|
|
82
|
+
|
|
83
|
+
This tool registers Claude Code only. For Codex, Cursor or Grok Build, use the
|
|
84
|
+
KM Hub setup instead, which connects the app you pick and needs no key:
|
|
85
|
+
${SETUP_URL}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// small output helpers
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function say(line = '') {
|
|
93
|
+
process.stdout.write(`${line}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fail(line = '') {
|
|
97
|
+
process.stderr.write(`${line}\n`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fixed width labels so a doctor report reads as a column, not a paragraph. */
|
|
101
|
+
function statusLine(status, label, detail) {
|
|
102
|
+
const tag = { ok: 'ok ', warn: 'warn', fail: 'FAIL' }[status] || ' ';
|
|
103
|
+
return ` ${tag} ${label}${detail ? `: ${detail}` : ''}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Never print a whole key. Enough to recognise it, not enough to use it. */
|
|
107
|
+
function maskKey(key) {
|
|
108
|
+
if (typeof key !== 'string' || key.length < 16) return '(unreadable)';
|
|
109
|
+
return `${key.slice(0, 13)}...${key.slice(-4)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// the connector's own modules
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/** This package's own package.json, read from disk rather than imported. */
|
|
117
|
+
function readPkg() {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Load tools.mjs, which is where the connector's real identity lives
|
|
127
|
+
* (SERVER_VERSION, the families that loaded, the API base).
|
|
128
|
+
*
|
|
129
|
+
* This is a genuine diagnostic and not just a version read. tools.mjs scans
|
|
130
|
+
* tools/ at import time, so if a family file is missing or a dependency did not
|
|
131
|
+
* install, the import is where that shows up. Failure is captured, never thrown:
|
|
132
|
+
* `doctor` reporting "your install is broken, here is the error" is the answer
|
|
133
|
+
* the support ticket needed.
|
|
134
|
+
*/
|
|
135
|
+
async function loadServer() {
|
|
136
|
+
try {
|
|
137
|
+
const mod = await import(new URL('../tools.mjs', import.meta.url));
|
|
138
|
+
return { ok: true, mod };
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return { ok: false, error: String(e?.message || e) };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function apiBase(server) {
|
|
145
|
+
const fromEnv = typeof process.env.KMHUB_API_BASE === 'string' ? process.env.KMHUB_API_BASE.trim() : '';
|
|
146
|
+
if (fromEnv) return fromEnv;
|
|
147
|
+
const fromServer = server?.ok ? server.mod?.DEFAULT_BASE : '';
|
|
148
|
+
return typeof fromServer === 'string' && fromServer ? fromServer : FALLBACK_BASE;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// KM Hub
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One GET against kmhub-api with the org key. Never throws: a dead network is a
|
|
157
|
+
* result to report, not a stack trace to print at somebody who wanted an answer.
|
|
158
|
+
*/
|
|
159
|
+
async function api(base, key, path, timeoutMs = 15000) {
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
162
|
+
try {
|
|
163
|
+
const r = await fetch(`${base}${path}`, {
|
|
164
|
+
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
165
|
+
signal: controller.signal,
|
|
166
|
+
});
|
|
167
|
+
const raw = await r.text();
|
|
168
|
+
let data;
|
|
169
|
+
try {
|
|
170
|
+
data = JSON.parse(raw);
|
|
171
|
+
} catch {
|
|
172
|
+
data = raw;
|
|
173
|
+
}
|
|
174
|
+
return { ok: r.ok, status: r.status, data };
|
|
175
|
+
} catch (e) {
|
|
176
|
+
const aborted = e?.name === 'AbortError';
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
status: 0,
|
|
180
|
+
data: null,
|
|
181
|
+
error: aborted ? `no answer within ${Math.round(timeoutMs / 1000)}s` : String(e?.message || e),
|
|
182
|
+
};
|
|
183
|
+
} finally {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Turn a failed call into a sentence a working performer can act on.
|
|
190
|
+
*
|
|
191
|
+
* The 402 case is the one that matters most. kmhub-api runs the entitlement gate
|
|
192
|
+
* before every route, so a lapsed subscription answers 402 on everything,
|
|
193
|
+
* including GET /version. Left as a status code that reads like the software
|
|
194
|
+
* broke. It did not, and the wording says so: nothing changed, nothing is gone,
|
|
195
|
+
* here is the one thing to do.
|
|
196
|
+
*/
|
|
197
|
+
function explain(res, base) {
|
|
198
|
+
const body = res && res.data && typeof res.data === 'object' ? res.data : {};
|
|
199
|
+
const message = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : '';
|
|
200
|
+
|
|
201
|
+
if (res.status === 0) {
|
|
202
|
+
return [
|
|
203
|
+
`Could not reach KM Hub at ${base} (${res.error || 'no answer'}).`,
|
|
204
|
+
'That is a network or DNS problem on this machine, not your key. Check the connection and try again.',
|
|
205
|
+
].join('\n');
|
|
206
|
+
}
|
|
207
|
+
if (res.status === 401) {
|
|
208
|
+
return [
|
|
209
|
+
'KM Hub did not accept that key. It is either mistyped or it has been revoked.',
|
|
210
|
+
'Create a fresh one in KM Hub under Settings > Connect > Developer & API, then run this again with the new key.',
|
|
211
|
+
].join('\n');
|
|
212
|
+
}
|
|
213
|
+
if (res.status === 402) {
|
|
214
|
+
return [
|
|
215
|
+
'Your KM Hub subscription is not active, so the connector is switched off at the server.',
|
|
216
|
+
'Nothing in your workspace has changed, and none of your data has gone anywhere.',
|
|
217
|
+
message,
|
|
218
|
+
'Sign in at https://hub.kivimedia.co and restart the subscription under Settings > Billing. Once that is done this works again on the next call, with nothing to reinstall.',
|
|
219
|
+
]
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.join('\n');
|
|
222
|
+
}
|
|
223
|
+
if (res.status === 403) {
|
|
224
|
+
return [
|
|
225
|
+
`This key does not carry the scope that call needs${message ? ` (${message})` : ''}.`,
|
|
226
|
+
'A read-only key can read your workspace but cannot create anything. Mint a key with write scope in Settings > Connect > Developer & API if you need the create tools.',
|
|
227
|
+
].join('\n');
|
|
228
|
+
}
|
|
229
|
+
if (res.status === 429) {
|
|
230
|
+
return `KM Hub rate limited this key${message ? `: ${message}` : '.'} Wait a minute and try again.`;
|
|
231
|
+
}
|
|
232
|
+
if (res.status >= 500) {
|
|
233
|
+
return `KM Hub answered ${res.status}${message ? `: ${message}` : '.'} That is a problem on the KM Hub side, not on this machine. Try again shortly.`;
|
|
234
|
+
}
|
|
235
|
+
return `KM Hub answered ${res.status}${message ? `: ${message}` : ''}.`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// finding an existing registration
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The files a Claude client stores MCP server registrations in. Read only, and
|
|
244
|
+
* every one of them is optional: a machine with none of these is simply a
|
|
245
|
+
* machine where nothing is registered yet, which is a finding, not an error.
|
|
246
|
+
*/
|
|
247
|
+
function candidateConfigs() {
|
|
248
|
+
const home = homedir();
|
|
249
|
+
const files = [
|
|
250
|
+
{ file: join(home, '.claude.json'), label: 'Claude Code (user)' },
|
|
251
|
+
{ file: join(process.cwd(), '.mcp.json'), label: 'Claude Code (this project)' },
|
|
252
|
+
];
|
|
253
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
254
|
+
files.push({ file: join(process.env.APPDATA, 'Claude', 'claude_desktop_config.json'), label: 'Claude Desktop' });
|
|
255
|
+
} else if (process.platform === 'darwin') {
|
|
256
|
+
files.push({
|
|
257
|
+
file: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
258
|
+
label: 'Claude Desktop',
|
|
259
|
+
});
|
|
260
|
+
} else {
|
|
261
|
+
files.push({ file: join(home, '.config', 'Claude', 'claude_desktop_config.json'), label: 'Claude Desktop' });
|
|
262
|
+
}
|
|
263
|
+
return files;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Every place a config file can hold a server entry under this name. The file it
|
|
268
|
+
* came from already says which client and which scope, so a top level entry adds
|
|
269
|
+
* nothing and says nothing. Only a per-project entry needs naming, because there
|
|
270
|
+
* can be many and only one of them is the directory the client is working in.
|
|
271
|
+
*/
|
|
272
|
+
function entriesInConfig(json, name) {
|
|
273
|
+
const found = [];
|
|
274
|
+
const top = json?.mcpServers?.[name];
|
|
275
|
+
if (top && typeof top === 'object') found.push({ entry: top, where: '' });
|
|
276
|
+
const projects = json?.projects;
|
|
277
|
+
if (projects && typeof projects === 'object') {
|
|
278
|
+
for (const [path, project] of Object.entries(projects)) {
|
|
279
|
+
const entry = project?.mcpServers?.[name];
|
|
280
|
+
if (entry && typeof entry === 'object') found.push({ entry, where: `project ${path}` });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return found;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function findRegistrations(name) {
|
|
287
|
+
const found = [];
|
|
288
|
+
for (const candidate of candidateConfigs()) {
|
|
289
|
+
if (!existsSync(candidate.file)) continue;
|
|
290
|
+
let json;
|
|
291
|
+
try {
|
|
292
|
+
json = JSON.parse(readFileSync(candidate.file, 'utf8'));
|
|
293
|
+
} catch {
|
|
294
|
+
found.push({ ...candidate, unreadable: true });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
for (const hit of entriesInConfig(json, name)) found.push({ ...candidate, ...hit });
|
|
298
|
+
}
|
|
299
|
+
return found;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A registration can carry the key as a stdio env var or as a remote Bearer header. */
|
|
303
|
+
function keyFromEntry(entry) {
|
|
304
|
+
const fromEnv = entry?.env?.KMHUB_API_KEY;
|
|
305
|
+
if (typeof fromEnv === 'string' && KEY_RE.test(fromEnv.trim())) return fromEnv.trim();
|
|
306
|
+
const headers = entry?.headers && typeof entry.headers === 'object' ? entry.headers : {};
|
|
307
|
+
for (const value of Object.values(headers)) {
|
|
308
|
+
if (typeof value !== 'string') continue;
|
|
309
|
+
const m = value.match(/Bearer\s+(kmh_live_[0-9a-f]+)/i);
|
|
310
|
+
if (m) return m[1];
|
|
311
|
+
}
|
|
312
|
+
return '';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Where the key comes from, most explicit first: the argument, then the
|
|
317
|
+
* environment, then whatever the existing registration already holds. That last
|
|
318
|
+
* one is what makes `doctor` answer a support ticket without asking the client
|
|
319
|
+
* to go and find their key.
|
|
320
|
+
*/
|
|
321
|
+
function resolveKey(argKey, registrations) {
|
|
322
|
+
if (argKey) return { key: argKey, source: 'the key you passed' };
|
|
323
|
+
const fromEnv = typeof process.env.KMHUB_API_KEY === 'string' ? process.env.KMHUB_API_KEY.trim() : '';
|
|
324
|
+
if (fromEnv) return { key: fromEnv, source: 'KMHUB_API_KEY in this environment' };
|
|
325
|
+
for (const reg of registrations) {
|
|
326
|
+
const key = keyFromEntry(reg.entry);
|
|
327
|
+
if (key) return { key, source: `the registration in ${reg.label}` };
|
|
328
|
+
}
|
|
329
|
+
return { key: '', source: '' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// running the claude CLI
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Windows needs a shell to launch the `claude` .cmd shim, and a shell means argv
|
|
338
|
+
* is re-parsed. Everything this file passes (a key, a connector name, a package
|
|
339
|
+
* name, npx flags) is validated before it gets here rather than escaped after,
|
|
340
|
+
* so quoting is only ever a safety net.
|
|
341
|
+
*/
|
|
342
|
+
function quoteArg(arg) {
|
|
343
|
+
if (/^[A-Za-z0-9_@:.,/=+-]+$/.test(arg)) return arg;
|
|
344
|
+
return `"${String(arg).replace(/"/g, '\\"')}"`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* On Windows the whole thing goes in as ONE already-quoted string. Handing
|
|
349
|
+
* spawnSync an args array together with shell:true is deprecated from Node 22
|
|
350
|
+
* (DEP0190) precisely because the args are concatenated rather than escaped, and
|
|
351
|
+
* it prints a warning at the client that has nothing to do with their problem.
|
|
352
|
+
* Everywhere else there is no shell at all, which is the safer path anyway.
|
|
353
|
+
*/
|
|
354
|
+
function runCli(cmd, args, timeoutMs = 120000) {
|
|
355
|
+
const useShell = process.platform === 'win32';
|
|
356
|
+
const r = useShell
|
|
357
|
+
? spawnSync([cmd, ...args].map(quoteArg).join(' '), {
|
|
358
|
+
encoding: 'utf8',
|
|
359
|
+
shell: true,
|
|
360
|
+
timeout: timeoutMs,
|
|
361
|
+
})
|
|
362
|
+
: spawnSync(cmd, args, {
|
|
363
|
+
encoding: 'utf8',
|
|
364
|
+
timeout: timeoutMs,
|
|
365
|
+
});
|
|
366
|
+
const stdout = (r.stdout || '').trim();
|
|
367
|
+
const stderr = (r.stderr || '').trim();
|
|
368
|
+
// ENOENT is how a missing binary surfaces without a shell. With cmd.exe in the
|
|
369
|
+
// way it comes back as an exit code plus a message instead, so test for both.
|
|
370
|
+
const missing =
|
|
371
|
+
(r.error && (r.error.code === 'ENOENT' || /ENOENT/.test(String(r.error.message || '')))) ||
|
|
372
|
+
/is not recognized as an internal or external command|command not found|no such file or directory/i.test(
|
|
373
|
+
`${stderr}\n${stdout}`,
|
|
374
|
+
);
|
|
375
|
+
return {
|
|
376
|
+
ok: !r.error && r.status === 0,
|
|
377
|
+
status: r.status,
|
|
378
|
+
stdout,
|
|
379
|
+
stderr,
|
|
380
|
+
missing: Boolean(missing),
|
|
381
|
+
error: r.error ? String(r.error.message || r.error) : '',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The argv the connector is registered with. npx resolves the package at launch, never a local path. */
|
|
386
|
+
function connectorCommand() {
|
|
387
|
+
return ['npx', '-y', '--package', PKG_NAME, 'kmhub-mcp'];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function desktopConfigBlock(name, key) {
|
|
391
|
+
return JSON.stringify(
|
|
392
|
+
{
|
|
393
|
+
mcpServers: {
|
|
394
|
+
[name]: {
|
|
395
|
+
command: 'npx',
|
|
396
|
+
args: ['-y', '--package', PKG_NAME, 'kmhub-mcp'],
|
|
397
|
+
env: { KMHUB_API_KEY: key },
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
null,
|
|
402
|
+
2,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// version comparison
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Compare two semver-ish strings. Build metadata after a plus is dropped before
|
|
412
|
+
* comparing, because the rules pack stamps itself as base plus a content hash
|
|
413
|
+
* and the hash is not an ordering. Returns negative, zero or positive.
|
|
414
|
+
*/
|
|
415
|
+
function cmpVersion(a, b) {
|
|
416
|
+
const parts = (v) =>
|
|
417
|
+
String(v || '')
|
|
418
|
+
.trim()
|
|
419
|
+
.split('+')[0]
|
|
420
|
+
.split('-')[0]
|
|
421
|
+
.split('.')
|
|
422
|
+
.map((n) => Number.parseInt(n, 10) || 0);
|
|
423
|
+
const left = parts(a);
|
|
424
|
+
const right = parts(b);
|
|
425
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
426
|
+
const d = (left[i] || 0) - (right[i] || 0);
|
|
427
|
+
if (d !== 0) return d;
|
|
428
|
+
}
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// install
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
async function cmdInstall(key, name) {
|
|
437
|
+
if (!key) {
|
|
438
|
+
fail('kmhub install needs your KM Hub API key.');
|
|
439
|
+
fail('');
|
|
440
|
+
fail(` npx ${PKG_NAME} install kmh_live_xxxxxxxx`);
|
|
441
|
+
fail('');
|
|
442
|
+
// Say what was done with what they did type. Anything not shaped like a key
|
|
443
|
+
// is read as a connector name, and silently doing that to a mistyped key is
|
|
444
|
+
// how somebody spends ten minutes on a message that looks wrong.
|
|
445
|
+
if (name !== DEFAULT_CONNECTOR) {
|
|
446
|
+
fail(`I read "${name}" as the connector name, not as a key. A key starts with kmh_live_.`);
|
|
447
|
+
fail('');
|
|
448
|
+
}
|
|
449
|
+
fail('Create one in KM Hub under Settings > Connect > Developer & API.');
|
|
450
|
+
return 2;
|
|
451
|
+
}
|
|
452
|
+
if (!KEY_RE.test(key)) {
|
|
453
|
+
fail('That does not look like a KM Hub API key.');
|
|
454
|
+
fail('A key starts with kmh_live_ and is followed by hex. Copy it again from Settings > Connect > Developer & API.');
|
|
455
|
+
return 2;
|
|
456
|
+
}
|
|
457
|
+
if (!NAME_RE.test(name)) {
|
|
458
|
+
fail(`"${name}" is not a usable connector name. Use letters, numbers, dash or underscore.`);
|
|
459
|
+
return 2;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const server = await loadServer();
|
|
463
|
+
const base = apiBase(server);
|
|
464
|
+
|
|
465
|
+
// 1. The key, before anything is written anywhere. Registering a dead key just
|
|
466
|
+
// moves the failure to the first tool call, where it is much harder to read.
|
|
467
|
+
say('Checking the key with KM Hub...');
|
|
468
|
+
const me = await api(base, key, '/me');
|
|
469
|
+
if (!me.ok) {
|
|
470
|
+
fail('');
|
|
471
|
+
fail(explain(me, base));
|
|
472
|
+
fail('');
|
|
473
|
+
fail('Nothing was registered. Your Claude Code configuration is untouched.');
|
|
474
|
+
return 1;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const org = me.data?.org && typeof me.data.org === 'object' ? me.data.org : {};
|
|
478
|
+
const scopes = Array.isArray(me.data?.scopes) ? me.data.scopes : [];
|
|
479
|
+
const workspace = typeof org.name === 'string' && org.name.trim() ? org.name.trim() : 'your workspace';
|
|
480
|
+
const plan = typeof org.plan === 'string' && org.plan.trim() ? org.plan.trim() : '';
|
|
481
|
+
|
|
482
|
+
say(`Key accepted. Workspace: ${workspace}${plan ? ` (plan ${plan})` : ''}.`);
|
|
483
|
+
say(`Scopes on this key: ${scopes.length ? scopes.join(', ') : 'none reported'}.`);
|
|
484
|
+
if (scopes.length && !scopes.includes('write')) {
|
|
485
|
+
say('');
|
|
486
|
+
say('Note: this key is read only. The connector will read your workspace, and any tool that');
|
|
487
|
+
say('creates something will be refused by KM Hub. Mint a key with write scope if you want those.');
|
|
488
|
+
}
|
|
489
|
+
say('');
|
|
490
|
+
|
|
491
|
+
// 2. Claude Code has to exist before there is anywhere to register.
|
|
492
|
+
const claude = runCli('claude', ['--version'], 30000);
|
|
493
|
+
if (claude.missing) {
|
|
494
|
+
fail('Claude Code is not on this machine, or its `claude` command is not on your PATH.');
|
|
495
|
+
fail('Install it from https://claude.com/claude-code and run this again.');
|
|
496
|
+
fail('');
|
|
497
|
+
fail('If you are setting up Claude Desktop instead, put this in your claude_desktop_config.json:');
|
|
498
|
+
fail('');
|
|
499
|
+
fail(desktopConfigBlock(name, key));
|
|
500
|
+
fail('');
|
|
501
|
+
fail('Using Codex, Cursor or Grok Build instead? This tool only registers Claude Code.');
|
|
502
|
+
fail(`The KM Hub setup connects the app you pick: ${SETUP_URL}`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 3. Register. The command is npx and the package name, never a path on disk,
|
|
507
|
+
// so restarting Claude Code is all it takes to pick up a newer build.
|
|
508
|
+
const args = ['mcp', 'add', name, '--scope', 'user', '-e', `KMHUB_API_KEY=${key}`, '--', ...connectorCommand()];
|
|
509
|
+
say(`Registering the connector as "${name}"...`);
|
|
510
|
+
const add = runCli('claude', args);
|
|
511
|
+
if (!add.ok) {
|
|
512
|
+
const output = `${add.stdout}\n${add.stderr}`.trim();
|
|
513
|
+
fail('');
|
|
514
|
+
fail(`Claude Code refused to add the connector${add.status ? ` (exit ${add.status})` : ''}.`);
|
|
515
|
+
if (output) fail(output);
|
|
516
|
+
if (/already exists|already configured/i.test(output)) {
|
|
517
|
+
fail('');
|
|
518
|
+
fail(`A connector called "${name}" is already registered. Remove it and run this again:`);
|
|
519
|
+
fail('');
|
|
520
|
+
fail(` claude mcp remove ${name} --scope user`);
|
|
521
|
+
fail(` npx ${PKG_NAME} install ${maskKey(key)} ${name}`);
|
|
522
|
+
fail('');
|
|
523
|
+
fail('(use the real key, not the masked one above)');
|
|
524
|
+
}
|
|
525
|
+
return 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
say('');
|
|
529
|
+
say(`Done. ${workspace} is connected to Claude Code on this machine.`);
|
|
530
|
+
say('');
|
|
531
|
+
say('Open Claude Code and type this:');
|
|
532
|
+
say('');
|
|
533
|
+
say(' Ask KM Hub what needs me today.');
|
|
534
|
+
say('');
|
|
535
|
+
say('Two things worth knowing:');
|
|
536
|
+
say(` 1. The key is stored in your Claude Code config. If it ever leaks, revoke it in KM Hub`);
|
|
537
|
+
say(' under Settings > Connect > Developer & API and run this install again with a fresh one.');
|
|
538
|
+
say(` 2. The connector is launched with npx, so restarting Claude Code picks up new builds.`);
|
|
539
|
+
say(` Run npx ${PKG_NAME} doctor if anything ever looks wrong.`);
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// update
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
async function cmdUpdate(argKey, name) {
|
|
548
|
+
const server = await loadServer();
|
|
549
|
+
const base = apiBase(server);
|
|
550
|
+
const pkg = readPkg();
|
|
551
|
+
const installed = server.ok && typeof server.mod?.SERVER_VERSION === 'string' ? server.mod.SERVER_VERSION : '';
|
|
552
|
+
|
|
553
|
+
say('KM Hub connector');
|
|
554
|
+
say('');
|
|
555
|
+
if (!server.ok) {
|
|
556
|
+
say(statusLine('fail', 'this build', 'the connector modules would not load'));
|
|
557
|
+
say(` ${server.error}`);
|
|
558
|
+
say(` Run npx ${PKG_NAME} doctor for the full picture.`);
|
|
559
|
+
} else {
|
|
560
|
+
say(statusLine('ok', 'this build', installed || 'unknown'));
|
|
561
|
+
if (pkg.version && installed && pkg.version !== installed) {
|
|
562
|
+
say(
|
|
563
|
+
statusLine(
|
|
564
|
+
'warn',
|
|
565
|
+
'version identity',
|
|
566
|
+
`package says ${pkg.version} and the server says ${installed}. Report that, it is a packaging bug.`,
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const registrations = findRegistrations(name).filter((r) => r.entry);
|
|
573
|
+
const { key, source } = resolveKey(argKey, registrations);
|
|
574
|
+
if (!key) {
|
|
575
|
+
say('');
|
|
576
|
+
fail('No KM Hub API key to ask with.');
|
|
577
|
+
fail(`Pass one, or set KMHUB_API_KEY, or install the connector first: npx ${PKG_NAME} install kmh_live_...`);
|
|
578
|
+
return 2;
|
|
579
|
+
}
|
|
580
|
+
if (!KEY_RE.test(key)) {
|
|
581
|
+
say('');
|
|
582
|
+
fail(`The key from ${source} is not shaped like a KM Hub key (kmh_live_ then hex).`);
|
|
583
|
+
return 2;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
say('');
|
|
587
|
+
say(`Asking KM Hub what it publishes today (key from ${source})...`);
|
|
588
|
+
const v = await api(base, key, '/version');
|
|
589
|
+
if (!v.ok) {
|
|
590
|
+
say('');
|
|
591
|
+
fail(explain(v, base));
|
|
592
|
+
return 1;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const d = v.data && typeof v.data === 'object' ? v.data : {};
|
|
596
|
+
const minClient = typeof d.min_client === 'string' ? d.min_client : '';
|
|
597
|
+
const rulesVersion = typeof d.rules_version === 'string' ? d.rules_version : '';
|
|
598
|
+
|
|
599
|
+
say('');
|
|
600
|
+
say('KM Hub publishes:');
|
|
601
|
+
say(statusLine('ok', 'API', d.api_version || 'not reported'));
|
|
602
|
+
say(statusLine('ok', 'tool catalogue', d.tools_version || 'not reported'));
|
|
603
|
+
say(statusLine('ok', 'rules pack', rulesVersion || 'not reported'));
|
|
604
|
+
say(statusLine('ok', 'oldest client it serves', minClient || 'not reported'));
|
|
605
|
+
say('');
|
|
606
|
+
|
|
607
|
+
// The only hard judgement available. min_client is the floor KM Hub refuses to
|
|
608
|
+
// serve below; there is no "latest client" field, so anything at or above the
|
|
609
|
+
// floor is reported as still served rather than dressed up as up to date.
|
|
610
|
+
if (!installed) {
|
|
611
|
+
say('This build could not be identified, so whether it is out of date cannot be answered here.');
|
|
612
|
+
say(`Run npx ${PKG_NAME} doctor first: something about the install is wrong.`);
|
|
613
|
+
return 1;
|
|
614
|
+
}
|
|
615
|
+
if (minClient && cmpVersion(installed, minClient) < 0) {
|
|
616
|
+
say(`OUT OF DATE. This connector is ${installed} and KM Hub no longer serves anything below ${minClient}.`);
|
|
617
|
+
say('It will stop working. Update it now:');
|
|
618
|
+
say('');
|
|
619
|
+
say(' 1. Quit Claude Code completely and open it again. The connector is launched with npx,');
|
|
620
|
+
say(' so a restart fetches the current build on its own.');
|
|
621
|
+
say(' 2. If it is still on the old version after that, re-register it:');
|
|
622
|
+
say('');
|
|
623
|
+
say(` claude mcp remove ${name} --scope user`);
|
|
624
|
+
say(` npx ${PKG_NAME}@latest install <your kmh_live_ key> ${name}`);
|
|
625
|
+
if (d.changelog_url) say(`\n What changed: ${d.changelog_url}`);
|
|
626
|
+
return 1;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
say(`Up to date enough. KM Hub still serves ${installed}${minClient ? ` (its floor is ${minClient})` : ''}.`);
|
|
630
|
+
say('Nothing to run.');
|
|
631
|
+
say('');
|
|
632
|
+
say(`The rules pack KM Hub publishes today is ${rulesVersion || 'not reported'}. This tool does not touch`);
|
|
633
|
+
say('your rules file. To refresh it, open Claude Code and say: check for KM Hub updates.');
|
|
634
|
+
say('That runs the connector\'s own update tools, which rewrite the KM Hub block in your CLAUDE.md.');
|
|
635
|
+
if (d.changelog_url) say(`\nChangelog: ${d.changelog_url}`);
|
|
636
|
+
return 0;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
// doctor
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The command that answers a support ticket without a call.
|
|
645
|
+
*
|
|
646
|
+
* Every check reports one of three things and, when it is not ok, the single
|
|
647
|
+
* next action. It never stops at the first failure: a client who has three
|
|
648
|
+
* things wrong should learn all three in one paste, not across three emails.
|
|
649
|
+
* Exit code is 1 if anything failed, so it is also usable as a gate.
|
|
650
|
+
*/
|
|
651
|
+
async function cmdDoctor(argKey, name) {
|
|
652
|
+
const checks = [];
|
|
653
|
+
const record = (status, label, detail, fix) => {
|
|
654
|
+
checks.push({ status, label, detail, fix });
|
|
655
|
+
say(statusLine(status, label, detail));
|
|
656
|
+
if (fix && status !== 'ok') {
|
|
657
|
+
for (const line of String(fix).split('\n')) say(` ${line}`);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const pkg = readPkg();
|
|
662
|
+
say(`kmhub doctor (${PKG_NAME}${pkg.version ? ` ${pkg.version}` : ''}, connector name "${name}")`);
|
|
663
|
+
say('');
|
|
664
|
+
|
|
665
|
+
// 1. The runtime underneath everything else.
|
|
666
|
+
const major = Number.parseInt(String(process.versions.node).split('.')[0], 10) || 0;
|
|
667
|
+
if (major >= 18) {
|
|
668
|
+
record('ok', 'Node', `${process.version} on ${process.platform}`);
|
|
669
|
+
} else {
|
|
670
|
+
record(
|
|
671
|
+
'fail',
|
|
672
|
+
'Node',
|
|
673
|
+
`${process.version} is too old`,
|
|
674
|
+
'The connector needs Node 18.17 or newer. Install a current Node and run this again.',
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// 2. Do the connector's own files load. This is the check that catches a
|
|
679
|
+
// half-finished install, a missing dependency, or a broken family file.
|
|
680
|
+
const server = await loadServer();
|
|
681
|
+
const installed = server.ok && typeof server.mod?.SERVER_VERSION === 'string' ? server.mod.SERVER_VERSION : '';
|
|
682
|
+
if (server.ok) {
|
|
683
|
+
const families = Array.isArray(server.mod?.FAMILY_NAMES) ? server.mod.FAMILY_NAMES : [];
|
|
684
|
+
const tools = Array.isArray(server.mod?.TOOL_NAMES) ? server.mod.TOOL_NAMES : [];
|
|
685
|
+
record('ok', 'connector files', `version ${installed || 'unknown'}, ${families.length} families, ${tools.length} tools`);
|
|
686
|
+
} else {
|
|
687
|
+
record(
|
|
688
|
+
'fail',
|
|
689
|
+
'connector files',
|
|
690
|
+
'they would not load',
|
|
691
|
+
`${server.error}\nReinstall the connector: npx ${PKG_NAME}@latest install <your kmh_live_ key> ${name}`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// 3. One process, two version numbers. They have disagreed before, so it is a check.
|
|
696
|
+
if (server.ok && pkg.version && installed) {
|
|
697
|
+
if (pkg.version === installed) {
|
|
698
|
+
record('ok', 'version identity', `package and server both say ${installed}`);
|
|
699
|
+
} else {
|
|
700
|
+
record(
|
|
701
|
+
'warn',
|
|
702
|
+
'version identity',
|
|
703
|
+
`package says ${pkg.version}, server says ${installed}`,
|
|
704
|
+
'That is a packaging bug on our side, not something you can fix. Send this report to KM Hub.',
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// 4. Is there a Claude Code to be registered in.
|
|
710
|
+
const claude = runCli('claude', ['--version'], 30000);
|
|
711
|
+
if (claude.missing) {
|
|
712
|
+
record(
|
|
713
|
+
'fail',
|
|
714
|
+
'Claude Code',
|
|
715
|
+
'the `claude` command was not found',
|
|
716
|
+
'Install it from https://claude.com/claude-code, then run the install again.',
|
|
717
|
+
);
|
|
718
|
+
} else if (claude.ok) {
|
|
719
|
+
record('ok', 'Claude Code', claude.stdout.split('\n')[0] || 'installed');
|
|
720
|
+
} else {
|
|
721
|
+
record('warn', 'Claude Code', `\`claude --version\` exited ${claude.status ?? 'oddly'}`, claude.stderr || claude.error);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// 5. Is the connector actually registered, and where.
|
|
725
|
+
const registrations = findRegistrations(name);
|
|
726
|
+
const usable = registrations.filter((r) => r.entry);
|
|
727
|
+
if (usable.length) {
|
|
728
|
+
for (const reg of usable) {
|
|
729
|
+
const cmd = Array.isArray(reg.entry.args)
|
|
730
|
+
? `${reg.entry.command} ${reg.entry.args.join(' ')}`
|
|
731
|
+
: reg.entry.command || reg.entry.url || 'no command recorded';
|
|
732
|
+
record('ok', 'registration', `${reg.label}${reg.where ? `, ${reg.where}` : ''}: ${cmd}`);
|
|
733
|
+
}
|
|
734
|
+
} else {
|
|
735
|
+
const unreadable = registrations.filter((r) => r.unreadable);
|
|
736
|
+
record(
|
|
737
|
+
'fail',
|
|
738
|
+
'registration',
|
|
739
|
+
`no connector called "${name}" in any Claude config on this machine`,
|
|
740
|
+
`${unreadable.length ? `${unreadable.map((u) => u.file).join(', ')} could not be parsed as JSON.\n` : ''}Register it: npx ${PKG_NAME} install <your kmh_live_ key> ${name}`,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// 6. A key to test with.
|
|
745
|
+
const { key, source } = resolveKey(argKey, usable);
|
|
746
|
+
const base = apiBase(server);
|
|
747
|
+
if (!key) {
|
|
748
|
+
record(
|
|
749
|
+
'fail',
|
|
750
|
+
'API key',
|
|
751
|
+
'none found',
|
|
752
|
+
`Pass it: npx ${PKG_NAME} doctor <your kmh_live_ key>\nOr set KMHUB_API_KEY in this shell.`,
|
|
753
|
+
);
|
|
754
|
+
} else if (!KEY_RE.test(key)) {
|
|
755
|
+
record(
|
|
756
|
+
'fail',
|
|
757
|
+
'API key',
|
|
758
|
+
`the value from ${source} is not shaped like a KM Hub key`,
|
|
759
|
+
'A key starts with kmh_live_ and is followed by hex. Copy it again from Settings > Connect > Developer & API.',
|
|
760
|
+
);
|
|
761
|
+
} else {
|
|
762
|
+
record('ok', 'API key', `${maskKey(key)} from ${source}`);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// 7 and 8. Authentication and entitlement, in one call. kmhub-api checks the
|
|
766
|
+
// key first and the subscription second, so a 401 says nothing about
|
|
767
|
+
// billing and a 402 says the key is fine and the subscription is not.
|
|
768
|
+
if (key && KEY_RE.test(key)) {
|
|
769
|
+
const me = await api(base, key, '/me');
|
|
770
|
+
if (me.ok) {
|
|
771
|
+
const org = me.data?.org && typeof me.data.org === 'object' ? me.data.org : {};
|
|
772
|
+
const scopes = Array.isArray(me.data?.scopes) ? me.data.scopes : [];
|
|
773
|
+
const workspace = typeof org.name === 'string' && org.name.trim() ? org.name.trim() : 'unnamed workspace';
|
|
774
|
+
record('ok', 'key authenticates', `${workspace}${org.plan ? ` (plan ${org.plan})` : ''}`);
|
|
775
|
+
record('ok', 'subscription', 'active, KM Hub is serving this workspace');
|
|
776
|
+
if (scopes.length && !scopes.includes('write')) {
|
|
777
|
+
record(
|
|
778
|
+
'warn',
|
|
779
|
+
'scopes',
|
|
780
|
+
`${scopes.join(', ')} only`,
|
|
781
|
+
'Read tools work. Anything that creates something will be refused. Mint a key with write scope if you need those.',
|
|
782
|
+
);
|
|
783
|
+
} else {
|
|
784
|
+
record('ok', 'scopes', scopes.length ? scopes.join(', ') : 'none reported');
|
|
785
|
+
}
|
|
786
|
+
} else if (me.status === 402) {
|
|
787
|
+
record('ok', 'key authenticates', 'the key itself is valid, KM Hub recognised it');
|
|
788
|
+
record('fail', 'subscription', 'not active', explain(me, base));
|
|
789
|
+
} else {
|
|
790
|
+
record('fail', 'key authenticates', `KM Hub answered ${me.status || 'nothing'}`, explain(me, base));
|
|
791
|
+
record('warn', 'subscription', 'cannot be checked until the key works');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// 9. Only meaningful once the key works.
|
|
795
|
+
if (me.ok) {
|
|
796
|
+
const v = await api(base, key, '/version');
|
|
797
|
+
if (!v.ok) {
|
|
798
|
+
record('warn', 'client version floor', 'KM Hub did not answer GET /version', explain(v, base));
|
|
799
|
+
} else {
|
|
800
|
+
const minClient = typeof v.data?.min_client === 'string' ? v.data.min_client : '';
|
|
801
|
+
if (!installed || !minClient) {
|
|
802
|
+
record('warn', 'client version floor', 'not enough information to judge');
|
|
803
|
+
} else if (cmpVersion(installed, minClient) < 0) {
|
|
804
|
+
record(
|
|
805
|
+
'fail',
|
|
806
|
+
'client version floor',
|
|
807
|
+
`this build is ${installed} and KM Hub serves nothing below ${minClient}`,
|
|
808
|
+
`Restart Claude Code to pull the current build, or re-register:\n claude mcp remove ${name} --scope user\n npx ${PKG_NAME}@latest install <your kmh_live_ key> ${name}`,
|
|
809
|
+
);
|
|
810
|
+
} else {
|
|
811
|
+
record('ok', 'client version floor', `${installed} is at or above ${minClient}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const failed = checks.filter((c) => c.status === 'fail');
|
|
818
|
+
const warned = checks.filter((c) => c.status === 'warn');
|
|
819
|
+
say('');
|
|
820
|
+
if (!failed.length && !warned.length) {
|
|
821
|
+
say('Everything checks out. If a tool still misbehaves, the problem is in the conversation,');
|
|
822
|
+
say('not the connection: say what you asked for and what came back.');
|
|
823
|
+
return 0;
|
|
824
|
+
}
|
|
825
|
+
say(`${failed.length} failed, ${warned.length} to be aware of.`);
|
|
826
|
+
if (failed.length) {
|
|
827
|
+
say('');
|
|
828
|
+
say(`Next: ${failed[0].label}.`);
|
|
829
|
+
if (failed[0].fix) for (const line of String(failed[0].fix).split('\n')) say(` ${line}`);
|
|
830
|
+
return 1;
|
|
831
|
+
}
|
|
832
|
+
return 0;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ---------------------------------------------------------------------------
|
|
836
|
+
// argv
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* No flags to learn. A KM Hub key is unmistakable, so any argument that looks
|
|
841
|
+
* like one is the key and anything else is the connector name, whatever order
|
|
842
|
+
* they arrive in.
|
|
843
|
+
*/
|
|
844
|
+
function parseArgs(argv) {
|
|
845
|
+
const [command, ...rest] = argv;
|
|
846
|
+
let key = '';
|
|
847
|
+
let name = '';
|
|
848
|
+
for (const arg of rest) {
|
|
849
|
+
if (!arg) continue;
|
|
850
|
+
if (/^kmh_live_/i.test(arg)) {
|
|
851
|
+
if (!key) key = arg.trim();
|
|
852
|
+
} else if (!name) {
|
|
853
|
+
name = arg.trim();
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return { command: (command || '').toLowerCase(), key, name: name || DEFAULT_CONNECTOR };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function main() {
|
|
860
|
+
const { command, key, name } = parseArgs(process.argv.slice(2));
|
|
861
|
+
|
|
862
|
+
if (!command || command === 'help' || command === '-h' || command === '--help') {
|
|
863
|
+
say(HELP);
|
|
864
|
+
return command ? 0 : 1;
|
|
865
|
+
}
|
|
866
|
+
if (command === 'version' || command === '-v' || command === '--version') {
|
|
867
|
+
const pkg = readPkg();
|
|
868
|
+
const server = await loadServer();
|
|
869
|
+
say(pkg.version || 'unknown');
|
|
870
|
+
if (server.ok && server.mod?.SERVER_VERSION && server.mod.SERVER_VERSION !== pkg.version) {
|
|
871
|
+
fail(`warning: the connector inside this package reports ${server.mod.SERVER_VERSION}. Those should match.`);
|
|
872
|
+
return 1;
|
|
873
|
+
}
|
|
874
|
+
return 0;
|
|
875
|
+
}
|
|
876
|
+
if (command === 'install') return cmdInstall(key, name);
|
|
877
|
+
if (command === 'update') return cmdUpdate(key, name);
|
|
878
|
+
if (command === 'doctor') return cmdDoctor(key, name);
|
|
879
|
+
|
|
880
|
+
fail(`kmhub: "${command}" is not a command.`);
|
|
881
|
+
fail('');
|
|
882
|
+
fail(HELP);
|
|
883
|
+
return 2;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
main()
|
|
887
|
+
.then((code) => {
|
|
888
|
+
process.exitCode = code || 0;
|
|
889
|
+
})
|
|
890
|
+
.catch((e) => {
|
|
891
|
+
// Nothing above is supposed to throw. If something does, say so plainly and
|
|
892
|
+
// point at the one command that is built to survive a broken install.
|
|
893
|
+
fail(`kmhub: unexpected failure: ${String(e?.stack || e?.message || e)}`);
|
|
894
|
+
fail(`If this was install or update, try: npx ${PKG_NAME} doctor`);
|
|
895
|
+
process.exitCode = 1;
|
|
896
|
+
});
|