@levr-one/cli 0.6.12 → 0.7.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/dist/addHandler-7QMKiy7o.js +1380 -0
- package/dist/cli.js +36 -6
- package/package.json +1 -1
- package/dist/addHandler-K2a4rVv8.js +0 -688
|
@@ -1,688 +0,0 @@
|
|
|
1
|
-
import { getApiUrl } from "./env-CHeKHu5S.js";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { delimiter, dirname, join } from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
6
|
-
|
|
7
|
-
//#region ../mcp-harnesses/dist/catalog.js
|
|
8
|
-
/**
|
|
9
|
-
* @levr/mcp-harnesses — isomorphic catalog (browser + Node).
|
|
10
|
-
*
|
|
11
|
-
* Single source of truth for MCP-capable clients ("harnesses"): the catalog,
|
|
12
|
-
* the OAuth-client → catalog matcher, and the pure config builders. This module
|
|
13
|
-
* MUST stay free of Node built-ins (`node:fs`, `node:os`, `node:path`) so the
|
|
14
|
-
* client SPA can import it without pulling `node:fs` into the bundle. Detection
|
|
15
|
-
* and config-write live in the `@levr/mcp-harnesses/node` subpath.
|
|
16
|
-
*
|
|
17
|
-
* Plan: specs/plans/mcp-harness-detect-installer.md (ENG-43, P1).
|
|
18
|
-
*/
|
|
19
|
-
/** Stable server key written into every harness config (used by detect/remove).
|
|
20
|
-
* Renamed from the legacy brand key pre-first-publish (ENG-2515) — this key
|
|
21
|
-
* is a persisted identity in end-users' client config files, so it must not
|
|
22
|
-
* carry the old brand. */
|
|
23
|
-
const SERVER_NAME = "levr";
|
|
24
|
-
/**
|
|
25
|
-
* The catalog. Order is presentation order (most common first).
|
|
26
|
-
*
|
|
27
|
-
* `comingSoon` clients (VS Code, Codex) are listed but not installable in v1:
|
|
28
|
-
* their config formats differ enough (VS Code's `servers`/native-http schema,
|
|
29
|
-
* Codex's TOML `~/.codex/config.toml`) that faithful writes are deferred to a
|
|
30
|
-
* later catalog entry rather than forced through the JSON `mcpServers` builder.
|
|
31
|
-
*/
|
|
32
|
-
const HARNESSES = [
|
|
33
|
-
{
|
|
34
|
-
id: "claude",
|
|
35
|
-
label: "Claude Desktop",
|
|
36
|
-
matchers: [
|
|
37
|
-
"claude desktop",
|
|
38
|
-
"claude-desktop",
|
|
39
|
-
"claude"
|
|
40
|
-
],
|
|
41
|
-
serverPropertyName: "mcpServers",
|
|
42
|
-
installKind: "config-file",
|
|
43
|
-
transport: "mcp-remote",
|
|
44
|
-
docsUrl: "https://modelcontextprotocol.io/quickstart/user",
|
|
45
|
-
comingSoon: false,
|
|
46
|
-
locations: [
|
|
47
|
-
{
|
|
48
|
-
platform: "darwin",
|
|
49
|
-
configPath: "~/Library/Application Support/Claude/claude_desktop_config.json",
|
|
50
|
-
installSignals: ["~/Library/Application Support/Claude", "/Applications/Claude.app"]
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
platform: "win32",
|
|
54
|
-
configPath: "~/AppData/Roaming/Claude/claude_desktop_config.json",
|
|
55
|
-
installSignals: ["~/AppData/Roaming/Claude"]
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
platform: "linux",
|
|
59
|
-
configPath: "~/.config/Claude/claude_desktop_config.json",
|
|
60
|
-
installSignals: ["~/.config/Claude"]
|
|
61
|
-
}
|
|
62
|
-
]
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: "claude-code",
|
|
66
|
-
label: "Claude Code",
|
|
67
|
-
matchers: [
|
|
68
|
-
"claude code",
|
|
69
|
-
"claude-code",
|
|
70
|
-
"claude_code",
|
|
71
|
-
"claudecode"
|
|
72
|
-
],
|
|
73
|
-
serverPropertyName: "mcpServers",
|
|
74
|
-
installKind: "cli-command",
|
|
75
|
-
transport: "native-http",
|
|
76
|
-
docsUrl: "https://docs.anthropic.com/en/docs/claude-code/mcp",
|
|
77
|
-
comingSoon: false,
|
|
78
|
-
locations: [
|
|
79
|
-
{
|
|
80
|
-
platform: "darwin",
|
|
81
|
-
configPath: "~/.claude.json",
|
|
82
|
-
installSignals: [
|
|
83
|
-
"which:claude",
|
|
84
|
-
"~/.claude.json",
|
|
85
|
-
"~/.claude"
|
|
86
|
-
]
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
platform: "linux",
|
|
90
|
-
configPath: "~/.claude.json",
|
|
91
|
-
installSignals: [
|
|
92
|
-
"which:claude",
|
|
93
|
-
"~/.claude.json",
|
|
94
|
-
"~/.claude"
|
|
95
|
-
]
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
platform: "win32",
|
|
99
|
-
configPath: "~/.claude.json",
|
|
100
|
-
installSignals: [
|
|
101
|
-
"which:claude",
|
|
102
|
-
"~/.claude.json",
|
|
103
|
-
"~/.claude"
|
|
104
|
-
]
|
|
105
|
-
}
|
|
106
|
-
]
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
id: "cursor",
|
|
110
|
-
label: "Cursor",
|
|
111
|
-
matchers: ["cursor"],
|
|
112
|
-
serverPropertyName: "mcpServers",
|
|
113
|
-
installKind: "config-file",
|
|
114
|
-
transport: "mcp-remote",
|
|
115
|
-
docsUrl: "https://docs.cursor.com/context/model-context-protocol",
|
|
116
|
-
comingSoon: false,
|
|
117
|
-
locations: [
|
|
118
|
-
{
|
|
119
|
-
platform: "darwin",
|
|
120
|
-
configPath: "~/.cursor/mcp.json",
|
|
121
|
-
installSignals: [
|
|
122
|
-
"~/.cursor",
|
|
123
|
-
"which:cursor",
|
|
124
|
-
"/Applications/Cursor.app"
|
|
125
|
-
]
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
platform: "win32",
|
|
129
|
-
configPath: "~/.cursor/mcp.json",
|
|
130
|
-
installSignals: ["~/.cursor", "which:cursor"]
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
platform: "linux",
|
|
134
|
-
configPath: "~/.cursor/mcp.json",
|
|
135
|
-
installSignals: ["~/.cursor", "which:cursor"]
|
|
136
|
-
}
|
|
137
|
-
]
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
id: "windsurf",
|
|
141
|
-
label: "Windsurf",
|
|
142
|
-
matchers: ["windsurf", "codeium"],
|
|
143
|
-
serverPropertyName: "mcpServers",
|
|
144
|
-
installKind: "config-file",
|
|
145
|
-
transport: "mcp-remote",
|
|
146
|
-
docsUrl: "https://docs.windsurf.com/windsurf/mcp",
|
|
147
|
-
comingSoon: false,
|
|
148
|
-
locations: [
|
|
149
|
-
{
|
|
150
|
-
platform: "darwin",
|
|
151
|
-
configPath: "~/.codeium/windsurf/mcp_config.json",
|
|
152
|
-
installSignals: [
|
|
153
|
-
"~/.codeium/windsurf",
|
|
154
|
-
"/Applications/Windsurf.app",
|
|
155
|
-
"which:windsurf"
|
|
156
|
-
]
|
|
157
|
-
},
|
|
158
|
-
{
|
|
159
|
-
platform: "win32",
|
|
160
|
-
configPath: "~/.codeium/windsurf/mcp_config.json",
|
|
161
|
-
installSignals: ["~/.codeium/windsurf", "which:windsurf"]
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
platform: "linux",
|
|
165
|
-
configPath: "~/.codeium/windsurf/mcp_config.json",
|
|
166
|
-
installSignals: ["~/.codeium/windsurf", "which:windsurf"]
|
|
167
|
-
}
|
|
168
|
-
]
|
|
169
|
-
},
|
|
170
|
-
{
|
|
171
|
-
id: "zed",
|
|
172
|
-
label: "Zed",
|
|
173
|
-
matchers: ["zed"],
|
|
174
|
-
serverPropertyName: "context_servers",
|
|
175
|
-
installKind: "config-file",
|
|
176
|
-
transport: "mcp-remote",
|
|
177
|
-
docsUrl: "https://zed.dev/docs/assistant/model-context-protocol",
|
|
178
|
-
comingSoon: false,
|
|
179
|
-
locations: [
|
|
180
|
-
{
|
|
181
|
-
platform: "darwin",
|
|
182
|
-
configPath: "~/.config/zed/settings.json",
|
|
183
|
-
installSignals: [
|
|
184
|
-
"~/.config/zed",
|
|
185
|
-
"/Applications/Zed.app",
|
|
186
|
-
"which:zed"
|
|
187
|
-
]
|
|
188
|
-
},
|
|
189
|
-
{
|
|
190
|
-
platform: "linux",
|
|
191
|
-
configPath: "~/.config/zed/settings.json",
|
|
192
|
-
installSignals: ["~/.config/zed", "which:zed"]
|
|
193
|
-
},
|
|
194
|
-
{
|
|
195
|
-
platform: "win32",
|
|
196
|
-
configPath: "~/AppData/Roaming/Zed/settings.json",
|
|
197
|
-
installSignals: ["~/AppData/Roaming/Zed"]
|
|
198
|
-
}
|
|
199
|
-
]
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
id: "vscode",
|
|
203
|
-
label: "VS Code",
|
|
204
|
-
matchers: [
|
|
205
|
-
"vscode",
|
|
206
|
-
"vs code",
|
|
207
|
-
"visual studio code"
|
|
208
|
-
],
|
|
209
|
-
serverPropertyName: "mcpServers",
|
|
210
|
-
installKind: "config-file",
|
|
211
|
-
transport: "mcp-remote",
|
|
212
|
-
docsUrl: "https://code.visualstudio.com/docs/copilot/chat/mcp-servers",
|
|
213
|
-
comingSoon: true,
|
|
214
|
-
locations: [
|
|
215
|
-
{
|
|
216
|
-
platform: "darwin",
|
|
217
|
-
configPath: "~/Library/Application Support/Code/User/mcp.json",
|
|
218
|
-
installSignals: ["/Applications/Visual Studio Code.app", "which:code"]
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
platform: "win32",
|
|
222
|
-
configPath: "~/AppData/Roaming/Code/User/mcp.json",
|
|
223
|
-
installSignals: ["which:code"]
|
|
224
|
-
},
|
|
225
|
-
{
|
|
226
|
-
platform: "linux",
|
|
227
|
-
configPath: "~/.config/Code/User/mcp.json",
|
|
228
|
-
installSignals: ["~/.config/Code", "which:code"]
|
|
229
|
-
}
|
|
230
|
-
]
|
|
231
|
-
},
|
|
232
|
-
{
|
|
233
|
-
id: "codex",
|
|
234
|
-
label: "Codex CLI",
|
|
235
|
-
matchers: ["codex"],
|
|
236
|
-
serverPropertyName: "mcpServers",
|
|
237
|
-
installKind: "config-file",
|
|
238
|
-
transport: "mcp-remote",
|
|
239
|
-
docsUrl: "https://github.com/openai/codex",
|
|
240
|
-
comingSoon: true,
|
|
241
|
-
locations: [
|
|
242
|
-
{
|
|
243
|
-
platform: "darwin",
|
|
244
|
-
configPath: "~/.codex/config.toml",
|
|
245
|
-
installSignals: ["~/.codex", "which:codex"]
|
|
246
|
-
},
|
|
247
|
-
{
|
|
248
|
-
platform: "linux",
|
|
249
|
-
configPath: "~/.codex/config.toml",
|
|
250
|
-
installSignals: ["~/.codex", "which:codex"]
|
|
251
|
-
},
|
|
252
|
-
{
|
|
253
|
-
platform: "win32",
|
|
254
|
-
configPath: "~/.codex/config.toml",
|
|
255
|
-
installSignals: ["~/.codex", "which:codex"]
|
|
256
|
-
}
|
|
257
|
-
]
|
|
258
|
-
}
|
|
259
|
-
];
|
|
260
|
-
/** Look up a harness by id. Returns `undefined` for unknown ids. */
|
|
261
|
-
function getHarness(id) {
|
|
262
|
-
return HARNESSES.find((h) => h.id === id);
|
|
263
|
-
}
|
|
264
|
-
/** The `npx -y mcp-remote <url>` invocation shared by mcp-remote harnesses. */
|
|
265
|
-
function mcpRemoteInvocation(mcpUrl) {
|
|
266
|
-
return {
|
|
267
|
-
command: "npx",
|
|
268
|
-
args: [
|
|
269
|
-
"-y",
|
|
270
|
-
"mcp-remote",
|
|
271
|
-
mcpUrl
|
|
272
|
-
]
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
/**
|
|
276
|
-
* The structured server entry to merge under `harness.serverPropertyName`,
|
|
277
|
-
* keyed by {@link SERVER_NAME}. Shape is per-harness:
|
|
278
|
-
* - `mcpServers` (Claude Desktop, Cursor, Windsurf): flat `{ command, args }`.
|
|
279
|
-
* - `context_servers` (Zed): nested `{ source, command: { path, args } }`.
|
|
280
|
-
*
|
|
281
|
-
* Consumed by the string builder here and, in P2, by the jsonc-preserving
|
|
282
|
-
* install merge. Not used for `cli-command` harnesses (see {@link buildHarnessConfig}).
|
|
283
|
-
*/
|
|
284
|
-
function buildServerEntry(harness, mcpUrl) {
|
|
285
|
-
const { command, args } = mcpRemoteInvocation(mcpUrl);
|
|
286
|
-
if (harness.serverPropertyName === "context_servers") return { [SERVER_NAME]: {
|
|
287
|
-
source: "custom",
|
|
288
|
-
command: {
|
|
289
|
-
path: command,
|
|
290
|
-
args
|
|
291
|
-
}
|
|
292
|
-
} };
|
|
293
|
-
return { [SERVER_NAME]: {
|
|
294
|
-
command,
|
|
295
|
-
args
|
|
296
|
-
} };
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* The customer-facing install snippet for a harness + MCP URL:
|
|
300
|
-
* - `cli-command` harnesses (Claude Code) → the exact command to run.
|
|
301
|
-
* - `config-file` harnesses → a pretty-printed JSON snippet (the wrapping
|
|
302
|
-
* `serverPropertyName` + our server entry) to paste/merge into their config.
|
|
303
|
-
*/
|
|
304
|
-
function buildHarnessConfig(harness, mcpUrl) {
|
|
305
|
-
if (harness.installKind === "cli-command") return `claude mcp add --transport http ${SERVER_NAME} ${mcpUrl}`;
|
|
306
|
-
const snippet = { [harness.serverPropertyName]: buildServerEntry(harness, mcpUrl) };
|
|
307
|
-
return JSON.stringify(snippet, null, 2);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
//#endregion
|
|
311
|
-
//#region ../mcp-harnesses/dist/node/paths.js
|
|
312
|
-
function defaultEnv() {
|
|
313
|
-
return {
|
|
314
|
-
platform: process.platform,
|
|
315
|
-
homedir: homedir(),
|
|
316
|
-
pathVar: process.env.PATH ?? ""
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
/** Expand a leading `~` / `~/…` to the given home directory. */
|
|
320
|
-
function expandTilde(p, home) {
|
|
321
|
-
if (p === "~") return home;
|
|
322
|
-
if (p.startsWith("~/") || p.startsWith("~\\")) return join(home, p.slice(2));
|
|
323
|
-
return p;
|
|
324
|
-
}
|
|
325
|
-
/** The config location for a harness on the given platform, if any. */
|
|
326
|
-
function locationFor(harness, platform) {
|
|
327
|
-
return harness.locations.find((l) => l.platform === platform);
|
|
328
|
-
}
|
|
329
|
-
/** Absolute (tilde-expanded) config path for a harness, or undefined if the
|
|
330
|
-
* harness has no location on this platform. */
|
|
331
|
-
function resolveConfigPath(harness, env) {
|
|
332
|
-
const loc = locationFor(harness, env.platform);
|
|
333
|
-
if (!loc) return void 0;
|
|
334
|
-
return expandTilde(loc.configPath, env.homedir);
|
|
335
|
-
}
|
|
336
|
-
const WIN_EXTS = [
|
|
337
|
-
"",
|
|
338
|
-
".exe",
|
|
339
|
-
".cmd",
|
|
340
|
-
".bat"
|
|
341
|
-
];
|
|
342
|
-
/** Is `bin` resolvable on PATH? Pure `process.env.PATH` scan — never spawns a
|
|
343
|
-
* shell (no `which`/`where` subprocess). */
|
|
344
|
-
function whichSync(bin, env) {
|
|
345
|
-
const exts = env.platform === "win32" ? WIN_EXTS : [""];
|
|
346
|
-
for (const dir of env.pathVar.split(delimiter)) {
|
|
347
|
-
if (!dir) continue;
|
|
348
|
-
for (const ext of exts) if (existsSync(join(dir, bin + ext))) return true;
|
|
349
|
-
}
|
|
350
|
-
return false;
|
|
351
|
-
}
|
|
352
|
-
/** Does one `installSignals` entry match on this machine? Supports
|
|
353
|
-
* `which:<bin>`, `~`-prefixed paths, and absolute (app-bundle) paths. */
|
|
354
|
-
function signalMatches(signal, env) {
|
|
355
|
-
if (signal.startsWith("which:")) return whichSync(signal.slice(6), env);
|
|
356
|
-
return existsSync(signal.startsWith("~") ? expandTilde(signal, env.homedir) : signal);
|
|
357
|
-
}
|
|
358
|
-
/** Read a text file, or `null` if it doesn't exist / can't be read. */
|
|
359
|
-
function readTextOrNull(path) {
|
|
360
|
-
try {
|
|
361
|
-
return readFileSync(path, "utf8");
|
|
362
|
-
} catch {
|
|
363
|
-
return null;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
/** Safe nested lookup over an unknown-typed parsed JSON value. */
|
|
367
|
-
function getAtPath(obj, path) {
|
|
368
|
-
let cur = obj;
|
|
369
|
-
for (const key of path) {
|
|
370
|
-
if (cur === null || typeof cur !== "object") return void 0;
|
|
371
|
-
cur = cur[key];
|
|
372
|
-
}
|
|
373
|
-
return cur;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
//#endregion
|
|
377
|
-
//#region ../mcp-harnesses/dist/node/detect.js
|
|
378
|
-
/** Is our server key present under the harness's server property? */
|
|
379
|
-
function isServerConfigured(harness, configPath) {
|
|
380
|
-
const text = readTextOrNull(configPath);
|
|
381
|
-
if (!text) return false;
|
|
382
|
-
const val = getAtPath(parse(text), [harness.serverPropertyName, SERVER_NAME]);
|
|
383
|
-
return val !== void 0 && val !== null;
|
|
384
|
-
}
|
|
385
|
-
function detectOne(harness, env) {
|
|
386
|
-
const loc = locationFor(harness, env.platform);
|
|
387
|
-
const configPath = resolveConfigPath(harness, env);
|
|
388
|
-
const available = Boolean(loc);
|
|
389
|
-
let installed = false;
|
|
390
|
-
let alreadyConfigured = false;
|
|
391
|
-
if (loc && configPath) {
|
|
392
|
-
installed = loc.installSignals.some((s) => signalMatches(s, env)) || existsSync(configPath);
|
|
393
|
-
alreadyConfigured = isServerConfigured(harness, configPath);
|
|
394
|
-
}
|
|
395
|
-
return {
|
|
396
|
-
id: harness.id,
|
|
397
|
-
label: harness.label,
|
|
398
|
-
installed,
|
|
399
|
-
alreadyConfigured,
|
|
400
|
-
configPath: configPath ?? "",
|
|
401
|
-
available,
|
|
402
|
-
comingSoon: harness.comingSoon
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
/** Synchronous detection over the whole catalog. Exported for tests. */
|
|
406
|
-
function detectSync(env = defaultEnv()) {
|
|
407
|
-
return HARNESSES.map((h) => detectOne(h, env));
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
//#endregion
|
|
411
|
-
//#region ../mcp-harnesses/dist/node/install.js
|
|
412
|
-
const FORMAT = {
|
|
413
|
-
insertSpaces: true,
|
|
414
|
-
tabSize: 2,
|
|
415
|
-
eol: "\n"
|
|
416
|
-
};
|
|
417
|
-
/** JSON-structural equality — sufficient for our small config values. */
|
|
418
|
-
function sameValue(a, b) {
|
|
419
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
420
|
-
}
|
|
421
|
-
/** Merge (or preview) our MCP into a harness config. */
|
|
422
|
-
function installHarnessSync(harness, mcpUrl, opts = {}) {
|
|
423
|
-
const env = opts.env ?? defaultEnv();
|
|
424
|
-
const dryRun = opts.dryRun ?? false;
|
|
425
|
-
if (harness.installKind === "cli-command") return {
|
|
426
|
-
ok: true,
|
|
427
|
-
wrote: false,
|
|
428
|
-
path: "",
|
|
429
|
-
command: buildHarnessConfig(harness, mcpUrl),
|
|
430
|
-
alreadyConfigured: false,
|
|
431
|
-
dryRun
|
|
432
|
-
};
|
|
433
|
-
const path = resolveConfigPath(harness, env);
|
|
434
|
-
if (!path) return {
|
|
435
|
-
ok: false,
|
|
436
|
-
wrote: false,
|
|
437
|
-
path: "",
|
|
438
|
-
alreadyConfigured: false,
|
|
439
|
-
dryRun
|
|
440
|
-
};
|
|
441
|
-
const entryValue = buildServerEntry(harness, mcpUrl)[SERVER_NAME];
|
|
442
|
-
const modPath = [harness.serverPropertyName, SERVER_NAME];
|
|
443
|
-
const existing = readTextOrNull(path);
|
|
444
|
-
const baseText = existing && existing.trim() ? existing : "{}";
|
|
445
|
-
const current = getAtPath(parse(baseText), modPath);
|
|
446
|
-
const alreadyConfigured = current !== void 0 && sameValue(current, entryValue);
|
|
447
|
-
const nextText = applyEdits(baseText, modify(baseText, modPath, entryValue, { formattingOptions: FORMAT }));
|
|
448
|
-
if (alreadyConfigured) return {
|
|
449
|
-
ok: true,
|
|
450
|
-
wrote: false,
|
|
451
|
-
path,
|
|
452
|
-
alreadyConfigured: true,
|
|
453
|
-
dryRun,
|
|
454
|
-
preview: nextText
|
|
455
|
-
};
|
|
456
|
-
if (dryRun) return {
|
|
457
|
-
ok: true,
|
|
458
|
-
wrote: false,
|
|
459
|
-
path,
|
|
460
|
-
alreadyConfigured: false,
|
|
461
|
-
dryRun: true,
|
|
462
|
-
preview: nextText
|
|
463
|
-
};
|
|
464
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
465
|
-
const finalText = nextText.endsWith("\n") ? nextText : `${nextText}\n`;
|
|
466
|
-
writeFileSync(path, finalText, "utf8");
|
|
467
|
-
return {
|
|
468
|
-
ok: true,
|
|
469
|
-
wrote: true,
|
|
470
|
-
path,
|
|
471
|
-
alreadyConfigured: false,
|
|
472
|
-
dryRun: false,
|
|
473
|
-
preview: finalText
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
//#endregion
|
|
478
|
-
//#region src/mcp/url.ts
|
|
479
|
-
const KNOWN_MCP_URLS = {
|
|
480
|
-
"api.levr.one": "https://ai.levr.one/api/v1/mcp",
|
|
481
|
-
"api.levr.now": "https://ai.levr.now/api/v1/mcp"
|
|
482
|
-
};
|
|
483
|
-
/**
|
|
484
|
-
* Resolve the MCP server URL: `--url` flag > `LEVR_MCP_URL` env > derived
|
|
485
|
-
* from the resolved API URL (which itself honors `LEVR_URL` > the URL stored
|
|
486
|
-
* at login > production default, ENG-2361). Known Levr hosts map to their
|
|
487
|
-
* app-host MCP resource; anything else (localhost dev stacks, custom
|
|
488
|
-
* deployments) derives `<api-url>/v1/mcp`.
|
|
489
|
-
*/
|
|
490
|
-
function resolveMcpUrl(flagUrl) {
|
|
491
|
-
if (flagUrl) return {
|
|
492
|
-
url: stripSlash(flagUrl),
|
|
493
|
-
source: "flag"
|
|
494
|
-
};
|
|
495
|
-
const envVar = process.env["LEVR_MCP_URL"];
|
|
496
|
-
if (envVar) return {
|
|
497
|
-
url: stripSlash(envVar),
|
|
498
|
-
source: "env:LEVR_MCP_URL"
|
|
499
|
-
};
|
|
500
|
-
const apiUrl = getApiUrl();
|
|
501
|
-
return {
|
|
502
|
-
url: knownMcpUrl(apiUrl) ?? `${apiUrl}/v1/mcp`,
|
|
503
|
-
source: `derived:${apiUrl}`
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
function knownMcpUrl(apiUrl) {
|
|
507
|
-
try {
|
|
508
|
-
return KNOWN_MCP_URLS[new URL(apiUrl).host];
|
|
509
|
-
} catch {
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
function stripSlash(url) {
|
|
514
|
-
return url.replace(/\/+$/, "");
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
//#endregion
|
|
518
|
-
//#region src/mcp/run.ts
|
|
519
|
-
/** Harness ids to pre-select in interactive mode: detected + installable +
|
|
520
|
-
* not-already-configured. */
|
|
521
|
-
function autoSelectIds(detected) {
|
|
522
|
-
return detected.filter((d) => d.available && !d.comingSoon && d.installed && !d.alreadyConfigured).map((d) => d.id);
|
|
523
|
-
}
|
|
524
|
-
/** Resolve `--all` / `--client` into concrete, installable harness ids. */
|
|
525
|
-
function resolveRequestedIds(options, detected) {
|
|
526
|
-
if (options.all) return {
|
|
527
|
-
ids: detected.filter((d) => d.available && !d.comingSoon).map((d) => d.id),
|
|
528
|
-
unknown: [],
|
|
529
|
-
comingSoon: []
|
|
530
|
-
};
|
|
531
|
-
const ids = [];
|
|
532
|
-
const unknown = [];
|
|
533
|
-
const comingSoon = [];
|
|
534
|
-
for (const c of options.clients ?? []) {
|
|
535
|
-
const harness = getHarness(c);
|
|
536
|
-
if (!harness) unknown.push(c);
|
|
537
|
-
else if (harness.comingSoon) comingSoon.push(c);
|
|
538
|
-
else ids.push(c);
|
|
539
|
-
}
|
|
540
|
-
return {
|
|
541
|
-
ids,
|
|
542
|
-
unknown,
|
|
543
|
-
comingSoon
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
/** Install each selected id, collecting structured outcomes. */
|
|
547
|
-
function installSelected(ids, mcpUrl, dryRun, install) {
|
|
548
|
-
const outcomes = [];
|
|
549
|
-
for (const id of ids) {
|
|
550
|
-
const harness = getHarness(id);
|
|
551
|
-
if (!harness) continue;
|
|
552
|
-
outcomes.push({
|
|
553
|
-
id,
|
|
554
|
-
label: harness.label,
|
|
555
|
-
result: install(harness, mcpUrl, dryRun)
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
return outcomes;
|
|
559
|
-
}
|
|
560
|
-
/**
|
|
561
|
-
* The non-interactive run: detect, pick ids from `--all`/`--client` (or
|
|
562
|
-
* auto-select when only `--yes` is given), install, and return a structured
|
|
563
|
-
* report. No console output — the caller formats it.
|
|
564
|
-
*/
|
|
565
|
-
function runNonInteractive(options, url, urlSource, deps) {
|
|
566
|
-
const detected = deps.detect();
|
|
567
|
-
let ids;
|
|
568
|
-
let unknown = [];
|
|
569
|
-
let comingSoon = [];
|
|
570
|
-
if (options.all || options.clients && options.clients.length > 0) {
|
|
571
|
-
const requested = resolveRequestedIds(options, detected);
|
|
572
|
-
ids = requested.ids;
|
|
573
|
-
unknown = requested.unknown;
|
|
574
|
-
comingSoon = requested.comingSoon;
|
|
575
|
-
} else ids = autoSelectIds(detected);
|
|
576
|
-
return {
|
|
577
|
-
url,
|
|
578
|
-
urlSource,
|
|
579
|
-
outcomes: installSelected(ids, url, options.dryRun, deps.install),
|
|
580
|
-
unknownClients: unknown,
|
|
581
|
-
comingSoonClients: comingSoon,
|
|
582
|
-
dryRun: options.dryRun
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
/** One human-readable status line per outcome. */
|
|
586
|
-
function outcomeLine(o, dryRun) {
|
|
587
|
-
const r = o.result;
|
|
588
|
-
if (r.command) return `${o.label}: run \`${r.command}\``;
|
|
589
|
-
if (!r.ok) return `${o.label}: failed (no config location on this platform)`;
|
|
590
|
-
if (r.alreadyConfigured) return `${o.label}: already set up (${r.path})`;
|
|
591
|
-
if (dryRun) return `${o.label}: would update ${r.path} (dry run — no changes)`;
|
|
592
|
-
if (r.wrote) return `${o.label}: installed → ${r.path}`;
|
|
593
|
-
return `${o.label}: no change (${r.path})`;
|
|
594
|
-
}
|
|
595
|
-
/** Render a report as a plain multi-line summary (used by the CLI + tests). */
|
|
596
|
-
function formatReport(report) {
|
|
597
|
-
const lines = [];
|
|
598
|
-
lines.push(`MCP URL: ${report.url} (${report.urlSource})`);
|
|
599
|
-
if (report.outcomes.length === 0) lines.push("No clients selected.");
|
|
600
|
-
else for (const o of report.outcomes) lines.push(outcomeLine(o, report.dryRun));
|
|
601
|
-
if (report.unknownClients.length > 0) lines.push(`Unknown clients (skipped): ${report.unknownClients.join(", ")}`);
|
|
602
|
-
if (report.comingSoonClients.length > 0) lines.push(`Coming soon (skipped): ${report.comingSoonClients.join(", ")}`);
|
|
603
|
-
return lines.join("\n");
|
|
604
|
-
}
|
|
605
|
-
/** Next-steps blurb after a run. */
|
|
606
|
-
function nextStepsText(report) {
|
|
607
|
-
if (report.dryRun) return "Dry run — re-run without --dry-run to apply these changes.";
|
|
608
|
-
if (!report.outcomes.some((o) => o.result.wrote || o.result.command)) return "Nothing to do.";
|
|
609
|
-
return ["Next: restart the client(s) above — each will prompt you to authorize", "Levr once in the browser. Then ask it: \"What issues are assigned to me?\""].join("\n");
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
//#endregion
|
|
613
|
-
//#region src/commands/mcp/addHandler.ts
|
|
614
|
-
const defaultInstall = (harness, mcpUrl, dryRun) => installHarnessSync(harness, mcpUrl, { dryRun });
|
|
615
|
-
const defaultDeps = {
|
|
616
|
-
detect: () => detectSync(),
|
|
617
|
-
install: defaultInstall
|
|
618
|
-
};
|
|
619
|
-
async function mcpAddHandler(flags) {
|
|
620
|
-
const { url, source } = resolveMcpUrl(flags.url);
|
|
621
|
-
const clients = (flags.client ?? []).flatMap((c) => c.split(",").map((s) => s.trim()).filter(Boolean));
|
|
622
|
-
const options = {
|
|
623
|
-
all: flags.all,
|
|
624
|
-
clients,
|
|
625
|
-
yes: flags.yes,
|
|
626
|
-
dryRun: flags["dry-run"]
|
|
627
|
-
};
|
|
628
|
-
if (options.all || clients.length > 0 || options.yes || !process.stdout.isTTY) {
|
|
629
|
-
const report = runNonInteractive(options, url, source, defaultDeps);
|
|
630
|
-
this.process.stdout.write(`${formatReport(report)}\n`);
|
|
631
|
-
this.process.stdout.write(`\n${nextStepsText(report)}\n`);
|
|
632
|
-
if (report.unknownClients.length > 0 || hasFailure(report)) this.process.exitCode = 1;
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
await interactive(this, options.dryRun, url, source);
|
|
636
|
-
}
|
|
637
|
-
function hasFailure(report) {
|
|
638
|
-
return report.outcomes.some((o) => !o.result.ok);
|
|
639
|
-
}
|
|
640
|
-
async function interactive(ctx, dryRun, url, urlSource) {
|
|
641
|
-
const p = await import("@clack/prompts");
|
|
642
|
-
p.intro("Levr MCP setup");
|
|
643
|
-
p.note(`${url}\n(${urlSource})`, "MCP endpoint");
|
|
644
|
-
const detected = defaultDeps.detect();
|
|
645
|
-
const installable = detected.filter((d) => d.available && !d.comingSoon);
|
|
646
|
-
if (installable.length === 0) {
|
|
647
|
-
p.outro("No supported MCP clients found on this machine.");
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
const preselect = new Set(autoSelectIds(detected));
|
|
651
|
-
const selection = await p.multiselect({
|
|
652
|
-
message: "Select clients to set up",
|
|
653
|
-
options: installable.map((d) => ({
|
|
654
|
-
value: d.id,
|
|
655
|
-
label: d.label,
|
|
656
|
-
hint: d.alreadyConfigured ? "already set up" : d.installed ? "detected" : "not detected"
|
|
657
|
-
})),
|
|
658
|
-
initialValues: installable.filter((d) => preselect.has(d.id)).map((d) => d.id),
|
|
659
|
-
required: false
|
|
660
|
-
});
|
|
661
|
-
if (p.isCancel(selection)) {
|
|
662
|
-
p.cancel("Cancelled.");
|
|
663
|
-
ctx.process.exitCode = 1;
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
if (selection.length === 0) {
|
|
667
|
-
p.outro("Nothing selected — bye.");
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
const spin = p.spinner();
|
|
671
|
-
spin.start(dryRun ? "Previewing changes" : "Installing");
|
|
672
|
-
const outcomes = installSelected(selection, url, dryRun, defaultDeps.install);
|
|
673
|
-
spin.stop(dryRun ? "Preview ready" : "Done");
|
|
674
|
-
const report = {
|
|
675
|
-
url,
|
|
676
|
-
urlSource,
|
|
677
|
-
outcomes,
|
|
678
|
-
unknownClients: [],
|
|
679
|
-
comingSoonClients: [],
|
|
680
|
-
dryRun
|
|
681
|
-
};
|
|
682
|
-
p.note(formatReport(report), "Results");
|
|
683
|
-
p.outro(nextStepsText(report));
|
|
684
|
-
if (hasFailure(report)) ctx.process.exitCode = 1;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
//#endregion
|
|
688
|
-
export { mcpAddHandler };
|