@vellumai/cli 0.6.6 → 0.7.1

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.
Files changed (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -1,14 +1,19 @@
1
1
  import { mkdirSync, writeFileSync } from "fs";
2
2
  import { dirname, join } from "path";
3
3
 
4
- import { findAssistantByName } from "../lib/assistant-config";
4
+ import type { AssistantEntry } from "../lib/assistant-config.js";
5
+ import { findAssistantByName } from "../lib/assistant-config.js";
5
6
  import { getBackupsDir, formatSize } from "../lib/backup-ops.js";
6
7
  import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
8
+ import { pollJobUntilDone } from "../lib/job-polling.js";
7
9
  import {
10
+ MigrationInProgressError,
11
+ localRuntimeExportToGcs,
12
+ localRuntimePollJobStatus,
13
+ } from "../lib/local-runtime-client.js";
14
+ import {
15
+ platformRequestSignedUrl,
8
16
  readPlatformToken,
9
- platformInitiateExport,
10
- platformPollExportStatus,
11
- platformDownloadExport,
12
17
  } from "../lib/platform-client.js";
13
18
 
14
19
  export async function backup(): Promise<void> {
@@ -73,7 +78,7 @@ export async function backup(): Promise<void> {
73
78
  }
74
79
 
75
80
  if (cloud === "vellum") {
76
- await backupPlatform(name, outputArg, entry.runtimeUrl);
81
+ await backupPlatform(entry, name, outputArg);
77
82
  return;
78
83
  }
79
84
 
@@ -188,39 +193,66 @@ export async function backup(): Promise<void> {
188
193
  }
189
194
 
190
195
  // ---------------------------------------------------------------------------
191
- // Platform (Vellum-hosted) backup via Django async migration export
196
+ // Platform-managed (cloud="vellum") backup over GCS.
197
+ //
198
+ // The runtime exports the bundle straight to a platform-issued signed GCS
199
+ // URL; the CLI then downloads from GCS to local disk. Bytes never flow
200
+ // through Django. Same architectural shape as the platform-source half of
201
+ // `vellum teleport`. Output format and success log lines match mode 1
202
+ // (runtime-direct local backup) so users see one consistent UX.
203
+ //
204
+ // Lifecycle: the GCS bucket has a 1-day TTL on `uploads/<org>/*` objects
205
+ // (see `vellum-assistant-platform/django/app/assistant/migration/views.py`
206
+ // and `migration/services.py`). Backup is single-shot with no import to
207
+ // trigger best-effort cleanup, so the bundle sits in GCS up to 24h before
208
+ // TTL deletion. No explicit cleanup endpoint exists; relying on TTL is
209
+ // intentional.
192
210
  // ---------------------------------------------------------------------------
193
-
194
211
  async function backupPlatform(
212
+ entry: AssistantEntry,
195
213
  name: string,
196
214
  outputArg?: string,
197
- runtimeUrl?: string,
198
215
  ): Promise<void> {
199
- // Step 1 — Authenticate
200
- const token = readPlatformToken();
201
- if (!token) {
202
- console.error("Not logged in. Run 'vellum login' first.");
216
+ const platformToken = readPlatformToken();
217
+ if (!platformToken) {
218
+ console.error(
219
+ "Not logged in. Run 'vellum login' first (required for platform-managed backup).",
220
+ );
203
221
  process.exit(1);
204
222
  }
205
-
206
- // Step 2 Initiate export job
223
+ // Pin upload, download, and runtime requests to the same platform instance
224
+ // the assistant lives on. Using `getPlatformUrl()` instead would target
225
+ // whatever the lockfile / env-var resolves to, which may differ from
226
+ // `entry.runtimeUrl` for staging/dev assistants and end up signing URLs
227
+ // for the wrong GCS bucket. Mirrors the teleport bundlePlatformUrl
228
+ // threading at `cli/src/commands/teleport.ts:1311-1312`.
229
+ const platformUrl = entry.runtimeUrl;
230
+ // Track the working platform token across kickoff/poll/download so a
231
+ // 401-driven refresh during polling stays consistent through the final
232
+ // signed-download request.
233
+ let exportPlatformToken = platformToken;
234
+
235
+ // Step 1 — Request a signed upload URL.
236
+ const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
237
+ { operation: "upload" },
238
+ exportPlatformToken,
239
+ platformUrl,
240
+ );
241
+
242
+ // Step 2 — Kick off runtime export-to-GCS through the platform's
243
+ // wildcard runtime proxy. `localRuntimeExportToGcs` builds the
244
+ // `/v1/assistants/<id>/migrations/export-to-gcs` URL for cloud="vellum"
245
+ // and uses platform-token auth (no guardian-token bootstrap).
207
246
  let jobId: string;
208
247
  try {
209
- const result = await platformInitiateExport(
210
- token,
211
- "CLI backup",
212
- runtimeUrl,
213
- );
214
- jobId = result.jobId;
248
+ ({ jobId } = await localRuntimeExportToGcs(entry, exportPlatformToken, {
249
+ uploadUrl,
250
+ description: "CLI backup",
251
+ }));
215
252
  } catch (err) {
216
- const msg = err instanceof Error ? err.message : String(err);
217
- if (msg.includes("401") || msg.includes("403")) {
218
- console.error("Authentication failed. Run 'vellum login' to refresh.");
219
- process.exit(1);
220
- }
221
- if (msg.includes("429")) {
253
+ if (err instanceof MigrationInProgressError) {
222
254
  console.error(
223
- "Too many export requests. Please wait before trying again.",
255
+ `Error: Another backup or teleport export is already in progress on '${entry.assistantId}' (job ${err.existingJobId}). Wait for it to finish, then re-run.`,
224
256
  );
225
257
  process.exit(1);
226
258
  }
@@ -229,65 +261,79 @@ async function backupPlatform(
229
261
 
230
262
  console.log(`Export started (job ${jobId})...`);
231
263
 
232
- // Step 3 — Poll for completion
233
- const POLL_INTERVAL_MS = 2_000;
234
- const TIMEOUT_MS = 5 * 60 * 1_000; // 5 minutes
235
- const deadline = Date.now() + TIMEOUT_MS;
236
- let downloadUrl: string | undefined;
237
-
238
- while (Date.now() < deadline) {
239
- let status: { status: string; downloadUrl?: string; error?: string };
240
- try {
241
- status = await platformPollExportStatus(jobId, token, runtimeUrl);
242
- } catch (err) {
243
- const msg = err instanceof Error ? err.message : String(err);
244
- // Let non-transient errors (e.g. 404 "job not found") propagate immediately
245
- if (msg.includes("not found")) {
246
- throw err;
264
+ // Step 3 — Poll the job through the wildcard proxy. The dedicated
265
+ // `/v1/migrations/jobs/{id}/` endpoint queries platform-side ImportJob
266
+ // records and would 404 on runtime-created job IDs.
267
+ const terminal = await pollJobUntilDone({
268
+ label: "platform export",
269
+ poll: () => localRuntimePollJobStatus(entry, exportPlatformToken, jobId),
270
+ refreshOn401: async () => {
271
+ const refreshed = readPlatformToken();
272
+ if (!refreshed) {
273
+ throw new Error(
274
+ "Platform auth expired during export and no credential was found on disk. Run 'vellum login' and retry.",
275
+ );
247
276
  }
248
- console.warn(`Polling failed, retrying... (${msg})`);
249
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
250
- continue;
251
- }
277
+ exportPlatformToken = refreshed;
278
+ },
279
+ });
252
280
 
253
- if (status.status === "complete") {
254
- downloadUrl = status.downloadUrl;
255
- break;
256
- }
257
-
258
- if (status.status === "failed") {
259
- console.error(`Export failed: ${status.error ?? "unknown error"}`);
260
- process.exit(1);
261
- }
262
-
263
- // Still in progress — wait and retry
264
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
281
+ if (terminal.status === "failed") {
282
+ console.error(`Error: Export failed: ${terminal.error}`);
283
+ process.exit(1);
265
284
  }
266
285
 
267
- if (!downloadUrl) {
268
- console.error("Export timed out after 5 minutes.");
286
+ // Step 4 — Request a signed download URL for the same bundle and fetch
287
+ // it from GCS directly. No auth on signed URLs.
288
+ // Use `exportPlatformToken` (not the original `platformToken`) so a
289
+ // poll-loop 401 refresh doesn't get clobbered here — otherwise a long
290
+ // export that recovered mid-poll via re-auth would still 401 on the
291
+ // download-URL request and abort an otherwise successful run.
292
+ const { url: bundleUrl } = await platformRequestSignedUrl(
293
+ { operation: "download", bundleKey },
294
+ exportPlatformToken,
295
+ platformUrl,
296
+ );
297
+
298
+ let downloadResponse: Response;
299
+ try {
300
+ downloadResponse = await fetch(bundleUrl);
301
+ } catch (err) {
302
+ const msg = err instanceof Error ? err.message : String(err);
303
+ console.error(`Error: Failed to fetch bundle from GCS: ${msg}`);
304
+ process.exit(1);
305
+ }
306
+ if (!downloadResponse.ok) {
307
+ const body = await downloadResponse.text().catch(() => "");
308
+ console.error(
309
+ `Error: Failed to fetch bundle from GCS (${downloadResponse.status}): ${body}`,
310
+ );
269
311
  process.exit(1);
270
312
  }
271
313
 
272
- // Step 4 Download bundle
314
+ const arrayBuffer = await downloadResponse.arrayBuffer();
315
+ const data = new Uint8Array(arrayBuffer);
316
+
317
+ // Step 5 — Write to disk using the same path resolution mode 1 uses.
273
318
  const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
274
319
  const outputPath =
275
320
  outputArg || join(getBackupsDir(), `${name}-${isoTimestamp}.vbundle`);
276
-
277
321
  mkdirSync(dirname(outputPath), { recursive: true });
278
-
279
- const response = await platformDownloadExport(downloadUrl);
280
- const arrayBuffer = await response.arrayBuffer();
281
- const data = new Uint8Array(arrayBuffer);
282
-
283
322
  writeFileSync(outputPath, data);
284
323
 
285
- // Step 5 — Print success
324
+ // Step 6 — Print success. Manifest SHA is included only if the runtime
325
+ // surfaced it via the unified job result; the export-to-gcs runtime
326
+ // route does not set the legacy `X-Vbundle-Manifest-Sha256` response
327
+ // header.
286
328
  console.log(`Backup saved to ${outputPath}`);
287
329
  console.log(`Size: ${formatSize(data.byteLength)}`);
288
-
289
- const manifestSha = response.headers.get("X-Vbundle-Manifest-Sha256");
290
- if (manifestSha) {
330
+ const manifestSha =
331
+ terminal.status === "complete" &&
332
+ terminal.result &&
333
+ typeof terminal.result === "object"
334
+ ? (terminal.result as Record<string, unknown>).manifest_sha256
335
+ : undefined;
336
+ if (typeof manifestSha === "string") {
291
337
  console.log(`Manifest SHA-256: ${manifestSha}`);
292
338
  }
293
339
  }
@@ -3,7 +3,7 @@ import { hostname } from "os";
3
3
  import {
4
4
  findAssistantByName,
5
5
  getActiveAssistant,
6
- loadLatestAssistant,
6
+ resolveAssistant,
7
7
  } from "../lib/assistant-config";
8
8
  import {
9
9
  DAEMON_INTERNAL_ASSISTANT_ID,
@@ -11,7 +11,8 @@ import {
11
11
  type Species,
12
12
  } from "../lib/constants";
13
13
  import { loadGuardianToken } from "../lib/guardian-token";
14
- import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
14
+ import { getLocalLanIPv4 } from "../lib/local";
15
+ import { tuiLog } from "../lib/tui-log";
15
16
 
16
17
  const ANSI = {
17
18
  reset: "\x1b[0m",
@@ -76,8 +77,8 @@ function parseArgs(): ParsedArgs {
76
77
  }
77
78
  }
78
79
  if (!entry && hasExplicitUrl) {
79
- // URL provided but active assistant missing or unset — use latest for remaining defaults
80
- entry = loadLatestAssistant();
80
+ // URL provided but active assistant missing or unset — resolve for remaining defaults
81
+ entry = resolveAssistant();
81
82
  } else if (!entry) {
82
83
  console.error(
83
84
  "No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
@@ -140,11 +141,6 @@ function maybeSwapToLocalhost(url: string): string {
140
141
  }
141
142
  }
142
143
 
143
- const macHost = getMacLocalHostname();
144
- if (macHost) {
145
- localNames.push(macHost.toLowerCase());
146
- }
147
-
148
144
  const lanIp = getLocalLanIPv4();
149
145
  if (lanIp) {
150
146
  localNames.push(lanIp);
@@ -188,6 +184,9 @@ export async function client(): Promise<void> {
188
184
  const { runtimeUrl, assistantId, species, bearerToken, project, zone } =
189
185
  parseArgs();
190
186
 
187
+ tuiLog.init();
188
+ tuiLog.info("session start", { runtimeUrl, assistantId, species });
189
+
191
190
  const { renderChatApp } = await import("../components/DefaultMainScreen");
192
191
 
193
192
  process.stdout.write("\x1b[2J\x1b[H");
@@ -197,6 +196,8 @@ export async function client(): Promise<void> {
197
196
  assistantId,
198
197
  species,
199
198
  () => {
199
+ tuiLog.info("session end (user disconnect)");
200
+ tuiLog.close();
200
201
  app.unmount();
201
202
  process.stdout.write("\x1b[2J\x1b[H");
202
203
  console.log(`${ANSI.dim}Disconnected.${ANSI.reset}`);
@@ -0,0 +1,93 @@
1
+ import { SEEDS } from "../lib/environments/seeds.js";
2
+ import {
3
+ clearDefaultEnvironment,
4
+ readDefaultEnvironment,
5
+ resolveEnvironmentSource,
6
+ writeDefaultEnvironment,
7
+ } from "../lib/environments/resolve.js";
8
+
9
+ function printUsage(): void {
10
+ console.log("Usage: vellum env <subcommand>");
11
+ console.log("");
12
+ console.log("Manage the default CLI environment.");
13
+ console.log("");
14
+ console.log("Subcommands:");
15
+ console.log(" set <name> Set the default environment");
16
+ console.log(" get Show the current environment and its source");
17
+ console.log(" clear Remove the default, falling back to production");
18
+ console.log("");
19
+ console.log(`Known environments: ${Object.keys(SEEDS).join(", ")}`);
20
+ console.log("");
21
+ console.log("Examples:");
22
+ console.log(" $ vellum env set local # all commands default to local");
23
+ console.log(" $ vellum env get # show resolved environment");
24
+ console.log(" $ vellum env clear # revert to production default");
25
+ }
26
+
27
+ function envSet(name: string | undefined): void {
28
+ if (!name) {
29
+ console.error(
30
+ `Usage: vellum env set <name>\nKnown environments: ${Object.keys(SEEDS).join(", ")}`,
31
+ );
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ if (!SEEDS[name]) {
36
+ console.error(
37
+ `Unknown environment "${name}". Known environments: ${Object.keys(SEEDS).join(", ")}`,
38
+ );
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ writeDefaultEnvironment(name);
43
+ console.log(`Default environment set to "${name}".`);
44
+ }
45
+
46
+ function envGet(): void {
47
+ const { name, source } = resolveEnvironmentSource();
48
+ const sourceLabels: Record<typeof source, string> = {
49
+ flag: "--environment flag",
50
+ env: "VELLUM_ENVIRONMENT env var",
51
+ config: "~/.config/vellum/environment",
52
+ default: "default",
53
+ };
54
+ console.log(`${name} (from ${sourceLabels[source]})`);
55
+ }
56
+
57
+ function envClear(): void {
58
+ const current = readDefaultEnvironment();
59
+ if (!current) {
60
+ console.log("No default environment is set (already using production).");
61
+ return;
62
+ }
63
+ clearDefaultEnvironment();
64
+ console.log(
65
+ `Cleared default environment "${current}". Falling back to production.`,
66
+ );
67
+ }
68
+
69
+ export async function env(): Promise<void> {
70
+ const args = process.argv.slice(3);
71
+ const sub = args[0];
72
+
73
+ if (!sub || sub === "--help" || sub === "-h") {
74
+ printUsage();
75
+ return;
76
+ }
77
+
78
+ switch (sub) {
79
+ case "set":
80
+ envSet(args[1]);
81
+ break;
82
+ case "get":
83
+ envGet();
84
+ break;
85
+ case "clear":
86
+ envClear();
87
+ break;
88
+ default:
89
+ console.error(`Unknown subcommand: ${sub}`);
90
+ printUsage();
91
+ process.exitCode = 1;
92
+ }
93
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { extractFlag } from "../lib/arg-utils.js";
11
11
  import { AssistantClient } from "../lib/assistant-client.js";
12
+ import { getClientRegistrationHeaders } from "../lib/client-identity.js";
12
13
 
13
14
  function printUsage(): void {
14
15
  console.log(`vellum events - Stream events from a running assistant
@@ -136,6 +137,7 @@ export async function events(): Promise<void> {
136
137
  for await (const event of client.stream<AssistantEvent>("/events", {
137
138
  signal: controller.signal,
138
139
  query,
140
+ headers: getClientRegistrationHeaders(),
139
141
  })) {
140
142
  if (jsonOutput) {
141
143
  console.log(JSON.stringify(event));
@@ -1,14 +1,16 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
- import {
4
- findAssistantByName,
5
- loadLatestAssistant,
6
- resolveCloud,
7
- } from "../lib/assistant-config";
3
+ import { resolveAssistant, resolveCloud } from "../lib/assistant-config";
8
4
  import { dockerResourceNames } from "../lib/docker";
9
5
  import type { ServiceName } from "../lib/docker";
10
6
  import { execAppleContainer } from "../lib/exec-apple-container";
7
+ import { getPlatformUrl, readPlatformToken } from "../lib/platform-client";
11
8
  import { sshAppleContainer } from "../lib/ssh-apple-container";
9
+ import {
10
+ interactiveSession,
11
+ nonInteractiveExec,
12
+ shellEscapeArgs,
13
+ } from "../lib/terminal-session";
12
14
 
13
15
  const SERVICE_ALIASES: Record<string, ServiceName> = {
14
16
  assistant: "assistant",
@@ -74,11 +76,15 @@ export async function exec(): Promise<void> {
74
76
  );
75
77
  console.log("");
76
78
  console.log("Options:");
79
+ console.log(" --service <svc> Target service (default: assistant)");
77
80
  console.log(
78
- " --service <svc> Target service (default: assistant)",
81
+ " -it Interactive mode with TTY (like docker exec -it)",
79
82
  );
80
83
  console.log(
81
- " -it Interactive mode with TTY (like docker exec -it)",
84
+ " --timeout <secs> Timeout in seconds (default: 30, 0 = no timeout)",
85
+ );
86
+ console.log(
87
+ " --verbose Show debug output (SSE events, sentinel parsing)",
82
88
  );
83
89
  console.log("");
84
90
  console.log("Services:");
@@ -90,9 +96,7 @@ export async function exec(): Promise<void> {
90
96
  console.log(" vellum exec -- ls -la /workspace");
91
97
  console.log(" vellum exec -- cat /workspace/NOW.md");
92
98
  console.log(" vellum exec -it -- /bin/bash");
93
- console.log(
94
- " vellum exec --service gateway -- cat /tmp/gateway.log",
95
- );
99
+ console.log(" vellum exec --service gateway -- cat /tmp/gateway.log");
96
100
  process.exit(0);
97
101
  }
98
102
 
@@ -114,12 +118,23 @@ export async function exec(): Promise<void> {
114
118
  let nameArg: string | undefined;
115
119
  let serviceRaw = "assistant";
116
120
  let interactive = false;
121
+ let verbose = false;
122
+ let timeoutMs = 30_000;
117
123
 
118
124
  for (let i = 0; i < preArgs.length; i++) {
119
125
  if (preArgs[i] === "--service" && preArgs[i + 1]) {
120
126
  serviceRaw = preArgs[++i];
121
127
  } else if (preArgs[i] === "-it" || preArgs[i] === "-ti") {
122
128
  interactive = true;
129
+ } else if (preArgs[i] === "--timeout" && preArgs[i + 1]) {
130
+ const secs = Number(preArgs[++i]);
131
+ if (!Number.isFinite(secs) || secs < 0) {
132
+ console.error("Error: --timeout must be a non-negative number.");
133
+ process.exit(1);
134
+ }
135
+ timeoutMs = secs === 0 ? 0 : secs * 1000;
136
+ } else if (preArgs[i] === "--verbose") {
137
+ verbose = true;
123
138
  } else if (!preArgs[i].startsWith("-")) {
124
139
  nameArg = preArgs[i];
125
140
  }
@@ -127,9 +142,7 @@ export async function exec(): Promise<void> {
127
142
 
128
143
  const service = normalizeService(serviceRaw);
129
144
 
130
- const entry = nameArg
131
- ? findAssistantByName(nameArg)
132
- : loadLatestAssistant();
145
+ const entry = resolveAssistant(nameArg);
133
146
 
134
147
  if (!entry) {
135
148
  if (nameArg) {
@@ -179,6 +192,38 @@ export async function exec(): Promise<void> {
179
192
  return;
180
193
  }
181
194
 
195
+ if (cloud === "vellum") {
196
+ const token = readPlatformToken();
197
+ if (!token) {
198
+ console.error(
199
+ "Not logged in. Run `vellum login` first to authenticate with the platform.",
200
+ );
201
+ process.exit(1);
202
+ }
203
+
204
+ const assistant = {
205
+ assistantId: entry.assistantId,
206
+ token,
207
+ platformUrl: getPlatformUrl(),
208
+ };
209
+
210
+ const serviceParam = service === "assistant" ? undefined : service;
211
+
212
+ if (interactive) {
213
+ // Interactive mode: shell-escape argv and delegate to full terminal
214
+ await interactiveSession(assistant, shellEscapeArgs(command), serviceParam);
215
+ return;
216
+ }
217
+
218
+ // Non-interactive: sentinel-based output capture with exit code
219
+ await nonInteractiveExec(assistant, command, {
220
+ verbose,
221
+ timeoutMs,
222
+ service: serviceParam,
223
+ });
224
+ return;
225
+ }
226
+
182
227
  console.error(
183
228
  `Error: 'vellum exec' is not supported for ${cloud} instances.`,
184
229
  );