@algosuite/vo-mcp 0.2.0-beta.2 → 0.2.0-beta.7
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 +154 -153
- package/bin/vo-mcp +41 -38
- package/dist/autostart-cli.js +79 -2
- package/dist/autostart-cli.js.map +2 -2
- package/dist/cli.js +1389 -875
- package/dist/cli.js.map +4 -4
- package/dist/index.js +354 -108
- package/dist/index.js.map +4 -4
- package/dist/install-cli.js +174 -287
- package/dist/install-cli.js.map +4 -4
- package/dist/login-cli.js +0 -0
- package/dist/login-cli.js.map +1 -1
- package/dist/pair-cli.js +0 -0
- package/dist/pair-cli.js.map +1 -1
- package/dist/runner-cli.js +5937 -875
- package/dist/runner-cli.js.map +4 -4
- package/dist/set-key-cli.js +13 -3
- package/dist/set-key-cli.js.map +2 -2
- package/dist/update-cli.js +62 -0
- package/dist/update-cli.js.map +7 -0
- package/package.json +1 -1
package/dist/install-cli.js
CHANGED
|
@@ -2,15 +2,13 @@
|
|
|
2
2
|
import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);
|
|
3
3
|
|
|
4
4
|
// src/install.ts
|
|
5
|
-
import { homedir as homedir3, platform as
|
|
5
|
+
import { homedir as homedir3, platform as platform3 } from "node:os";
|
|
6
6
|
import { join as join3, dirname as dirname2 } from "node:path";
|
|
7
7
|
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, copyFileSync as copyFileSync2 } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
|
-
// src/cloud/
|
|
11
|
-
import {
|
|
12
|
-
import { randomBytes } from "node:crypto";
|
|
13
|
-
import { spawn } from "node:child_process";
|
|
10
|
+
// src/cloud/pairing.ts
|
|
11
|
+
import { hostname, platform } from "node:os";
|
|
14
12
|
|
|
15
13
|
// src/cloud/credential-store.ts
|
|
16
14
|
import { homedir } from "node:os";
|
|
@@ -125,243 +123,82 @@ function writeStoredCredential(cred, storedAt, env = process.env, keychain = rea
|
|
|
125
123
|
return p;
|
|
126
124
|
}
|
|
127
125
|
|
|
128
|
-
// src/cloud/
|
|
126
|
+
// src/cloud/pairing.ts
|
|
127
|
+
var DEFAULT_CONTROL_PLANE_URL = "https://vo-control-plane-bzjphrajaq-uc.a.run.app";
|
|
129
128
|
var DEFAULT_DASHBOARD_URL = "https://algosuite.ai";
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
function processCapture(rawBody, expectedState, store) {
|
|
133
|
-
let data;
|
|
134
|
-
try {
|
|
135
|
-
data = JSON.parse(rawBody);
|
|
136
|
-
} catch {
|
|
137
|
-
return { ok: false, httpStatus: 400, error: "invalid JSON body" };
|
|
138
|
-
}
|
|
139
|
-
if (!data || typeof data !== "object") return { ok: false, httpStatus: 400, error: "invalid body" };
|
|
140
|
-
if (typeof data.state !== "string" || data.state !== expectedState) {
|
|
141
|
-
return { ok: false, httpStatus: 403, error: "state mismatch (possible CSRF) \u2014 login aborted" };
|
|
142
|
-
}
|
|
143
|
-
const refresh = typeof data.refresh_token === "string" ? data.refresh_token.trim() : "";
|
|
144
|
-
const apiKey = typeof data.api_key === "string" ? data.api_key.trim() : "";
|
|
145
|
-
if (!refresh || !apiKey) {
|
|
146
|
-
return { ok: false, httpStatus: 400, error: "login response missing refresh_token / api_key" };
|
|
147
|
-
}
|
|
148
|
-
const email = typeof data.email === "string" && data.email.trim() ? data.email.trim() : void 0;
|
|
149
|
-
const path = store({ refresh_token: refresh, api_key: apiKey, ...email ? { email } : {} });
|
|
150
|
-
return {
|
|
151
|
-
ok: true,
|
|
152
|
-
httpStatus: 200,
|
|
153
|
-
result: { ...email ? { email } : {}, credentialPath: path },
|
|
154
|
-
captured: { refresh_token: refresh, api_key: apiKey, ...email ? { email } : {} }
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
function captureHtml() {
|
|
158
|
-
return `<!doctype html><html><head><meta charset="utf-8"><title>VO login</title></head>
|
|
159
|
-
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;text-align:center">
|
|
160
|
-
<h2 id="m">Completing sign-in\u2026</h2>
|
|
161
|
-
<script>
|
|
162
|
-
(function(){
|
|
163
|
-
var h=location.hash.replace(/^#/,''), p=new URLSearchParams(h), b={};
|
|
164
|
-
['state','refresh_token','api_key','email'].forEach(function(k){ if(p.get(k)) b[k]=p.get(k); });
|
|
165
|
-
fetch('/capture',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b)})
|
|
166
|
-
.then(function(r){ document.getElementById('m').textContent = r.ok ? 'Sign-in complete \u2014 you can close this tab.' : 'Sign-in failed \u2014 check the terminal.'; })
|
|
167
|
-
.catch(function(){ document.getElementById('m').textContent = 'Sign-in failed \u2014 check the terminal.'; });
|
|
168
|
-
})();
|
|
169
|
-
</script></body></html>`;
|
|
129
|
+
function formatPairingCode(code) {
|
|
130
|
+
return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
|
|
170
131
|
}
|
|
171
|
-
function
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
175
|
-
} else if (platform3 === "darwin") {
|
|
176
|
-
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
177
|
-
} else {
|
|
178
|
-
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
179
|
-
}
|
|
132
|
+
async function readJson(res) {
|
|
133
|
+
const body = await res.json().catch(() => ({}));
|
|
134
|
+
return body && typeof body === "object" ? body : {};
|
|
180
135
|
}
|
|
181
|
-
async function
|
|
182
|
-
const env =
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
server.close();
|
|
196
|
-
if (err) reject(err);
|
|
197
|
-
else resolve2(result);
|
|
198
|
-
};
|
|
199
|
-
const server = createServer((req, res) => {
|
|
200
|
-
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
201
|
-
if (req.method === "GET" && url.pathname === "/callback") {
|
|
202
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
203
|
-
res.end(captureHtml());
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (req.method === "POST" && url.pathname === "/capture") {
|
|
207
|
-
let body = "";
|
|
208
|
-
req.on("data", (chunk) => {
|
|
209
|
-
body += chunk.toString("utf8");
|
|
210
|
-
if (body.length > MAX_BODY_BYTES) req.destroy();
|
|
211
|
-
});
|
|
212
|
-
req.on("end", () => {
|
|
213
|
-
void (async () => {
|
|
214
|
-
if (body.length > MAX_BODY_BYTES) {
|
|
215
|
-
res.writeHead(413, { "content-type": "text/plain" });
|
|
216
|
-
res.end("payload too large");
|
|
217
|
-
finish(new Error("login request body exceeded the size cap"));
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
const writeNow = (cred) => writeStoredCredential(cred, nowIso(), env);
|
|
221
|
-
const outcome = processCapture(body, state, opts.exchange ? () => "pending" : writeNow);
|
|
222
|
-
let result = outcome.result;
|
|
223
|
-
if (outcome.ok && outcome.captured && opts.exchange) {
|
|
224
|
-
const capt = outcome.captured;
|
|
225
|
-
let cred = {
|
|
226
|
-
refresh_token: capt.refresh_token,
|
|
227
|
-
api_key: capt.api_key,
|
|
228
|
-
...capt.email ? { email: capt.email } : {}
|
|
229
|
-
};
|
|
230
|
-
try {
|
|
231
|
-
const voc = await opts.exchange(capt.refresh_token, capt.api_key);
|
|
232
|
-
if (voc && voc.vo_credential) {
|
|
233
|
-
cred = {
|
|
234
|
-
vo_credential: voc.vo_credential,
|
|
235
|
-
vo_credential_expires_at: voc.expires_at,
|
|
236
|
-
...capt.email ? { email: capt.email } : {}
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
} catch {
|
|
240
|
-
}
|
|
241
|
-
const path = writeNow(cred);
|
|
242
|
-
result = { ...capt.email ? { email: capt.email } : {}, credentialPath: path };
|
|
243
|
-
}
|
|
244
|
-
res.writeHead(outcome.httpStatus, { "content-type": "text/html; charset=utf-8" });
|
|
245
|
-
res.end(outcome.ok ? "<h2>VO login complete \u2014 you can close this tab.</h2>" : `<h2>Login failed: ${outcome.error}</h2>`);
|
|
246
|
-
finish(outcome.ok ? null : new Error(outcome.error ?? "login failed"), result);
|
|
247
|
-
})();
|
|
248
|
-
});
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
res.writeHead(404);
|
|
252
|
-
res.end("not found");
|
|
253
|
-
});
|
|
254
|
-
const timer = setTimeout(
|
|
255
|
-
() => finish(new Error(`login timed out after ${timeoutMs}ms \u2014 no sign-in captured`)),
|
|
256
|
-
timeoutMs
|
|
257
|
-
);
|
|
258
|
-
server.on("error", (e) => finish(e));
|
|
259
|
-
server.listen(0, "127.0.0.1", () => {
|
|
260
|
-
const addr = server.address();
|
|
261
|
-
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
262
|
-
if (!port) {
|
|
263
|
-
finish(new Error("failed to bind a loopback port"));
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
const loginUrl = `${dashboardUrl}/cli-login?port=${port}&state=${encodeURIComponent(state)}`;
|
|
267
|
-
log(`[vo-mcp] Opening your browser to sign in:
|
|
268
|
-
${loginUrl}`);
|
|
269
|
-
log("[vo-mcp] If it did not open, paste that URL into your browser. Waiting for sign-in\u2026");
|
|
270
|
-
try {
|
|
271
|
-
openBrowser(loginUrl);
|
|
272
|
-
} catch {
|
|
273
|
-
}
|
|
274
|
-
});
|
|
136
|
+
async function runPairing(deps = {}) {
|
|
137
|
+
const env = deps.env ?? process.env;
|
|
138
|
+
const log = deps.log ?? ((m) => console.error(m));
|
|
139
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
140
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
141
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
142
|
+
const store = deps.store ?? ((cred, iso) => writeStoredCredential(cred, iso, env));
|
|
143
|
+
const controlPlaneUrl = env["VO_CONTROL_PLANE_URL"]?.trim() || DEFAULT_CONTROL_PLANE_URL;
|
|
144
|
+
const dashboardUrl = env["VO_DASHBOARD_URL"]?.trim() || DEFAULT_DASHBOARD_URL;
|
|
145
|
+
const deviceLabel = `${platform()} on ${hostname()}`.slice(0, 120);
|
|
146
|
+
const initRes = await fetchImpl(`${controlPlaneUrl}/api/v1/pair/initiate`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ device_label: deviceLabel })
|
|
275
150
|
});
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
headers: {
|
|
295
|
-
"content-type": "application/x-www-form-urlencoded",
|
|
296
|
-
referer: FIREBASE_TOKEN_REFERER
|
|
297
|
-
},
|
|
298
|
-
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
|
|
299
|
-
});
|
|
300
|
-
const text = await res.text();
|
|
301
|
-
if (res.status < 200 || res.status >= 300) {
|
|
302
|
-
cachedToken = null;
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
const parsed = JSON.parse(text);
|
|
306
|
-
const idToken = typeof parsed.id_token === "string" ? parsed.id_token : "";
|
|
307
|
-
if (!idToken) {
|
|
308
|
-
cachedToken = null;
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
const expiresInSec = Number(parsed.expires_in);
|
|
312
|
-
const ttlMs = Number.isFinite(expiresInSec) && expiresInSec > 0 ? expiresInSec * 1e3 : 36e5;
|
|
313
|
-
cachedToken = idToken;
|
|
314
|
-
expiresAtMs = now() + ttlMs;
|
|
315
|
-
return idToken;
|
|
316
|
-
} catch {
|
|
317
|
-
cachedToken = null;
|
|
318
|
-
return null;
|
|
151
|
+
if (!initRes.ok) throw new Error(`Could not start pairing (server ${initRes.status}).`);
|
|
152
|
+
const init = await readJson(initRes);
|
|
153
|
+
const code = String(init["code"] ?? "");
|
|
154
|
+
const pollToken = String(init["poll_token"] ?? "");
|
|
155
|
+
if (!code || !pollToken) throw new Error("Pairing service returned an incomplete response.");
|
|
156
|
+
const intervalMs = (Number(init["poll_interval_seconds"]) || 5) * 1e3;
|
|
157
|
+
const expiresAtMs = new Date(String(init["expires_at"] ?? "")).getTime();
|
|
158
|
+
log("");
|
|
159
|
+
log(" To connect this runner, open this page in your browser:");
|
|
160
|
+
log(` ${dashboardUrl}/pair`);
|
|
161
|
+
log(" and enter this code:");
|
|
162
|
+
log("");
|
|
163
|
+
log(` ${formatPairingCode(code)}`);
|
|
164
|
+
log("");
|
|
165
|
+
log(" Your Anthropic key never leaves this computer. Waiting for you to authorize\u2026");
|
|
166
|
+
for (; ; ) {
|
|
167
|
+
if (Number.isFinite(expiresAtMs) && now().getTime() >= expiresAtMs) {
|
|
168
|
+
throw new Error("The pairing code expired before it was authorized. Run `vo-mcp pair` again.");
|
|
319
169
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (cachedToken && now() < expiresAtMs - REFRESH_SKEW_MS) return cachedToken;
|
|
325
|
-
if (!inFlight) {
|
|
326
|
-
inFlight = refresh().finally(() => {
|
|
327
|
-
inFlight = null;
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
return inFlight;
|
|
331
|
-
}
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// src/cloud/vo-credential-exchange.ts
|
|
336
|
-
async function exchangeForVoCredential(opts) {
|
|
337
|
-
const base = opts.controlPlaneUrl.replace(/\/+$/, "");
|
|
338
|
-
if (!base) return null;
|
|
339
|
-
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
340
|
-
try {
|
|
341
|
-
const idToken = await createFirebaseRefreshTokenSource({
|
|
342
|
-
refreshToken: opts.refreshToken,
|
|
343
|
-
apiKey: opts.apiKey,
|
|
344
|
-
...opts.fetchFn ? { fetchFn: opts.fetchFn } : {}
|
|
345
|
-
}).getToken();
|
|
346
|
-
if (!idToken) return null;
|
|
347
|
-
const res = await fetchFn(`${base}/api/v1/auth/vo-credential`, {
|
|
348
|
-
method: "POST",
|
|
349
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${idToken}` },
|
|
350
|
-
body: JSON.stringify(opts.label ? { label: opts.label } : {})
|
|
170
|
+
await sleep(intervalMs);
|
|
171
|
+
const pollRes = await fetchImpl(`${controlPlaneUrl}/api/v1/pair/poll`, {
|
|
172
|
+
method: "GET",
|
|
173
|
+
headers: { "x-vo-poll-token": pollToken }
|
|
351
174
|
});
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
175
|
+
if (pollRes.status === 404) {
|
|
176
|
+
throw new Error("The pairing expired. Run `vo-mcp pair` again.");
|
|
177
|
+
}
|
|
178
|
+
if (pollRes.status === 410) {
|
|
179
|
+
throw new Error("This code was already used. Run `vo-mcp pair` again.");
|
|
180
|
+
}
|
|
181
|
+
if (!pollRes.ok) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const body = await readJson(pollRes);
|
|
185
|
+
if (body["status"] === "pending") continue;
|
|
186
|
+
if (body["status"] === "authorized" && typeof body["vo_credential"] === "string") {
|
|
187
|
+
const credentialPath2 = store(
|
|
188
|
+
{
|
|
189
|
+
vo_credential: body["vo_credential"],
|
|
190
|
+
...typeof body["expires_at"] === "string" ? { vo_credential_expires_at: body["expires_at"] } : {}
|
|
191
|
+
},
|
|
192
|
+
now().toISOString()
|
|
193
|
+
);
|
|
194
|
+
return { credentialPath: credentialPath2, expires_at: String(body["expires_at"] ?? "") };
|
|
195
|
+
}
|
|
196
|
+
throw new Error("Unexpected response from the pairing service.");
|
|
360
197
|
}
|
|
361
198
|
}
|
|
362
199
|
|
|
363
200
|
// src/autostart.ts
|
|
364
|
-
import { homedir as homedir2, platform } from "node:os";
|
|
201
|
+
import { homedir as homedir2, platform as platform2 } from "node:os";
|
|
365
202
|
import { join as join2 } from "node:path";
|
|
366
203
|
import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync2, unlinkSync, copyFileSync } from "node:fs";
|
|
367
204
|
function resolveRunnerCommand(override) {
|
|
@@ -446,44 +283,98 @@ ${args.map((a) => ` <string>${a}</string>`).join("\n")}
|
|
|
446
283
|
log(` Run: launchctl load "${plistPath}"`);
|
|
447
284
|
}
|
|
448
285
|
}
|
|
286
|
+
async function installLinuxAutostart(runnerCommand, log, env) {
|
|
287
|
+
const home = env["HOME"]?.trim() || homedir2();
|
|
288
|
+
const unitDir = join2(home, ".config", "systemd", "user");
|
|
289
|
+
mkdirSync2(unitDir, { recursive: true });
|
|
290
|
+
const unitPath = join2(unitDir, "vo-runner.service");
|
|
291
|
+
if (existsSync2(unitPath)) {
|
|
292
|
+
const existing = readFileSync2(unitPath, "utf8");
|
|
293
|
+
if (existing.includes(runnerCommand) || existing.includes("vo-mcp runner")) {
|
|
294
|
+
log(`\u2713 Auto-start is already configured (systemd user unit)`);
|
|
295
|
+
log(` Path: ${unitPath}`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const backupPath = `${unitPath}.backup-${Date.now()}`;
|
|
299
|
+
copyFileSync(unitPath, backupPath);
|
|
300
|
+
log(` Backed up existing unit to: ${backupPath}`);
|
|
301
|
+
}
|
|
302
|
+
const logFile = join2(home, ".claude", "vo-runner.log");
|
|
303
|
+
const errFile = join2(home, ".claude", "vo-runner-error.log");
|
|
304
|
+
mkdirSync2(join2(home, ".claude"), { recursive: true });
|
|
305
|
+
const unit = `[Unit]
|
|
306
|
+
Description=VO Code Runner (vo-mcp)
|
|
307
|
+
After=network-online.target
|
|
308
|
+
Wants=network-online.target
|
|
309
|
+
|
|
310
|
+
[Service]
|
|
311
|
+
Type=simple
|
|
312
|
+
ExecStart=/bin/sh -lc '${runnerCommand}'
|
|
313
|
+
Restart=on-failure
|
|
314
|
+
RestartSec=10
|
|
315
|
+
StandardOutput=append:${logFile}
|
|
316
|
+
StandardError=append:${errFile}
|
|
317
|
+
|
|
318
|
+
[Install]
|
|
319
|
+
WantedBy=default.target
|
|
320
|
+
`;
|
|
321
|
+
writeFileSync2(unitPath, unit, "utf8");
|
|
322
|
+
log(`\u2713 Installed systemd user unit`);
|
|
323
|
+
log(` Path: ${unitPath}`);
|
|
324
|
+
if (process.env["VITEST"]) {
|
|
325
|
+
log(` (test mode: skipping systemctl enable)`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const { execSync } = await import("node:child_process");
|
|
330
|
+
execSync("systemctl --user daemon-reload", { stdio: "ignore" });
|
|
331
|
+
execSync("systemctl --user enable --now vo-runner.service", { stdio: "ignore" });
|
|
332
|
+
log(`\u2713 Enabled + started vo-runner.service (starts at login)`);
|
|
333
|
+
log(` Logs: ${logFile}`);
|
|
334
|
+
} catch {
|
|
335
|
+
log(`\u26A0 Could not enable via systemctl (enable it manually):`);
|
|
336
|
+
log(` systemctl --user daemon-reload && systemctl --user enable --now vo-runner.service`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
449
339
|
async function installAutostart(opts = {}) {
|
|
450
340
|
const log = opts.log ?? ((m) => console.error(m));
|
|
451
341
|
const env = opts.env ?? process.env;
|
|
452
342
|
const runnerCommand = resolveRunnerCommand(opts.runnerCommand);
|
|
453
|
-
const plat =
|
|
343
|
+
const plat = platform2();
|
|
454
344
|
if (plat === "win32") {
|
|
455
345
|
installWindowsAutostart(runnerCommand, log, env);
|
|
456
346
|
} else if (plat === "darwin") {
|
|
457
347
|
await installMacAutostart(runnerCommand, log);
|
|
348
|
+
} else if (plat === "linux") {
|
|
349
|
+
await installLinuxAutostart(runnerCommand, log, env);
|
|
458
350
|
} else {
|
|
459
351
|
log(`\u2717 Auto-start is not supported on platform: ${plat}`);
|
|
460
|
-
log(` Supported platforms: win32 (Windows), darwin (macOS)`);
|
|
352
|
+
log(` Supported platforms: win32 (Windows), darwin (macOS), linux (Linux)`);
|
|
461
353
|
}
|
|
462
354
|
}
|
|
463
355
|
|
|
464
356
|
// src/install.ts
|
|
465
|
-
var
|
|
466
|
-
function
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
357
|
+
var DEFAULT_CONTROL_PLANE_URL2 = "https://vo-control-plane-bzjphrajaq-uc.a.run.app";
|
|
358
|
+
function resolveCodeConfigPath(home) {
|
|
359
|
+
return join3(home, ".claude.json");
|
|
360
|
+
}
|
|
361
|
+
function resolveDesktopConfigPath(home, plat, appData) {
|
|
470
362
|
if (plat === "win32") {
|
|
471
|
-
|
|
472
|
-
return join3(appData, "Claude", "claude_desktop_config.json");
|
|
363
|
+
return join3(appData ?? join3(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
473
364
|
}
|
|
474
365
|
if (plat === "darwin") {
|
|
475
|
-
return join3(
|
|
366
|
+
return join3(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
476
367
|
}
|
|
477
|
-
return join3(
|
|
368
|
+
return join3(home, ".config", "Claude", "claude_desktop_config.json");
|
|
478
369
|
}
|
|
479
370
|
function readClaudeConfig(path) {
|
|
480
371
|
try {
|
|
481
|
-
if (!existsSync3(path)) return {
|
|
372
|
+
if (!existsSync3(path)) return {};
|
|
482
373
|
const raw = readFileSync3(path, "utf8");
|
|
483
374
|
const parsed = JSON.parse(raw);
|
|
484
|
-
return
|
|
375
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
485
376
|
} catch {
|
|
486
|
-
return {
|
|
377
|
+
return {};
|
|
487
378
|
}
|
|
488
379
|
}
|
|
489
380
|
function writeClaudeConfig(path, config) {
|
|
@@ -492,62 +383,58 @@ function writeClaudeConfig(path, config) {
|
|
|
492
383
|
`, "utf8");
|
|
493
384
|
}
|
|
494
385
|
function resolveVoMcpCliPath() {
|
|
495
|
-
|
|
496
|
-
if (scriptPath.includes("install-cli.js") || scriptPath.includes("install-cli")) {
|
|
497
|
-
return scriptPath.replace(/install-cli\.js$/, "cli.js").replace(/install-cli$/, "cli.js");
|
|
498
|
-
}
|
|
499
|
-
return resolve(dirname2(scriptPath), "cli.js");
|
|
386
|
+
return join3(dirname2(fileURLToPath(import.meta.url)), "cli.js");
|
|
500
387
|
}
|
|
501
|
-
function
|
|
502
|
-
const configPath = resolveClaudeConfigPath();
|
|
388
|
+
function installMcpConfigAt(configPath, cliPath, controlPlaneUrl, log, label) {
|
|
503
389
|
const existing = readClaudeConfig(configPath);
|
|
504
|
-
const
|
|
505
|
-
const voEntry =
|
|
506
|
-
if (voEntry
|
|
507
|
-
log(
|
|
390
|
+
const mcpServers = existing["mcpServers"] && typeof existing["mcpServers"] === "object" ? existing["mcpServers"] : {};
|
|
391
|
+
const voEntry = mcpServers["vo"] ?? mcpServers["vo-mcp"];
|
|
392
|
+
if (voEntry?.args?.some((a) => a.includes(cliPath))) {
|
|
393
|
+
log(` ${label} already current: ${configPath}`);
|
|
508
394
|
return;
|
|
509
395
|
}
|
|
510
396
|
if (existsSync3(configPath)) {
|
|
511
397
|
const backupPath = `${configPath}.backup-${Date.now()}`;
|
|
512
398
|
copyFileSync2(configPath, backupPath);
|
|
513
|
-
log(` Backed up
|
|
399
|
+
log(` Backed up ${label} config \u2192 ${backupPath}`);
|
|
514
400
|
}
|
|
515
|
-
const controlPlaneUrl = env["VO_CONTROL_PLANE_URL"]?.trim() || DEFAULT_CONTROL_PLANE_URL;
|
|
516
401
|
const merged = {
|
|
402
|
+
...existing,
|
|
517
403
|
mcpServers: {
|
|
518
|
-
...
|
|
404
|
+
...mcpServers,
|
|
519
405
|
"vo-mcp": {
|
|
520
406
|
command: "node",
|
|
521
407
|
args: [cliPath],
|
|
522
|
-
env
|
|
523
|
-
|
|
524
|
-
}
|
|
408
|
+
// Preserve any existing env the user added (e.g. model API keys) — only
|
|
409
|
+
// ensure VO_CONTROL_PLANE_URL is present. NEVER drop the user's env keys.
|
|
410
|
+
env: { VO_CONTROL_PLANE_URL: controlPlaneUrl, ...voEntry?.env ?? {} }
|
|
525
411
|
}
|
|
526
412
|
}
|
|
527
413
|
};
|
|
528
414
|
writeClaudeConfig(configPath, merged);
|
|
529
|
-
log(`\u2713 Wrote vo-mcp
|
|
415
|
+
log(`\u2713 Wrote vo-mcp to ${label}: ${configPath}`);
|
|
530
416
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
417
|
+
function installMcpConfig(log, env) {
|
|
418
|
+
const home = env["HOME"]?.trim() || env["USERPROFILE"]?.trim() || homedir3();
|
|
419
|
+
const appData = env["APPDATA"]?.trim();
|
|
420
|
+
const plat = platform3();
|
|
421
|
+
const cliPath = resolveVoMcpCliPath();
|
|
422
|
+
const controlPlaneUrl = env["VO_CONTROL_PLANE_URL"]?.trim() || DEFAULT_CONTROL_PLANE_URL2;
|
|
423
|
+
installMcpConfigAt(resolveCodeConfigPath(home), cliPath, controlPlaneUrl, log, "Claude Code CLI");
|
|
424
|
+
installMcpConfigAt(resolveDesktopConfigPath(home, plat, appData), cliPath, controlPlaneUrl, log, "Claude Desktop");
|
|
425
|
+
}
|
|
426
|
+
async function runPairFlow(log, env) {
|
|
427
|
+
log("\n\u2501\u2501\u2501 Step 2: Link your VO account (device code) \u2501\u2501\u2501");
|
|
428
|
+
log("A short code appears below \u2014 open the URL it prints and enter the code.");
|
|
429
|
+
log("Your raw token never persists; a scoped credential is stored in your OS keychain.\n");
|
|
544
430
|
try {
|
|
545
|
-
const result = await
|
|
546
|
-
log(
|
|
431
|
+
const result = await runPairing({ env, log });
|
|
432
|
+
log(`
|
|
433
|
+
\u2713 Paired \u2014 scoped credential stored at: ${result.credentialPath}`);
|
|
547
434
|
} catch (err) {
|
|
548
|
-
log(
|
|
549
|
-
|
|
550
|
-
|
|
435
|
+
log(`
|
|
436
|
+
\u26A0 Pairing didn't complete: ${err instanceof Error ? err.message : String(err)}`);
|
|
437
|
+
log(" No problem \u2014 the rest will finish; pair anytime with: vo-mcp pair");
|
|
551
438
|
}
|
|
552
439
|
}
|
|
553
440
|
function printNextSteps(log, autostartInstalled) {
|
|
@@ -582,14 +469,14 @@ async function install(opts = {}) {
|
|
|
582
469
|
log("\u2501\u2501\u2501 Step 1: Configure Claude Desktop / Claude Code \u2501\u2501\u2501");
|
|
583
470
|
installMcpConfig(log, env);
|
|
584
471
|
if (!opts.skipLogin) {
|
|
585
|
-
await
|
|
472
|
+
await runPairFlow(log, env);
|
|
586
473
|
} else {
|
|
587
|
-
log("\n(
|
|
474
|
+
log("\n(Pairing skipped \u2014 run `vo-mcp pair` when ready.)");
|
|
588
475
|
}
|
|
589
476
|
let autostartInstalled = false;
|
|
590
477
|
if (!opts.skipAutostart) {
|
|
591
|
-
const plat =
|
|
592
|
-
if (plat === "win32" || plat === "darwin") {
|
|
478
|
+
const plat = platform3();
|
|
479
|
+
if (plat === "win32" || plat === "darwin" || plat === "linux") {
|
|
593
480
|
log("\n\u2501\u2501\u2501 Step 3: Set up auto-start \u2501\u2501\u2501");
|
|
594
481
|
log("Would you like the runner daemon to start automatically at login?");
|
|
595
482
|
log("(You can skip this and set it up later with: vo-mcp runner --install-autostart)\n");
|