@vellumai/cli 0.6.5 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,8 +1,14 @@
1
+ import { existsSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+
1
4
  import {
2
5
  findAssistantByName,
6
+ loadAllAssistants,
3
7
  removeAssistantEntry,
4
8
  } from "../lib/assistant-config";
5
9
  import type { AssistantEntry } from "../lib/assistant-config";
10
+ import { getConfigDir } from "../lib/environments/paths";
11
+ import { getCurrentEnvironment } from "../lib/environments/resolve";
6
12
  import {
7
13
  authHeaders,
8
14
  getPlatformUrl,
@@ -246,4 +252,21 @@ async function retireInner(): Promise<void> {
246
252
 
247
253
  removeAssistantEntry(name);
248
254
  console.log(`Removed ${name} from config.`);
255
+
256
+ // When no assistants remain, remove the dock-display-name sentinel so
257
+ // the next build.sh run falls back to "Vellum" instead of using the
258
+ // retired assistant's name.
259
+ if (loadAllAssistants().length === 0) {
260
+ const dockLabelFile = join(
261
+ getConfigDir(getCurrentEnvironment()),
262
+ "dock-display-name",
263
+ );
264
+ if (existsSync(dockLabelFile)) {
265
+ try {
266
+ unlinkSync(dockLabelFile);
267
+ } catch {
268
+ // Best-effort — the macOS app will also reset this on next launch.
269
+ }
270
+ }
271
+ }
249
272
  }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * `vellum terminal` — Interactive shell into a managed assistant container.
3
+ *
4
+ * Bridges the local tty to a platform terminal session (K8s exec) so the
5
+ * user can interact with their assistant's sandbox from iTerm2 or any
6
+ * local terminal emulator.
7
+ *
8
+ * Subcommands:
9
+ * vellum terminal — Interactive shell
10
+ * vellum terminal attach <name> — Attach to a tmux session
11
+ * vellum terminal list — List tmux sessions
12
+ */
13
+
14
+ import {
15
+ findAssistantByName,
16
+ loadLatestAssistant,
17
+ resolveCloud,
18
+ } from "../lib/assistant-config.js";
19
+ import { getPlatformUrl, readPlatformToken } from "../lib/platform-client.js";
20
+ import {
21
+ closeTerminalSession,
22
+ createTerminalSession,
23
+ resizeTerminalSession,
24
+ sendTerminalInput,
25
+ subscribeTerminalEvents,
26
+ } from "../lib/terminal-client.js";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function printHelp(): void {
33
+ console.log("Usage: vellum terminal [subcommand] [options]");
34
+ console.log("");
35
+ console.log(
36
+ "Open an interactive terminal session into a managed assistant container.",
37
+ );
38
+ console.log("");
39
+ console.log("Subcommands:");
40
+ console.log(" (none) Interactive shell");
41
+ console.log(
42
+ " attach <name> Attach to a tmux session inside the container",
43
+ );
44
+ console.log(
45
+ " list List tmux sessions running inside the container",
46
+ );
47
+ console.log("");
48
+ console.log("Options:");
49
+ console.log(
50
+ " <name> Name of the assistant (defaults to active)",
51
+ );
52
+ console.log(
53
+ " --assistant <name> Explicit assistant name (alternative to positional)",
54
+ );
55
+ console.log("");
56
+ console.log("Examples:");
57
+ console.log(" vellum terminal");
58
+ console.log(" vellum terminal attach my-session");
59
+ console.log(" vellum terminal list");
60
+ console.log(" vellum terminal --assistant my-assistant");
61
+ }
62
+
63
+ interface ResolvedAssistant {
64
+ assistantId: string;
65
+ token: string;
66
+ platformUrl: string;
67
+ }
68
+
69
+ function resolveAssistant(nameArg?: string): ResolvedAssistant {
70
+ const entry = nameArg ? findAssistantByName(nameArg) : loadLatestAssistant();
71
+
72
+ if (!entry) {
73
+ if (nameArg) {
74
+ console.error(`No assistant instance found with name '${nameArg}'.`);
75
+ } else {
76
+ console.error("No assistant instance found. Run `vellum hatch` first.");
77
+ }
78
+ process.exit(1);
79
+ }
80
+
81
+ const cloud = resolveCloud(entry);
82
+ if (cloud !== "vellum") {
83
+ if (cloud === "local") {
84
+ console.error(
85
+ "This assistant runs locally on your machine. You can access it directly.",
86
+ );
87
+ } else if (cloud === "docker") {
88
+ console.error(
89
+ `Use 'vellum exec -it -- /bin/bash' or 'vellum ssh' for ${cloud} instances.`,
90
+ );
91
+ } else {
92
+ console.error(
93
+ `'vellum terminal' is for managed (cloud-hosted) assistants. This assistant uses '${cloud}'.`,
94
+ );
95
+ }
96
+ process.exit(1);
97
+ }
98
+
99
+ const token = readPlatformToken();
100
+ if (!token) {
101
+ console.error(
102
+ "Not logged in. Run `vellum login` first to authenticate with the platform.",
103
+ );
104
+ process.exit(1);
105
+ }
106
+
107
+ return {
108
+ assistantId: entry.assistantId,
109
+ token,
110
+ platformUrl: getPlatformUrl(),
111
+ };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Interactive session
116
+ // ---------------------------------------------------------------------------
117
+
118
+ async function interactiveSession(
119
+ assistant: ResolvedAssistant,
120
+ initialCommand?: string,
121
+ ): Promise<void> {
122
+ const cols = process.stdout.columns || 80;
123
+ const rows = process.stdout.rows || 24;
124
+
125
+ console.error(`\x1b[2m🔗 Connecting to ${assistant.assistantId}...\x1b[0m`);
126
+
127
+ const { session_id: sessionId } = await createTerminalSession(
128
+ assistant.token,
129
+ assistant.assistantId,
130
+ cols,
131
+ rows,
132
+ assistant.platformUrl,
133
+ );
134
+
135
+ // --- TTY raw mode setup ---
136
+ const wasRaw = process.stdin.isRaw;
137
+ if (process.stdin.isTTY) {
138
+ process.stdin.setRawMode(true);
139
+ }
140
+ process.stdin.resume();
141
+ process.stdin.setEncoding("utf-8");
142
+
143
+ // Abort controller for the SSE stream
144
+ const abortController = new AbortController();
145
+ let exiting = false;
146
+
147
+ // --- Cleanup function (idempotent) ---
148
+ async function cleanup(): Promise<void> {
149
+ if (exiting) return;
150
+ exiting = true;
151
+
152
+ // Restore tty
153
+ if (process.stdin.isTTY) {
154
+ process.stdin.setRawMode(wasRaw ?? false);
155
+ }
156
+ process.stdin.pause();
157
+
158
+ // Abort SSE stream
159
+ abortController.abort();
160
+
161
+ // Close remote session (best-effort)
162
+ try {
163
+ await closeTerminalSession(
164
+ assistant.token,
165
+ assistant.assistantId,
166
+ sessionId,
167
+ assistant.platformUrl,
168
+ );
169
+ } catch {
170
+ // Best-effort cleanup
171
+ }
172
+ }
173
+
174
+ // --- Signal handlers ---
175
+ const onSigInt = () => {
176
+ cleanup().then(() => process.exit(0));
177
+ };
178
+ const onSigTerm = () => {
179
+ cleanup().then(() => process.exit(0));
180
+ };
181
+ process.on("SIGINT", onSigInt);
182
+ process.on("SIGTERM", onSigTerm);
183
+
184
+ // --- SIGWINCH (terminal resize) ---
185
+ const onResize = () => {
186
+ const newCols = process.stdout.columns || 80;
187
+ const newRows = process.stdout.rows || 24;
188
+ resizeTerminalSession(
189
+ assistant.token,
190
+ assistant.assistantId,
191
+ sessionId,
192
+ newCols,
193
+ newRows,
194
+ assistant.platformUrl,
195
+ ).catch(() => {
196
+ // Resize failures are non-fatal
197
+ });
198
+ };
199
+ process.stdout.on("resize", onResize);
200
+
201
+ // --- Input: stdin → remote ---
202
+ let inputBuffer = "";
203
+ let inputTimer: ReturnType<typeof setTimeout> | null = null;
204
+ const INPUT_DEBOUNCE_MS = 30;
205
+
206
+ function flushInput(): void {
207
+ if (inputBuffer.length === 0) return;
208
+ const data = inputBuffer;
209
+ inputBuffer = "";
210
+ sendTerminalInput(
211
+ assistant.token,
212
+ assistant.assistantId,
213
+ sessionId,
214
+ data,
215
+ assistant.platformUrl,
216
+ ).catch((err) => {
217
+ if (!exiting) {
218
+ console.error(`\r\nInput error: ${err.message}\r\n`);
219
+ }
220
+ });
221
+ }
222
+
223
+ process.stdin.on("data", (chunk: string) => {
224
+ if (exiting) return;
225
+ inputBuffer += chunk;
226
+ if (inputTimer) clearTimeout(inputTimer);
227
+ inputTimer = setTimeout(flushInput, INPUT_DEBOUNCE_MS);
228
+ });
229
+
230
+ // --- Send initial command (for `attach` subcommand) ---
231
+ if (initialCommand) {
232
+ // Brief delay to let the shell initialize
233
+ await new Promise((resolve) => setTimeout(resolve, 300));
234
+ await sendTerminalInput(
235
+ assistant.token,
236
+ assistant.assistantId,
237
+ sessionId,
238
+ initialCommand + "\r",
239
+ assistant.platformUrl,
240
+ );
241
+ }
242
+
243
+ // --- Output: remote SSE → stdout ---
244
+ try {
245
+ for await (const event of subscribeTerminalEvents(
246
+ assistant.token,
247
+ assistant.assistantId,
248
+ sessionId,
249
+ assistant.platformUrl,
250
+ abortController.signal,
251
+ )) {
252
+ if (exiting) break;
253
+ // Decode base64 output and write raw bytes to stdout
254
+ const bytes = Buffer.from(event.data, "base64");
255
+ process.stdout.write(bytes);
256
+ }
257
+ } catch (err) {
258
+ if (!exiting) {
259
+ const msg = err instanceof Error ? err.message : String(err);
260
+ // AbortError is expected on cleanup
261
+ if (!msg.includes("abort")) {
262
+ console.error(`\r\nConnection lost: ${msg}\r\n`);
263
+ }
264
+ }
265
+ } finally {
266
+ await cleanup();
267
+
268
+ // Remove listeners
269
+ process.off("SIGINT", onSigInt);
270
+ process.off("SIGTERM", onSigTerm);
271
+ process.stdout.off("resize", onResize);
272
+ }
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // List tmux sessions
277
+ // ---------------------------------------------------------------------------
278
+
279
+ async function listTmuxSessions(assistant: ResolvedAssistant): Promise<void> {
280
+ const cols = 120;
281
+ const rows = 24;
282
+
283
+ const { session_id: sessionId } = await createTerminalSession(
284
+ assistant.token,
285
+ assistant.assistantId,
286
+ cols,
287
+ rows,
288
+ assistant.platformUrl,
289
+ );
290
+
291
+ const abortController = new AbortController();
292
+ const output: string[] = [];
293
+ let commandSent = false;
294
+
295
+ try {
296
+ const timeout = setTimeout(() => abortController.abort(), 5000);
297
+
298
+ const streamPromise = (async () => {
299
+ for await (const event of subscribeTerminalEvents(
300
+ assistant.token,
301
+ assistant.assistantId,
302
+ sessionId,
303
+ assistant.platformUrl,
304
+ abortController.signal,
305
+ )) {
306
+ const text = Buffer.from(event.data, "base64").toString("utf-8");
307
+ output.push(text);
308
+
309
+ // Wait for shell prompt before sending command
310
+ if (!commandSent) {
311
+ const joined = output.join("");
312
+ if (
313
+ joined.includes("$") ||
314
+ joined.includes("#") ||
315
+ joined.includes("%")
316
+ ) {
317
+ commandSent = true;
318
+ await sendTerminalInput(
319
+ assistant.token,
320
+ assistant.assistantId,
321
+ sessionId,
322
+ 'tmux list-sessions 2>/dev/null || echo "No tmux sessions found"; exit\r',
323
+ assistant.platformUrl,
324
+ );
325
+ }
326
+ }
327
+ }
328
+ })();
329
+
330
+ await streamPromise.catch(() => {});
331
+ clearTimeout(timeout);
332
+ } catch {
333
+ // Expected — abort or stream end
334
+ } finally {
335
+ abortController.abort();
336
+ await closeTerminalSession(
337
+ assistant.token,
338
+ assistant.assistantId,
339
+ sessionId,
340
+ assistant.platformUrl,
341
+ ).catch(() => {});
342
+ }
343
+
344
+ // Parse and display results
345
+ const raw = output.join("");
346
+ // Strip ANSI escape sequences for clean parsing
347
+ const clean = raw.replace(
348
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
349
+ /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][^\n]|\r/g,
350
+ "",
351
+ );
352
+
353
+ // Find tmux output lines (format: "session_name: N windows ...")
354
+ const lines = clean.split("\n");
355
+ const sessionLines = lines.filter(
356
+ (l) =>
357
+ /^\S+:\s+\d+\s+windows?/.test(l.trim()) ||
358
+ l.includes("No tmux sessions found"),
359
+ );
360
+
361
+ if (sessionLines.length === 0) {
362
+ console.log("No tmux sessions found.");
363
+ } else {
364
+ for (const line of sessionLines) {
365
+ console.log(line.trim());
366
+ }
367
+ }
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Main entry point
372
+ // ---------------------------------------------------------------------------
373
+
374
+ export async function terminal(): Promise<void> {
375
+ const args = process.argv.slice(3);
376
+
377
+ if (args.includes("--help") || args.includes("-h")) {
378
+ printHelp();
379
+ process.exit(0);
380
+ }
381
+
382
+ // Parse arguments
383
+ //
384
+ // Accepted forms:
385
+ // vellum terminal [--assistant <name>]
386
+ // vellum terminal list [--assistant <name>]
387
+ // vellum terminal attach <session> [--assistant <name>]
388
+ let subcommand: string | undefined;
389
+ let assistantName: string | undefined;
390
+ let tmuxSessionName: string | undefined;
391
+
392
+ for (let i = 0; i < args.length; i++) {
393
+ if (args[i] === "--assistant" && args[i + 1]) {
394
+ assistantName = args[++i];
395
+ } else if (args[i].startsWith("-")) {
396
+ // Skip unknown flags
397
+ continue;
398
+ } else if (!subcommand) {
399
+ // First positional — subcommand or assistant name
400
+ if (args[i] === "list" || args[i] === "attach") {
401
+ subcommand = args[i];
402
+ } else {
403
+ assistantName = args[i];
404
+ }
405
+ } else if (subcommand === "attach" && !tmuxSessionName) {
406
+ // Second positional after "attach" — tmux session name
407
+ tmuxSessionName = args[i];
408
+ } else if (!assistantName) {
409
+ // Trailing positional after subcommand args — assistant name
410
+ assistantName = args[i];
411
+ }
412
+ }
413
+
414
+ const assistant = resolveAssistant(assistantName);
415
+
416
+ if (subcommand === "list") {
417
+ await listTmuxSessions(assistant);
418
+ return;
419
+ }
420
+
421
+ if (subcommand === "attach") {
422
+ if (!tmuxSessionName) {
423
+ console.error("Usage: vellum terminal attach <session-name>");
424
+ console.error(
425
+ "\nUse 'vellum terminal list' to see available tmux sessions.",
426
+ );
427
+ process.exit(1);
428
+ }
429
+ // Shell-escape the session name to handle spaces/metacharacters
430
+ const escaped = tmuxSessionName.replace(/'/g, "'\\''");
431
+ await interactiveSession(assistant, `tmux attach -t '${escaped}'`);
432
+ return;
433
+ }
434
+
435
+ // Default: interactive shell
436
+ await interactiveSession(assistant);
437
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import { setup } from "./commands/setup";
20
20
  import { sleep } from "./commands/sleep";
21
21
  import { ssh } from "./commands/ssh";
22
22
  import { teleport } from "./commands/teleport";
23
+ import { terminal } from "./commands/terminal";
23
24
  import { tunnel } from "./commands/tunnel";
24
25
  import { upgrade } from "./commands/upgrade";
25
26
  import { use } from "./commands/use";
@@ -54,6 +55,7 @@ const commands = {
54
55
  sleep,
55
56
  ssh,
56
57
  teleport,
58
+ terminal,
57
59
  tunnel,
58
60
  upgrade,
59
61
  use,
@@ -91,6 +93,7 @@ function printHelp(): void {
91
93
  console.log(" sleep Stop the assistant process");
92
94
  console.log(" ssh SSH into a remote assistant instance");
93
95
  console.log(" teleport Transfer assistant data between environments");
96
+ console.log(" terminal Open a terminal into a managed assistant container");
94
97
  console.log(" tunnel Create a tunnel for a locally hosted assistant");
95
98
  console.log(" upgrade Upgrade an assistant to a newer version");
96
99
  console.log(" use Set the active assistant for commands");
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Platform terminal API client.
3
+ *
4
+ * Wraps the Django terminal session endpoints that proxy through vembda to
5
+ * open K8s exec streams into managed assistant containers. Same transport
6
+ * the web UI's xterm.js terminal uses.
7
+ */
8
+
9
+ import { authHeaders, getPlatformUrl } from "./platform-client.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Create / Close
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export async function createTerminalSession(
16
+ token: string,
17
+ assistantId: string,
18
+ cols: number,
19
+ rows: number,
20
+ platformUrl?: string,
21
+ ): Promise<{ session_id: string }> {
22
+ const baseUrl = platformUrl || getPlatformUrl();
23
+ const response = await fetch(
24
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/`,
25
+ {
26
+ method: "POST",
27
+ headers: await authHeaders(token, platformUrl),
28
+ body: JSON.stringify({ cols, rows }),
29
+ },
30
+ );
31
+ if (!response.ok) {
32
+ const detail = await response.text().catch(() => "");
33
+ throw new Error(
34
+ `Failed to create terminal session (${response.status}): ${detail || response.statusText}`,
35
+ );
36
+ }
37
+ return (await response.json()) as { session_id: string };
38
+ }
39
+
40
+ export async function closeTerminalSession(
41
+ token: string,
42
+ assistantId: string,
43
+ sessionId: string,
44
+ platformUrl?: string,
45
+ ): Promise<void> {
46
+ const baseUrl = platformUrl || getPlatformUrl();
47
+ const response = await fetch(
48
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/`,
49
+ {
50
+ method: "DELETE",
51
+ headers: await authHeaders(token, platformUrl),
52
+ },
53
+ );
54
+ // 404 = already closed, treat as success
55
+ if (!response.ok && response.status !== 404) {
56
+ throw new Error(
57
+ `Failed to close terminal session (${response.status}): ${response.statusText}`,
58
+ );
59
+ }
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Input / Resize
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export async function sendTerminalInput(
67
+ token: string,
68
+ assistantId: string,
69
+ sessionId: string,
70
+ data: string,
71
+ platformUrl?: string,
72
+ ): Promise<void> {
73
+ const baseUrl = platformUrl || getPlatformUrl();
74
+ const response = await fetch(
75
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/input/`,
76
+ {
77
+ method: "POST",
78
+ headers: await authHeaders(token, platformUrl),
79
+ body: JSON.stringify({ data }),
80
+ },
81
+ );
82
+ if (!response.ok) {
83
+ throw new Error(
84
+ `Failed to send terminal input (${response.status}): ${response.statusText}`,
85
+ );
86
+ }
87
+ }
88
+
89
+ export async function resizeTerminalSession(
90
+ token: string,
91
+ assistantId: string,
92
+ sessionId: string,
93
+ cols: number,
94
+ rows: number,
95
+ platformUrl?: string,
96
+ ): Promise<void> {
97
+ const baseUrl = platformUrl || getPlatformUrl();
98
+ const response = await fetch(
99
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/resize/`,
100
+ {
101
+ method: "POST",
102
+ headers: await authHeaders(token, platformUrl),
103
+ body: JSON.stringify({ cols, rows }),
104
+ },
105
+ );
106
+ if (!response.ok) {
107
+ throw new Error(
108
+ `Failed to resize terminal (${response.status}): ${response.statusText}`,
109
+ );
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // SSE event stream
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export interface TerminalOutputEvent {
118
+ seq: number;
119
+ /** Base64-encoded PTY output bytes. */
120
+ data: string;
121
+ }
122
+
123
+ /**
124
+ * Subscribe to the terminal output SSE stream. Yields parsed events as they
125
+ * arrive. The generator completes when the stream ends or is aborted.
126
+ */
127
+ export async function* subscribeTerminalEvents(
128
+ token: string,
129
+ assistantId: string,
130
+ sessionId: string,
131
+ platformUrl?: string,
132
+ signal?: AbortSignal,
133
+ ): AsyncGenerator<TerminalOutputEvent> {
134
+ const baseUrl = platformUrl || getPlatformUrl();
135
+ const response = await fetch(
136
+ `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/events/`,
137
+ {
138
+ headers: await authHeaders(token, platformUrl),
139
+ signal,
140
+ },
141
+ );
142
+
143
+ if (!response.ok || !response.body) {
144
+ throw new Error(
145
+ `SSE connection failed (${response.status}): ${response.statusText}`,
146
+ );
147
+ }
148
+
149
+ const reader = response.body.getReader();
150
+ const decoder = new TextDecoder();
151
+ let buffer = "";
152
+
153
+ try {
154
+ while (true) {
155
+ const { done, value } = await reader.read();
156
+ if (done) break;
157
+
158
+ buffer += decoder.decode(value, { stream: true });
159
+ const lines = buffer.split("\n");
160
+ // Keep the last incomplete line in the buffer
161
+ buffer = lines.pop() ?? "";
162
+
163
+ for (const line of lines) {
164
+ const trimmed = line.trimEnd();
165
+ if (trimmed.startsWith("data: ")) {
166
+ try {
167
+ yield JSON.parse(trimmed.slice(6)) as TerminalOutputEvent;
168
+ } catch {
169
+ // Skip malformed SSE frames
170
+ }
171
+ }
172
+ }
173
+ }
174
+ } finally {
175
+ reader.releaseLock();
176
+ }
177
+ }