@vellumai/cli 0.8.6 → 0.8.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/bun.lock +8 -0
- package/knip.json +5 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +21 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/commands/client.ts +413 -2
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +89 -17
- package/src/components/DefaultMainScreen.tsx +16 -1
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-config.ts +3 -3
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +2 -5
- package/src/lib/guardian-token.ts +12 -5
- package/src/lib/hatch-local.ts +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
package/src/commands/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import { hostname } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -23,10 +24,27 @@ import {
|
|
|
23
24
|
WEB_INTERFACE_ID,
|
|
24
25
|
getClientRegistrationHeaders,
|
|
25
26
|
} from "../lib/client-identity";
|
|
27
|
+
import {
|
|
28
|
+
getLockfileData,
|
|
29
|
+
upsertLockfileAssistant,
|
|
30
|
+
replacePlatformAssistants,
|
|
31
|
+
runHatch,
|
|
32
|
+
runRetire,
|
|
33
|
+
getGuardianAccessToken,
|
|
34
|
+
parseGatewayUrl,
|
|
35
|
+
readAllowedGatewayPorts,
|
|
36
|
+
isLoopbackAddr,
|
|
37
|
+
resolveDevCliInvocation,
|
|
38
|
+
resolveLockfilePaths,
|
|
39
|
+
resolveConfigDir,
|
|
40
|
+
type CliInvocation,
|
|
41
|
+
} from "@vellumai/local-mode";
|
|
26
42
|
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
27
43
|
import {
|
|
28
44
|
fetchOrganizationId,
|
|
29
45
|
fetchPlatformAssistants,
|
|
46
|
+
getPlatformUrl,
|
|
47
|
+
getWebUrl,
|
|
30
48
|
readPlatformToken,
|
|
31
49
|
} from "../lib/platform-client";
|
|
32
50
|
import { tuiLog } from "../lib/tui-log";
|
|
@@ -311,7 +329,282 @@ function findWebDistDir(): string | null {
|
|
|
311
329
|
return null;
|
|
312
330
|
}
|
|
313
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Locate the apps/web source directory for running the Vite dev server.
|
|
334
|
+
* Only works from a source checkout (not npm-installed).
|
|
335
|
+
*/
|
|
336
|
+
function findWebSourceDir(): string | null {
|
|
337
|
+
let dir = import.meta.dir;
|
|
338
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
339
|
+
const candidate = path.join(dir, "apps", "web", "vite.config.ts");
|
|
340
|
+
if (existsSync(candidate)) {
|
|
341
|
+
return path.dirname(candidate);
|
|
342
|
+
}
|
|
343
|
+
const parent = path.dirname(dir);
|
|
344
|
+
if (parent === dir) break;
|
|
345
|
+
dir = parent;
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const LOCKFILE_PATTERN = /^(?:\/assistant)?\/__local\/lockfile$/;
|
|
351
|
+
const HATCH_PATTERN = /^(?:\/assistant)?\/__local\/hatch$/;
|
|
352
|
+
const RETIRE_PATTERN = /^(?:\/assistant)?\/__local\/retire$/;
|
|
353
|
+
const GUARDIAN_TOKEN_PATTERN =
|
|
354
|
+
/^(?:\/assistant)?\/__local\/guardian-token\/([^/]+)$/;
|
|
355
|
+
|
|
356
|
+
function getEnvRecord(): Record<string, string> {
|
|
357
|
+
const result: Record<string, string> = {};
|
|
358
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
359
|
+
if (v !== undefined) result[k] = v;
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const _localEnv = getEnvRecord();
|
|
365
|
+
const _lockfilePaths = resolveLockfilePaths(_localEnv);
|
|
366
|
+
const _configDir = resolveConfigDir(_localEnv);
|
|
367
|
+
const _baseDir = getBaseDir();
|
|
368
|
+
|
|
369
|
+
async function handleLocalEndpoints(
|
|
370
|
+
req: Request,
|
|
371
|
+
url: URL,
|
|
372
|
+
server: { requestIP(req: Request): { address: string } | null },
|
|
373
|
+
): Promise<Response | null> {
|
|
374
|
+
const { pathname } = url;
|
|
375
|
+
const lockfilePaths = _lockfilePaths;
|
|
376
|
+
const configDir = _configDir;
|
|
377
|
+
|
|
378
|
+
// Check if this is a __local or __gateway route before enforcing loopback.
|
|
379
|
+
const isLocalRoute =
|
|
380
|
+
LOCKFILE_PATTERN.test(pathname) ||
|
|
381
|
+
HATCH_PATTERN.test(pathname) ||
|
|
382
|
+
RETIRE_PATTERN.test(pathname) ||
|
|
383
|
+
GUARDIAN_TOKEN_PATTERN.test(pathname) ||
|
|
384
|
+
parseGatewayUrl(pathname).match;
|
|
385
|
+
|
|
386
|
+
if (!isLocalRoute) return null;
|
|
387
|
+
|
|
388
|
+
// All __local and __gateway endpoints are restricted to loopback clients.
|
|
389
|
+
const peer = server.requestIP(req)?.address ?? "";
|
|
390
|
+
if (!isLoopbackAddr(peer)) {
|
|
391
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Lockfile
|
|
395
|
+
if (LOCKFILE_PATTERN.test(pathname)) {
|
|
396
|
+
if (req.method === "GET") {
|
|
397
|
+
const result = getLockfileData(lockfilePaths);
|
|
398
|
+
if (result.ok) {
|
|
399
|
+
return Response.json(result.data);
|
|
400
|
+
}
|
|
401
|
+
return new Response(null, { status: result.status });
|
|
402
|
+
}
|
|
403
|
+
if (req.method === "POST") {
|
|
404
|
+
let body: Record<string, unknown>;
|
|
405
|
+
try {
|
|
406
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
407
|
+
} catch {
|
|
408
|
+
return Response.json(
|
|
409
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
410
|
+
{ status: 400 },
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
let result;
|
|
414
|
+
if (body.syncPlatform && Array.isArray(body.platformAssistants)) {
|
|
415
|
+
result = replacePlatformAssistants(
|
|
416
|
+
lockfilePaths,
|
|
417
|
+
body.platformAssistants as Array<Record<string, unknown>>,
|
|
418
|
+
);
|
|
419
|
+
} else {
|
|
420
|
+
result = upsertLockfileAssistant(
|
|
421
|
+
lockfilePaths,
|
|
422
|
+
body.assistant as Record<string, unknown>,
|
|
423
|
+
body.activeAssistant as string | undefined,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return Response.json(result, { status: result.ok ? 200 : result.status });
|
|
427
|
+
}
|
|
428
|
+
return new Response(null, { status: 405 });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Hatch
|
|
432
|
+
if (HATCH_PATTERN.test(pathname)) {
|
|
433
|
+
if (req.method !== "POST") return new Response(null, { status: 405 });
|
|
434
|
+
|
|
435
|
+
let species = "vellum";
|
|
436
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
437
|
+
if (contentType.includes("json")) {
|
|
438
|
+
try {
|
|
439
|
+
const body = (await req.json()) as { species?: string };
|
|
440
|
+
if (body.species) species = body.species;
|
|
441
|
+
} catch {
|
|
442
|
+
return Response.json(
|
|
443
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
444
|
+
{ status: 400 },
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let invocation: CliInvocation;
|
|
450
|
+
try {
|
|
451
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
return Response.json(
|
|
454
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
455
|
+
{ status: 500 },
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const result = await runHatch(invocation, species);
|
|
460
|
+
if (result.ok) {
|
|
461
|
+
return Response.json({ ok: true, assistantId: result.assistantId });
|
|
462
|
+
}
|
|
463
|
+
return Response.json(
|
|
464
|
+
{ ok: false, error: result.error },
|
|
465
|
+
{ status: result.status },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Retire
|
|
470
|
+
if (RETIRE_PATTERN.test(pathname)) {
|
|
471
|
+
if (req.method !== "POST") return new Response(null, { status: 405 });
|
|
472
|
+
|
|
473
|
+
let assistantId: string | undefined;
|
|
474
|
+
try {
|
|
475
|
+
const body = (await req.json()) as { assistantId?: string };
|
|
476
|
+
assistantId = body.assistantId;
|
|
477
|
+
} catch {
|
|
478
|
+
return Response.json(
|
|
479
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
480
|
+
{ status: 400 },
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!assistantId) {
|
|
485
|
+
return Response.json(
|
|
486
|
+
{ ok: false, error: "Missing assistantId" },
|
|
487
|
+
{ status: 400 },
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let invocation: CliInvocation;
|
|
492
|
+
try {
|
|
493
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
return Response.json(
|
|
496
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
497
|
+
{ status: 500 },
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const result = await runRetire(invocation, assistantId);
|
|
502
|
+
if (result.ok) {
|
|
503
|
+
return Response.json({ ok: true });
|
|
504
|
+
}
|
|
505
|
+
return Response.json(
|
|
506
|
+
{ ok: false, error: result.error },
|
|
507
|
+
{ status: result.status },
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Guardian token
|
|
512
|
+
const guardianMatch = pathname.match(GUARDIAN_TOKEN_PATTERN);
|
|
513
|
+
if (guardianMatch) {
|
|
514
|
+
if (req.method !== "GET") return new Response(null, { status: 405 });
|
|
515
|
+
|
|
516
|
+
const assistantId = decodeURIComponent(guardianMatch[1]!);
|
|
517
|
+
|
|
518
|
+
let invocation: CliInvocation;
|
|
519
|
+
try {
|
|
520
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
return Response.json(
|
|
523
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
524
|
+
{ status: 500 },
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const result = await getGuardianAccessToken(
|
|
529
|
+
assistantId,
|
|
530
|
+
configDir,
|
|
531
|
+
invocation,
|
|
532
|
+
true,
|
|
533
|
+
_localEnv,
|
|
534
|
+
);
|
|
535
|
+
if (result.ok) {
|
|
536
|
+
return Response.json({ accessToken: result.accessToken });
|
|
537
|
+
}
|
|
538
|
+
return Response.json({ error: result.error }, { status: result.status });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Gateway proxy
|
|
542
|
+
const gatewayResult = parseGatewayUrl(pathname);
|
|
543
|
+
if (gatewayResult.match) {
|
|
544
|
+
if (!gatewayResult.valid) {
|
|
545
|
+
return new Response("Port must be between 1024 and 65535", {
|
|
546
|
+
status: 400,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
const { target: gatewayTarget } = gatewayResult;
|
|
550
|
+
const allowedPorts = readAllowedGatewayPorts(lockfilePaths);
|
|
551
|
+
if (!allowedPorts.has(gatewayTarget.port)) {
|
|
552
|
+
return new Response("Gateway port is not active in lockfile", {
|
|
553
|
+
status: 403,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
const targetUrl = `http://127.0.0.1:${gatewayTarget.port}${gatewayTarget.path}${url.search}`;
|
|
557
|
+
const headers = new Headers(req.headers);
|
|
558
|
+
headers.set("host", `127.0.0.1:${gatewayTarget.port}`);
|
|
559
|
+
|
|
560
|
+
try {
|
|
561
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
562
|
+
const proxyRes = await fetch(targetUrl, {
|
|
563
|
+
method: req.method,
|
|
564
|
+
headers,
|
|
565
|
+
body: hasBody ? req.body : undefined,
|
|
566
|
+
redirect: "manual",
|
|
567
|
+
});
|
|
568
|
+
const resHeaders = new Headers(proxyRes.headers);
|
|
569
|
+
resHeaders.delete("transfer-encoding");
|
|
570
|
+
return new Response(proxyRes.body, {
|
|
571
|
+
status: proxyRes.status,
|
|
572
|
+
statusText: proxyRes.statusText,
|
|
573
|
+
headers: resHeaders,
|
|
574
|
+
});
|
|
575
|
+
} catch {
|
|
576
|
+
return new Response("Gateway proxy error", { status: 502 });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function getBaseDir(): string {
|
|
584
|
+
let dir = import.meta.dir;
|
|
585
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
586
|
+
if (existsSync(path.join(dir, "cli", "src", "index.ts"))) {
|
|
587
|
+
return dir;
|
|
588
|
+
}
|
|
589
|
+
const pkgPath = path.join(dir, "package.json");
|
|
590
|
+
if (existsSync(pkgPath)) {
|
|
591
|
+
return dir;
|
|
592
|
+
}
|
|
593
|
+
const parent = path.dirname(dir);
|
|
594
|
+
if (parent === dir) break;
|
|
595
|
+
dir = parent;
|
|
596
|
+
}
|
|
597
|
+
return path.resolve(import.meta.dir, "..", "..", "..");
|
|
598
|
+
}
|
|
599
|
+
|
|
314
600
|
async function runWebInterface(): Promise<void> {
|
|
601
|
+
// Prefer Vite dev server in source checkouts for full local-mode support
|
|
602
|
+
// (HMR, __local endpoints, gateway proxy).
|
|
603
|
+
const webSourceDir = findWebSourceDir();
|
|
604
|
+
if (webSourceDir) {
|
|
605
|
+
return runViteDevServer(webSourceDir);
|
|
606
|
+
}
|
|
607
|
+
|
|
315
608
|
const distDir = findWebDistDir();
|
|
316
609
|
if (!distDir) {
|
|
317
610
|
console.error(
|
|
@@ -323,7 +616,14 @@ async function runWebInterface(): Promise<void> {
|
|
|
323
616
|
process.exit(1);
|
|
324
617
|
}
|
|
325
618
|
|
|
326
|
-
const
|
|
619
|
+
const rawIndexHtml = await Bun.file(path.join(distDir, "index.html")).text();
|
|
620
|
+
const platformUrl = getPlatformUrl();
|
|
621
|
+
const webUrl = getWebUrl();
|
|
622
|
+
const configJson = JSON.stringify({ webUrl, platformUrl });
|
|
623
|
+
const indexHtml = rawIndexHtml.replace(
|
|
624
|
+
"</head>",
|
|
625
|
+
`<script>window.__VELLUM_CONFIG__=${configJson}</script></head>`,
|
|
626
|
+
);
|
|
327
627
|
|
|
328
628
|
const server = Bun.serve({
|
|
329
629
|
port: 3000,
|
|
@@ -332,10 +632,82 @@ async function runWebInterface(): Promise<void> {
|
|
|
332
632
|
const url = new URL(req.url);
|
|
333
633
|
const { pathname } = url;
|
|
334
634
|
|
|
335
|
-
if (pathname === "/") {
|
|
635
|
+
if (pathname === "/" || pathname === "/assistant") {
|
|
336
636
|
return Response.redirect(SPA_BASE, 302);
|
|
337
637
|
}
|
|
338
638
|
|
|
639
|
+
// Loopback auth: the platform redirects here after login with
|
|
640
|
+
// ?state=...&session_token=... — forward into the SPA.
|
|
641
|
+
if (pathname === "/callback") {
|
|
642
|
+
return Response.redirect(
|
|
643
|
+
`/account/platform-callback${url.search}`,
|
|
644
|
+
302,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Expose environment config to the SPA.
|
|
649
|
+
if (pathname === "/assistant/__config" || pathname === "/__config") {
|
|
650
|
+
return new Response(configJson, {
|
|
651
|
+
headers: { "Content-Type": "application/json" },
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// __local endpoints for local-mode (lockfile, hatch, retire, guardian-token, gateway-proxy).
|
|
656
|
+
const localResponse = await handleLocalEndpoints(req, url, server);
|
|
657
|
+
if (localResponse) return localResponse;
|
|
658
|
+
|
|
659
|
+
// Reverse-proxy platform API requests.
|
|
660
|
+
if (
|
|
661
|
+
pathname.startsWith("/v1/") ||
|
|
662
|
+
pathname.startsWith("/_allauth/") ||
|
|
663
|
+
pathname.startsWith("/accounts/")
|
|
664
|
+
) {
|
|
665
|
+
const target = new URL(pathname + url.search, platformUrl);
|
|
666
|
+
const headers = new Headers(req.headers);
|
|
667
|
+
headers.set("Host", new URL(platformUrl).host);
|
|
668
|
+
headers.delete("Origin");
|
|
669
|
+
headers.delete("Referer");
|
|
670
|
+
|
|
671
|
+
// Forward the session token — the loopback flow stores it in
|
|
672
|
+
// the browser cookie jar for localhost, but the platform backend
|
|
673
|
+
// expects it on its own domain. Set both the Cookie (for Django
|
|
674
|
+
// session middleware / allauth) and X-Session-Token (for DRF
|
|
675
|
+
// views that accept header-based auth).
|
|
676
|
+
const sessionToken = /sessionid=([^;]+)/.exec(
|
|
677
|
+
req.headers.get("Cookie") ?? "",
|
|
678
|
+
)?.[1];
|
|
679
|
+
if (sessionToken) {
|
|
680
|
+
headers.set(
|
|
681
|
+
"Cookie",
|
|
682
|
+
`sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
|
|
683
|
+
);
|
|
684
|
+
headers.set("X-Session-Token", sessionToken);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
689
|
+
const body = hasBody ? await req.arrayBuffer() : undefined;
|
|
690
|
+
const proxyRes = await fetch(target.toString(), {
|
|
691
|
+
method: req.method,
|
|
692
|
+
headers,
|
|
693
|
+
body,
|
|
694
|
+
redirect: "manual",
|
|
695
|
+
});
|
|
696
|
+
const resHeaders = new Headers(proxyRes.headers);
|
|
697
|
+
resHeaders.delete("transfer-encoding");
|
|
698
|
+
return new Response(proxyRes.body, {
|
|
699
|
+
status: proxyRes.status,
|
|
700
|
+
statusText: proxyRes.statusText,
|
|
701
|
+
headers: resHeaders,
|
|
702
|
+
});
|
|
703
|
+
} catch (err) {
|
|
704
|
+
return new Response(
|
|
705
|
+
JSON.stringify({ error: `Platform proxy error: ${err}` }),
|
|
706
|
+
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
339
711
|
if (pathname.startsWith(SPA_BASE)) {
|
|
340
712
|
const relPath = pathname.slice(SPA_BASE.length);
|
|
341
713
|
if (relPath) {
|
|
@@ -350,6 +722,13 @@ async function runWebInterface(): Promise<void> {
|
|
|
350
722
|
});
|
|
351
723
|
}
|
|
352
724
|
|
|
725
|
+
// SPA fallback for /account/* routes (login, callback, etc.)
|
|
726
|
+
if (pathname.startsWith("/account/")) {
|
|
727
|
+
return new Response(indexHtml, {
|
|
728
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
353
732
|
return new Response("Not Found", { status: 404 });
|
|
354
733
|
},
|
|
355
734
|
});
|
|
@@ -368,6 +747,38 @@ async function runWebInterface(): Promise<void> {
|
|
|
368
747
|
await new Promise(() => {});
|
|
369
748
|
}
|
|
370
749
|
|
|
750
|
+
async function runViteDevServer(webSourceDir: string): Promise<void> {
|
|
751
|
+
const platformUrl = getPlatformUrl();
|
|
752
|
+
|
|
753
|
+
const child = spawn("bun", ["run", "dev"], {
|
|
754
|
+
cwd: webSourceDir,
|
|
755
|
+
stdio: "inherit",
|
|
756
|
+
env: {
|
|
757
|
+
...process.env,
|
|
758
|
+
VITE_PLATFORM_MODE: "false",
|
|
759
|
+
API_PROXY_TARGET: platformUrl,
|
|
760
|
+
VELLUM_WEB_URL: getWebUrl(),
|
|
761
|
+
VELLUM_PLATFORM_URL: platformUrl,
|
|
762
|
+
PORT: "3000",
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const shutdown = (): void => {
|
|
767
|
+
child.kill();
|
|
768
|
+
process.exit(0);
|
|
769
|
+
};
|
|
770
|
+
process.on("SIGINT", shutdown);
|
|
771
|
+
process.on("SIGTERM", shutdown);
|
|
772
|
+
|
|
773
|
+
await new Promise<void>((_, reject) => {
|
|
774
|
+
child.on("exit", (code) => {
|
|
775
|
+
if (code !== 0) {
|
|
776
|
+
reject(new Error(`Vite dev server exited with code ${code}`));
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
371
782
|
export async function client(): Promise<void> {
|
|
372
783
|
const {
|
|
373
784
|
runtimeUrl,
|
package/src/commands/env.ts
CHANGED
package/src/commands/flags.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { AssistantClient } from "../lib/assistant-client.js";
|
|
2
|
+
import {
|
|
3
|
+
formatAssistantLookupError,
|
|
4
|
+
lookupAssistantByIdentifier,
|
|
5
|
+
} from "../lib/assistant-config.js";
|
|
2
6
|
|
|
3
7
|
type FeatureFlagEntry = {
|
|
4
8
|
key: string;
|
|
@@ -17,7 +21,12 @@ function pad(s: string, w: number): string {
|
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
function printFlagTable(flags: FeatureFlagEntry[]): void {
|
|
20
|
-
const headers = {
|
|
24
|
+
const headers = {
|
|
25
|
+
key: "KEY",
|
|
26
|
+
enabled: "ENABLED",
|
|
27
|
+
default: "DEFAULT",
|
|
28
|
+
label: "LABEL",
|
|
29
|
+
};
|
|
21
30
|
|
|
22
31
|
const rows = flags
|
|
23
32
|
.slice()
|
|
@@ -55,7 +64,9 @@ function printHelp(): void {
|
|
|
55
64
|
console.log("Usage: vellum flags [subcommand] [options]");
|
|
56
65
|
console.log("");
|
|
57
66
|
console.log("Show and toggle feature flags for the active assistant.");
|
|
58
|
-
console.log(
|
|
67
|
+
console.log(
|
|
68
|
+
"Reads from the gateway's merged flag state (persisted overrides > remote > defaults).",
|
|
69
|
+
);
|
|
59
70
|
console.log("");
|
|
60
71
|
console.log("Subcommands:");
|
|
61
72
|
console.log(" (none) List all feature flags in a table");
|
|
@@ -63,20 +74,53 @@ function printHelp(): void {
|
|
|
63
74
|
console.log(" set <key> <bool> Set a flag override to true or false");
|
|
64
75
|
console.log("");
|
|
65
76
|
console.log("Options:");
|
|
77
|
+
console.log(
|
|
78
|
+
" --assistant <name> Target a specific assistant (display name or ID)",
|
|
79
|
+
);
|
|
80
|
+
console.log(
|
|
81
|
+
" instead of the active one. Useful for scripted",
|
|
82
|
+
);
|
|
83
|
+
console.log(
|
|
84
|
+
" flows like eval harnesses that must not mutate",
|
|
85
|
+
);
|
|
86
|
+
console.log(" the user's active-assistant pointer.");
|
|
66
87
|
console.log(" --help, -h Show this help");
|
|
67
88
|
console.log("");
|
|
68
89
|
console.log("Examples:");
|
|
69
|
-
console.log(
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
console.log(
|
|
91
|
+
" $ vellum flags # list flags for active assistant",
|
|
92
|
+
);
|
|
93
|
+
console.log(
|
|
94
|
+
" $ vellum flags get query-complexity-routing # inspect one flag",
|
|
95
|
+
);
|
|
96
|
+
console.log(
|
|
97
|
+
" $ vellum flags set voice-mode true # enable a flag",
|
|
98
|
+
);
|
|
99
|
+
console.log(
|
|
100
|
+
" $ vellum flags set external-plugins true --assistant eval-1 # target by name/id",
|
|
101
|
+
);
|
|
72
102
|
}
|
|
73
103
|
|
|
74
|
-
function createClient(): AssistantClient {
|
|
104
|
+
function createClient(assistantName?: string): AssistantClient {
|
|
105
|
+
// When `--assistant <name>` is provided, resolve the display name or
|
|
106
|
+
// explicit ID through the standard lookup helper (see cli/AGENTS.md
|
|
107
|
+
// "Assistant targeting convention"). Exact ID wins over display-name
|
|
108
|
+
// matches; ambiguous names fail loudly.
|
|
109
|
+
let assistantId: string | undefined;
|
|
110
|
+
if (assistantName) {
|
|
111
|
+
const result = lookupAssistantByIdentifier(assistantName);
|
|
112
|
+
if (result.status !== "found") {
|
|
113
|
+
throw new Error(formatAssistantLookupError(assistantName, result));
|
|
114
|
+
}
|
|
115
|
+
assistantId = result.entry.assistantId;
|
|
116
|
+
}
|
|
75
117
|
try {
|
|
76
|
-
return new AssistantClient();
|
|
118
|
+
return new AssistantClient(assistantId ? { assistantId } : undefined);
|
|
77
119
|
} catch {
|
|
78
120
|
throw new Error(
|
|
79
|
-
|
|
121
|
+
assistantName
|
|
122
|
+
? `No assistant found matching '${assistantName}'.`
|
|
123
|
+
: "No assistant found. Hatch one with 'vellum hatch' first.",
|
|
80
124
|
);
|
|
81
125
|
}
|
|
82
126
|
}
|
|
@@ -93,8 +137,8 @@ function rethrowFetchError(err: unknown): never {
|
|
|
93
137
|
throw err;
|
|
94
138
|
}
|
|
95
139
|
|
|
96
|
-
async function listFlags(): Promise<void> {
|
|
97
|
-
const client = createClient();
|
|
140
|
+
async function listFlags(assistantName?: string): Promise<void> {
|
|
141
|
+
const client = createClient(assistantName);
|
|
98
142
|
let res: Response;
|
|
99
143
|
try {
|
|
100
144
|
res = await client.get("/feature-flags");
|
|
@@ -113,8 +157,8 @@ async function listFlags(): Promise<void> {
|
|
|
113
157
|
printFlagTable(data.flags);
|
|
114
158
|
}
|
|
115
159
|
|
|
116
|
-
async function getFlag(key: string): Promise<void> {
|
|
117
|
-
const client = createClient();
|
|
160
|
+
async function getFlag(key: string, assistantName?: string): Promise<void> {
|
|
161
|
+
const client = createClient(assistantName);
|
|
118
162
|
let res: Response;
|
|
119
163
|
try {
|
|
120
164
|
res = await client.get("/feature-flags");
|
|
@@ -136,8 +180,12 @@ async function getFlag(key: string): Promise<void> {
|
|
|
136
180
|
console.log(`Description: ${flag.description || "(none)"}`);
|
|
137
181
|
}
|
|
138
182
|
|
|
139
|
-
async function setFlag(
|
|
140
|
-
|
|
183
|
+
async function setFlag(
|
|
184
|
+
key: string,
|
|
185
|
+
value: boolean,
|
|
186
|
+
assistantName?: string,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const client = createClient(assistantName);
|
|
141
189
|
let res: Response;
|
|
142
190
|
try {
|
|
143
191
|
res = await client.patch(`/feature-flags/${key}`, { enabled: value });
|
|
@@ -151,6 +199,28 @@ async function setFlag(key: string, value: boolean): Promise<void> {
|
|
|
151
199
|
console.log(`Flag "${key}" set to ${value}`);
|
|
152
200
|
}
|
|
153
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Strip `--assistant <name>` from argv and return the captured value.
|
|
204
|
+
*
|
|
205
|
+
* Mutates the input array so positional parsing downstream sees a clean
|
|
206
|
+
* shape (subcommand + key + value). Returns `undefined` if the flag is
|
|
207
|
+
* absent. Error-reports a missing value so the user gets a clear message
|
|
208
|
+
* rather than the flag being silently swallowed as a positional.
|
|
209
|
+
*/
|
|
210
|
+
function extractAssistantFlag(args: string[]): string | undefined {
|
|
211
|
+
for (let i = 0; i < args.length; i++) {
|
|
212
|
+
if (args[i] !== "--assistant") continue;
|
|
213
|
+
const value = args[i + 1];
|
|
214
|
+
if (!value || value.startsWith("-")) {
|
|
215
|
+
console.error("Missing value for --assistant <name>");
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
args.splice(i, 2);
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
154
224
|
export async function flags(): Promise<void> {
|
|
155
225
|
const args = process.argv.slice(3);
|
|
156
226
|
|
|
@@ -159,10 +229,12 @@ export async function flags(): Promise<void> {
|
|
|
159
229
|
return;
|
|
160
230
|
}
|
|
161
231
|
|
|
232
|
+
const assistantName = extractAssistantFlag(args);
|
|
233
|
+
|
|
162
234
|
const subcommand = args[0];
|
|
163
235
|
|
|
164
236
|
if (!subcommand) {
|
|
165
|
-
await listFlags();
|
|
237
|
+
await listFlags(assistantName);
|
|
166
238
|
return;
|
|
167
239
|
}
|
|
168
240
|
|
|
@@ -172,7 +244,7 @@ export async function flags(): Promise<void> {
|
|
|
172
244
|
console.error("Usage: vellum flags get <key>");
|
|
173
245
|
process.exit(1);
|
|
174
246
|
}
|
|
175
|
-
await getFlag(key);
|
|
247
|
+
await getFlag(key, assistantName);
|
|
176
248
|
return;
|
|
177
249
|
}
|
|
178
250
|
|
|
@@ -187,7 +259,7 @@ export async function flags(): Promise<void> {
|
|
|
187
259
|
console.error(`Invalid value "${rawValue}". Must be "true" or "false".`);
|
|
188
260
|
process.exit(1);
|
|
189
261
|
}
|
|
190
|
-
await setFlag(key, rawValue === "true");
|
|
262
|
+
await setFlag(key, rawValue === "true", assistantName);
|
|
191
263
|
return;
|
|
192
264
|
}
|
|
193
265
|
|
|
@@ -13,6 +13,7 @@ import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
|
13
13
|
import { checkHealth } from "../lib/health-check";
|
|
14
14
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
15
15
|
import { tuiLog } from "../lib/tui-log";
|
|
16
|
+
import { segmentsToPlainText } from "../lib/segments-to-plain-text";
|
|
16
17
|
import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
|
|
17
18
|
import {
|
|
18
19
|
getTerminalCapabilities,
|
|
@@ -230,13 +231,20 @@ async function pollMessages(
|
|
|
230
231
|
auth?: Record<string, string>,
|
|
231
232
|
): Promise<ListMessagesResponse> {
|
|
232
233
|
const params = new URLSearchParams({ conversationKey: assistantId });
|
|
233
|
-
|
|
234
|
+
const response = await runtimeRequest<ListMessagesResponse>(
|
|
234
235
|
baseUrl,
|
|
235
236
|
assistantId,
|
|
236
237
|
`/messages?${params.toString()}`,
|
|
237
238
|
undefined,
|
|
238
239
|
auth,
|
|
239
240
|
);
|
|
241
|
+
return {
|
|
242
|
+
...response,
|
|
243
|
+
messages: response.messages.map((msg) => ({
|
|
244
|
+
...msg,
|
|
245
|
+
content: segmentsToPlainText(msg.textSegments),
|
|
246
|
+
})),
|
|
247
|
+
};
|
|
240
248
|
}
|
|
241
249
|
|
|
242
250
|
async function sendMessage(
|
|
@@ -626,6 +634,13 @@ export interface RuntimeMessage {
|
|
|
626
634
|
id: string;
|
|
627
635
|
role: "user" | "assistant";
|
|
628
636
|
content: string;
|
|
637
|
+
/**
|
|
638
|
+
* Ordered text segments from the daemon's history payload, split at
|
|
639
|
+
* tool_use/surface boundaries. The flat `content` body is derived from
|
|
640
|
+
* these (see `segmentsToPlainText`); the daemon no longer sends a
|
|
641
|
+
* redundant flattened `content` field on the wire.
|
|
642
|
+
*/
|
|
643
|
+
textSegments?: string[];
|
|
629
644
|
timestamp: string;
|
|
630
645
|
toolCalls?: ToolCallInfo[];
|
|
631
646
|
label?: string;
|