@vellumai/cli 0.8.5 → 0.8.6

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.
@@ -278,18 +278,29 @@ async function maybeHydratePlatformAssistantName(
278
278
  }
279
279
  }
280
280
 
281
+ const SPA_BASE = "/assistant/";
282
+
281
283
  /**
282
- * Walk up from this file's location to find a sibling `clients/web` package.
284
+ * Locate the pre-built @vellumai/web dist directory.
283
285
  *
284
- * Returns the absolute path to its directory, or null when not found —
285
- * e.g. when the CLI is installed via npm/bunx, where the `clients/web`
286
- * source isn't shipped alongside `@vellumai/cli`. For now we treat the
287
- * `--interface web` path as source-checkout-only.
286
+ * Resolution order:
287
+ * 1. npm-installed package require.resolve('@vellumai/web/package.json')
288
+ * 2. Source checkout walk up from cli/ to find apps/web/dist/
288
289
  */
289
- function findClientsWebDir(): string | null {
290
+ function findWebDistDir(): string | null {
291
+ try {
292
+ const pkgPath = require.resolve("@vellumai/web/package.json");
293
+ const distDir = path.join(path.dirname(pkgPath), "dist");
294
+ if (existsSync(path.join(distDir, "index.html"))) {
295
+ return distDir;
296
+ }
297
+ } catch {
298
+ // Package not installed; try source checkout.
299
+ }
300
+
290
301
  let dir = import.meta.dir;
291
302
  for (let depth = 0; depth < 8; depth++) {
292
- const candidate = path.join(dir, "clients", "web", "package.json");
303
+ const candidate = path.join(dir, "apps", "web", "dist", "index.html");
293
304
  if (existsSync(candidate)) {
294
305
  return path.dirname(candidate);
295
306
  }
@@ -300,42 +311,61 @@ function findClientsWebDir(): string | null {
300
311
  return null;
301
312
  }
302
313
 
303
- /**
304
- * Spawn the `clients/web` package's `local` script and proxy its lifecycle.
305
- *
306
- * The web client is deliberately not declared as a dependency of `@vellumai/cli`:
307
- * the CLI is published, the web package is not. Locating it on disk and
308
- * shelling out keeps the two packages independent.
309
- */
310
314
  async function runWebInterface(): Promise<void> {
311
- const webDir = findClientsWebDir();
312
- if (!webDir) {
315
+ const distDir = findWebDistDir();
316
+ if (!distDir) {
313
317
  console.error(
314
318
  `${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
315
- `clients/web. This interface currently requires running ` +
316
- `vellum from a source checkout of vellum-assistant.`,
319
+ `@vellumai/web assets.\n\n` +
320
+ ` npm/bunx install: npm install @vellumai/web\n` +
321
+ ` source checkout: cd apps/web && VITE_PLATFORM_MODE=false bun run build`,
317
322
  );
318
323
  process.exit(1);
319
324
  }
320
325
 
321
- const child = Bun.spawn({
322
- cmd: ["bun", "run", "local"],
323
- cwd: webDir,
324
- stdio: ["inherit", "inherit", "inherit"],
326
+ const indexHtml = await Bun.file(path.join(distDir, "index.html")).text();
327
+
328
+ const server = Bun.serve({
329
+ port: 3000,
330
+ hostname: "127.0.0.1",
331
+ fetch: async (req) => {
332
+ const url = new URL(req.url);
333
+ const { pathname } = url;
334
+
335
+ if (pathname === "/") {
336
+ return Response.redirect(SPA_BASE, 302);
337
+ }
338
+
339
+ if (pathname.startsWith(SPA_BASE)) {
340
+ const relPath = pathname.slice(SPA_BASE.length);
341
+ if (relPath) {
342
+ const filePath = path.join(distDir, relPath);
343
+ const file = Bun.file(filePath);
344
+ if (await file.exists()) {
345
+ return new Response(file);
346
+ }
347
+ }
348
+ return new Response(indexHtml, {
349
+ headers: { "Content-Type": "text/html; charset=utf-8" },
350
+ });
351
+ }
352
+
353
+ return new Response("Not Found", { status: 404 });
354
+ },
325
355
  });
326
356
 
327
- const forward = (signal: "SIGINT" | "SIGTERM"): void => {
328
- try {
329
- child.kill(signal);
330
- } catch {
331
- // Child already exited; nothing to forward.
332
- }
357
+ console.log(
358
+ `Vellum web interface: http://${server.hostname}:${server.port}${SPA_BASE}`,
359
+ );
360
+
361
+ const shutdown = (): void => {
362
+ server.stop();
363
+ process.exit(0);
333
364
  };
334
- process.on("SIGINT", () => forward("SIGINT"));
335
- process.on("SIGTERM", () => forward("SIGTERM"));
365
+ process.on("SIGINT", shutdown);
366
+ process.on("SIGTERM", shutdown);
336
367
 
337
- const exitCode = await child.exited;
338
- process.exit(typeof exitCode === "number" ? exitCode : 0);
368
+ await new Promise(() => {});
339
369
  }
340
370
 
341
371
  export async function client(): Promise<void> {
@@ -0,0 +1,197 @@
1
+ import { AssistantClient } from "../lib/assistant-client.js";
2
+
3
+ type FeatureFlagEntry = {
4
+ key: string;
5
+ label: string;
6
+ enabled: boolean;
7
+ defaultEnabled: boolean;
8
+ description: string;
9
+ };
10
+
11
+ type FlagsResponse = {
12
+ flags: FeatureFlagEntry[];
13
+ };
14
+
15
+ function pad(s: string, w: number): string {
16
+ return s + " ".repeat(Math.max(0, w - s.length));
17
+ }
18
+
19
+ function printFlagTable(flags: FeatureFlagEntry[]): void {
20
+ const headers = { key: "KEY", enabled: "ENABLED", default: "DEFAULT", label: "LABEL" };
21
+
22
+ const rows = flags
23
+ .slice()
24
+ .sort((a, b) => a.key.localeCompare(b.key))
25
+ .map((f) => ({
26
+ key: f.enabled !== f.defaultEnabled ? `* ${f.key}` : ` ${f.key}`,
27
+ enabled: String(f.enabled),
28
+ default: String(f.defaultEnabled),
29
+ label: f.label,
30
+ }));
31
+
32
+ const all = [headers, ...rows];
33
+ const colWidths = {
34
+ key: Math.max(...all.map((r) => r.key.length)),
35
+ enabled: Math.max(...all.map((r) => r.enabled.length)),
36
+ default: Math.max(...all.map((r) => r.default.length)),
37
+ label: Math.max(...all.map((r) => r.label.length)),
38
+ };
39
+
40
+ const formatRow = (r: typeof headers) =>
41
+ `${pad(r.key, colWidths.key)} ${pad(r.enabled, colWidths.enabled)} ${pad(r.default, colWidths.default)} ${r.label}`;
42
+
43
+ console.log(formatRow(headers));
44
+ console.log(
45
+ `${"-".repeat(colWidths.key)} ${"-".repeat(colWidths.enabled)} ${"-".repeat(colWidths.default)} ${"-".repeat(colWidths.label)}`,
46
+ );
47
+ for (const row of rows) {
48
+ console.log(formatRow(row));
49
+ }
50
+ console.log("");
51
+ console.log("* = overridden (differs from default)");
52
+ }
53
+
54
+ function printHelp(): void {
55
+ console.log("Usage: vellum flags [subcommand] [options]");
56
+ console.log("");
57
+ console.log("Show and toggle feature flags for the active assistant.");
58
+ console.log("Reads from the gateway's merged flag state (persisted overrides > remote > defaults).");
59
+ console.log("");
60
+ console.log("Subcommands:");
61
+ console.log(" (none) List all feature flags in a table");
62
+ console.log(" get <key> Show details for a single flag");
63
+ console.log(" set <key> <bool> Set a flag override to true or false");
64
+ console.log("");
65
+ console.log("Options:");
66
+ console.log(" --help, -h Show this help");
67
+ console.log("");
68
+ console.log("Examples:");
69
+ console.log(" $ vellum flags # list all flags");
70
+ console.log(" $ vellum flags get query-complexity-routing # inspect one flag");
71
+ console.log(" $ vellum flags set voice-mode true # enable a flag");
72
+ }
73
+
74
+ function createClient(): AssistantClient {
75
+ try {
76
+ return new AssistantClient();
77
+ } catch {
78
+ throw new Error(
79
+ "No assistant found. Hatch one with 'vellum hatch' first.",
80
+ );
81
+ }
82
+ }
83
+
84
+ function rethrowFetchError(err: unknown): never {
85
+ if (
86
+ err instanceof TypeError &&
87
+ (err.message.includes("fetch") || err.message.includes("connect"))
88
+ ) {
89
+ throw new Error(
90
+ "Could not reach the assistant gateway. Is it running? Try 'vellum wake'.",
91
+ );
92
+ }
93
+ throw err;
94
+ }
95
+
96
+ async function listFlags(): Promise<void> {
97
+ const client = createClient();
98
+ let res: Response;
99
+ try {
100
+ res = await client.get("/feature-flags");
101
+ } catch (err) {
102
+ rethrowFetchError(err);
103
+ }
104
+ if (!res.ok) {
105
+ const body = await res.text().catch(() => "");
106
+ throw new Error(`Failed to fetch flags: HTTP ${res.status} ${body}`.trim());
107
+ }
108
+ const data = (await res.json()) as FlagsResponse;
109
+ if (data.flags.length === 0) {
110
+ console.log("No feature flags found.");
111
+ return;
112
+ }
113
+ printFlagTable(data.flags);
114
+ }
115
+
116
+ async function getFlag(key: string): Promise<void> {
117
+ const client = createClient();
118
+ let res: Response;
119
+ try {
120
+ res = await client.get("/feature-flags");
121
+ } catch (err) {
122
+ rethrowFetchError(err);
123
+ }
124
+ if (!res.ok) {
125
+ const body = await res.text().catch(() => "");
126
+ throw new Error(`Failed to fetch flags: HTTP ${res.status} ${body}`.trim());
127
+ }
128
+ const data = (await res.json()) as FlagsResponse;
129
+ const flag = data.flags.find((f) => f.key === key);
130
+ if (!flag) {
131
+ throw new Error(`Flag "${key}" not found.`);
132
+ }
133
+ console.log(`Key: ${flag.key}`);
134
+ console.log(`Enabled: ${flag.enabled}`);
135
+ console.log(`Default: ${flag.defaultEnabled}`);
136
+ console.log(`Description: ${flag.description || "(none)"}`);
137
+ }
138
+
139
+ async function setFlag(key: string, value: boolean): Promise<void> {
140
+ const client = createClient();
141
+ let res: Response;
142
+ try {
143
+ res = await client.patch(`/feature-flags/${key}`, { enabled: value });
144
+ } catch (err) {
145
+ rethrowFetchError(err);
146
+ }
147
+ if (!res.ok) {
148
+ const body = await res.text().catch(() => "");
149
+ throw new Error(`Failed to set flag: HTTP ${res.status} ${body}`.trim());
150
+ }
151
+ console.log(`Flag "${key}" set to ${value}`);
152
+ }
153
+
154
+ export async function flags(): Promise<void> {
155
+ const args = process.argv.slice(3);
156
+
157
+ if (args.includes("--help") || args.includes("-h")) {
158
+ printHelp();
159
+ return;
160
+ }
161
+
162
+ const subcommand = args[0];
163
+
164
+ if (!subcommand) {
165
+ await listFlags();
166
+ return;
167
+ }
168
+
169
+ if (subcommand === "get") {
170
+ const key = args[1];
171
+ if (!key) {
172
+ console.error("Usage: vellum flags get <key>");
173
+ process.exit(1);
174
+ }
175
+ await getFlag(key);
176
+ return;
177
+ }
178
+
179
+ if (subcommand === "set") {
180
+ const key = args[1];
181
+ const rawValue = args[2];
182
+ if (!key || rawValue === undefined) {
183
+ console.error("Usage: vellum flags set <key> <true|false>");
184
+ process.exit(1);
185
+ }
186
+ if (rawValue !== "true" && rawValue !== "false") {
187
+ console.error(`Invalid value "${rawValue}". Must be "true" or "false".`);
188
+ process.exit(1);
189
+ }
190
+ await setFlag(key, rawValue === "true");
191
+ return;
192
+ }
193
+
194
+ console.error(`Unknown subcommand: ${subcommand}`);
195
+ printHelp();
196
+ process.exit(1);
197
+ }
@@ -0,0 +1,73 @@
1
+ import {
2
+ lookupAssistantByIdentifier,
3
+ formatAssistantLookupError,
4
+ } from "../../lib/assistant-config.js";
5
+ import {
6
+ loadGuardianToken,
7
+ refreshGuardianToken,
8
+ } from "../../lib/guardian-token.js";
9
+
10
+ function printUsage(): void {
11
+ console.log("Usage: vellum gateway token <subcommand> <assistantId>");
12
+ console.log("");
13
+ console.log("Manage gateway authentication tokens.");
14
+ console.log("");
15
+ console.log("Subcommands:");
16
+ console.log(" get Print the current guardian access token");
17
+ console.log(" refresh Refresh an expired access token and print it");
18
+ }
19
+
20
+ export async function gatewayToken(): Promise<void> {
21
+ const args = process.argv.slice(4);
22
+ const subcommand = args[0];
23
+
24
+ if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
25
+ printUsage();
26
+ process.exit(0);
27
+ }
28
+
29
+ if (subcommand !== "get" && subcommand !== "refresh") {
30
+ console.error(`Unknown subcommand: ${subcommand}`);
31
+ printUsage();
32
+ process.exit(1);
33
+ }
34
+
35
+ const assistantId = args[1];
36
+ if (!assistantId) {
37
+ console.error("Missing required argument: <assistantId>");
38
+ printUsage();
39
+ process.exit(1);
40
+ }
41
+
42
+ const result = lookupAssistantByIdentifier(assistantId);
43
+ if (result.status !== "found") {
44
+ console.error(formatAssistantLookupError(assistantId, result));
45
+ process.exit(1);
46
+ }
47
+ const entry = result.entry;
48
+
49
+ const tokenData = loadGuardianToken(entry.assistantId);
50
+ if (!tokenData) {
51
+ console.error("No guardian token found for this assistant.");
52
+ process.exit(1);
53
+ }
54
+
55
+ if (subcommand === "get") {
56
+ console.log(tokenData.accessToken);
57
+ return;
58
+ }
59
+
60
+ const gatewayUrl = entry.localUrl || entry.runtimeUrl;
61
+ if (!gatewayUrl) {
62
+ console.error("No gateway URL found for this assistant.");
63
+ process.exit(1);
64
+ }
65
+
66
+ const refreshed = await refreshGuardianToken(gatewayUrl, entry.assistantId);
67
+ if (!refreshed) {
68
+ console.error("Failed to refresh guardian token.");
69
+ process.exit(1);
70
+ }
71
+
72
+ console.log(refreshed.accessToken);
73
+ }
@@ -0,0 +1,29 @@
1
+ import { gatewayToken } from "./gateway/token.js";
2
+
3
+ function printUsage(): void {
4
+ console.log("Usage: vellum gateway <subcommand>");
5
+ console.log("");
6
+ console.log("Gateway management commands.");
7
+ console.log("");
8
+ console.log("Subcommands:");
9
+ console.log(" token Manage gateway authentication tokens");
10
+ }
11
+
12
+ export async function gateway(): Promise<void> {
13
+ const args = process.argv.slice(3);
14
+ const subcommand = args[0];
15
+
16
+ if (subcommand === "--help" || subcommand === "-h" || !subcommand) {
17
+ printUsage();
18
+ process.exit(0);
19
+ }
20
+
21
+ if (subcommand === "token") {
22
+ await gatewayToken();
23
+ return;
24
+ }
25
+
26
+ console.error(`Unknown subcommand: ${subcommand}`);
27
+ printUsage();
28
+ process.exit(1);
29
+ }
@@ -4,8 +4,12 @@ import { createInterface } from "readline";
4
4
  import { watch } from "fs";
5
5
  import { join } from "path";
6
6
 
7
- import { resolveAssistant } from "../lib/assistant-config";
8
- import type { AssistantEntry } from "../lib/assistant-config";
7
+ import {
8
+ extractHostFromUrl,
9
+ resolveAssistant,
10
+ resolveCloud,
11
+ type AssistantEntry,
12
+ } from "../lib/assistant-config";
9
13
  import { dockerResourceNames } from "../lib/docker";
10
14
  import { getLogDir } from "../lib/xdg-log";
11
15
  import { execOutput } from "../lib/step-runner";
@@ -112,13 +116,6 @@ function parseArgs(): LogsArgs {
112
116
 
113
117
  // ── Helpers ─────────────────────────────────────────────────────
114
118
 
115
- function resolveCloud(entry: AssistantEntry): string {
116
- if (entry.cloud) return entry.cloud;
117
- if (entry.project) return "gcp";
118
- if (entry.sshUser) return "custom";
119
- return "local";
120
- }
121
-
122
119
  /**
123
120
  * Parse a relative time string like "10m", "2h", "30s" into a Date.
124
121
  * Returns null if the string doesn't look like a relative time.
@@ -494,15 +491,6 @@ async function showGcpLogs(
494
491
  }
495
492
  }
496
493
 
497
- function extractHostFromUrl(url: string): string {
498
- try {
499
- const parsed = new URL(url);
500
- return parsed.hostname;
501
- } catch {
502
- return url.replace(/^https?:\/\//, "").split(":")[0];
503
- }
504
- }
505
-
506
494
  async function showCustomLogs(
507
495
  entry: AssistantEntry,
508
496
  opts: LogsArgs,
@@ -1,6 +1,7 @@
1
1
  import { join } from "path";
2
2
 
3
3
  import {
4
+ extractHostFromUrl,
4
5
  findAssistantByName,
5
6
  formatAssistantLookupError,
6
7
  formatAssistantReference,
@@ -9,6 +10,7 @@ import {
9
10
  getDaemonPidPath,
10
11
  loadAllAssistants,
11
12
  lookupAssistantByIdentifier,
13
+ resolveCloud,
12
14
  type AssistantEntry,
13
15
  } from "../lib/assistant-config";
14
16
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
@@ -26,7 +28,7 @@ import { existsSync } from "fs";
26
28
  import {
27
29
  classifyProcess,
28
30
  detectOrphanedProcesses,
29
- isProcessAlive,
31
+ isPidAlive,
30
32
  parseRemotePs,
31
33
  readPidFile,
32
34
  } from "../lib/orphan-detection";
@@ -149,35 +151,25 @@ const REMOTE_PS_CMD = [
149
151
  "| grep -v grep",
150
152
  ].join(" ");
151
153
 
152
- function extractHostFromUrl(url: string): string {
153
- try {
154
- const parsed = new URL(url);
155
- return parsed.hostname;
156
- } catch {
157
- return url.replace(/^https?:\/\//, "").split(":")[0];
158
- }
159
- }
160
-
161
- function resolveCloud(entry: AssistantEntry): string {
162
- if (entry.cloud) return entry.cloud;
163
- if (entry.project) return "gcp";
164
- if (entry.sshUser) return "custom";
165
- return "local";
166
- }
154
+ const REMOTE_SSH_TIMEOUT_MS = 30_000;
167
155
 
168
156
  async function getRemoteProcessesGcp(entry: AssistantEntry): Promise<string> {
169
- return execOutput("gcloud", [
170
- "compute",
171
- "ssh",
172
- `${entry.sshUser ?? entry.assistantId}@${entry.assistantId}`,
173
- `--zone=${entry.zone}`,
174
- `--project=${entry.project}`,
175
- `--command=${REMOTE_PS_CMD}`,
176
- "--ssh-flag=-o StrictHostKeyChecking=no",
177
- "--ssh-flag=-o UserKnownHostsFile=/dev/null",
178
- "--ssh-flag=-o ConnectTimeout=10",
179
- "--ssh-flag=-o LogLevel=ERROR",
180
- ]);
157
+ return execOutput(
158
+ "gcloud",
159
+ [
160
+ "compute",
161
+ "ssh",
162
+ `${entry.sshUser ?? entry.assistantId}@${entry.assistantId}`,
163
+ `--zone=${entry.zone}`,
164
+ `--project=${entry.project}`,
165
+ `--command=${REMOTE_PS_CMD}`,
166
+ "--ssh-flag=-o StrictHostKeyChecking=no",
167
+ "--ssh-flag=-o UserKnownHostsFile=/dev/null",
168
+ "--ssh-flag=-o ConnectTimeout=10",
169
+ "--ssh-flag=-o LogLevel=ERROR",
170
+ ],
171
+ { timeoutMs: REMOTE_SSH_TIMEOUT_MS },
172
+ );
181
173
  }
182
174
 
183
175
  async function getRemoteProcessesCustom(
@@ -185,7 +177,9 @@ async function getRemoteProcessesCustom(
185
177
  ): Promise<string> {
186
178
  const host = extractHostFromUrl(entry.runtimeUrl);
187
179
  const sshUser = entry.sshUser ?? "root";
188
- return execOutput("ssh", [...SSH_OPTS, `${sshUser}@${host}`, REMOTE_PS_CMD]);
180
+ return execOutput("ssh", [...SSH_OPTS, `${sshUser}@${host}`, REMOTE_PS_CMD], {
181
+ timeoutMs: REMOTE_SSH_TIMEOUT_MS,
182
+ });
189
183
  }
190
184
 
191
185
  interface ProcessSpec {
@@ -203,9 +197,13 @@ interface DetectedProcess {
203
197
  watch: boolean;
204
198
  }
205
199
 
200
+ const LOCAL_CMD_TIMEOUT_MS = 5_000;
201
+
206
202
  async function isWatchMode(pid: string): Promise<boolean> {
207
203
  try {
208
- const args = await execOutput("ps", ["-p", pid, "-o", "args="]);
204
+ const args = await execOutput("ps", ["-p", pid, "-o", "args="], {
205
+ timeoutMs: LOCAL_CMD_TIMEOUT_MS,
206
+ });
209
207
  return args.includes("--watch");
210
208
  } catch {
211
209
  return false;
@@ -242,7 +240,7 @@ async function detectProcess(spec: ProcessSpec): Promise<DetectedProcess> {
242
240
 
243
241
  // Tier 3: PID file fallback
244
242
  const filePid = readPidFile(spec.pidFile);
245
- if (filePid && isProcessAlive(filePid)) {
243
+ if (filePid && isPidAlive(filePid)) {
246
244
  const watch = await isWatchMode(filePid);
247
245
  return {
248
246
  name: spec.name,
@@ -320,12 +318,11 @@ async function getDockerContainerState(
320
318
  containerName: string,
321
319
  ): Promise<string | null> {
322
320
  try {
323
- const output = await execOutput("docker", [
324
- "inspect",
325
- "--format",
326
- "{{.State.Status}}",
327
- containerName,
328
- ]);
321
+ const output = await execOutput(
322
+ "docker",
323
+ ["inspect", "--format", "{{.State.Status}}", containerName],
324
+ { timeoutMs: LOCAL_CMD_TIMEOUT_MS },
325
+ );
329
326
  return output.trim() || "unknown";
330
327
  } catch {
331
328
  return null;
@@ -458,9 +455,12 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
458
455
  process.exit(1);
459
456
  }
460
457
  } catch (error) {
461
- console.error(
462
- `Failed to list processes: ${error instanceof Error ? error.message : error}`,
463
- );
458
+ const msg = error instanceof Error ? error.message : String(error);
459
+ if (msg.includes("timed out")) {
460
+ console.warn(`Warning: remote process listing timed out — ${msg}`);
461
+ return;
462
+ }
463
+ console.error(`Failed to list processes: ${msg}`);
464
464
  process.exit(1);
465
465
  }
466
466
 
@@ -496,7 +496,7 @@ async function getAssistantListHealth(
496
496
  // TODO(ATL-306): Remove readPidFile/getDaemonPidPath in favor of
497
497
  // fetching daemon PIDs via the health API (Gateway Security Migration).
498
498
  const pid = readPidFile(getDaemonPidPath(resources));
499
- const alive = pid !== null && isProcessAlive(pid);
499
+ const alive = pid !== null && isPidAlive(pid);
500
500
  if (!alive) {
501
501
  return { status: "sleeping", detail: null };
502
502
  }