@vellumai/cli 0.4.49 → 0.4.50

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.4.49",
3
+ "version": "0.4.50",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6,7 +6,7 @@ import {
6
6
  loadAllAssistants,
7
7
  type AssistantEntry,
8
8
  } from "../lib/assistant-config";
9
- import { checkHealth } from "../lib/health-check";
9
+ import { checkHealth, checkManagedHealth } from "../lib/health-check";
10
10
  import {
11
11
  classifyProcess,
12
12
  detectOrphanedProcesses,
@@ -359,6 +359,8 @@ async function listAllAssistants(): Promise<void> {
359
359
  } else {
360
360
  health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
361
361
  }
362
+ } else if (a.cloud === "vellum") {
363
+ health = await checkManagedHealth(a.runtimeUrl, a.assistantId);
362
364
  } else {
363
365
  health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
364
366
  }
@@ -500,6 +500,9 @@ async function handleScopeSelection(
500
500
 
501
501
  export const TYPING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
502
502
 
503
+ /** ASCII-safe spinner frames for the connection screen. */
504
+ const CONNECTION_SPINNER_FRAMES = ["|", "/", "-", "\\"];
505
+
503
506
  export interface ToolCallInfo {
504
507
  name: string;
505
508
  input: Record<string, unknown>;
@@ -675,6 +678,75 @@ function SpinnerDisplay({ text }: { text: string }): ReactElement {
675
678
  );
676
679
  }
677
680
 
681
+ type ConnectionState = "connecting" | "connected" | "error";
682
+
683
+ function ConnectionScreen({
684
+ state,
685
+ errorMessage,
686
+ species,
687
+ terminalRows,
688
+ terminalColumns,
689
+ onRetry,
690
+ onExit,
691
+ }: {
692
+ state: ConnectionState;
693
+ errorMessage?: string;
694
+ species: Species;
695
+ terminalRows: number;
696
+ terminalColumns: number;
697
+ onRetry: () => void;
698
+ onExit: () => void;
699
+ }): ReactElement {
700
+ const [frameIndex, setFrameIndex] = useState(0);
701
+
702
+ useEffect(() => {
703
+ if (state !== "connecting") return;
704
+ const timer = setInterval(() => {
705
+ setFrameIndex((prev) => (prev + 1) % CONNECTION_SPINNER_FRAMES.length);
706
+ }, 150);
707
+ return () => clearInterval(timer);
708
+ }, [state]);
709
+
710
+ useInput((input, key) => {
711
+ if (key.ctrl && input === "c") {
712
+ onExit();
713
+ }
714
+ if (state === "error" && input === "r") {
715
+ onRetry();
716
+ }
717
+ });
718
+
719
+ const config = SPECIES_CONFIG[species];
720
+ const title = `Vellum ${config.hatchedEmoji} ${species}`;
721
+ const width = Math.min(terminalColumns, MAX_TOTAL_WIDTH);
722
+
723
+ return (
724
+ <Box
725
+ flexDirection="column"
726
+ height={terminalRows}
727
+ width={width}
728
+ justifyContent="center"
729
+ alignItems="center"
730
+ >
731
+ <Text dimColor bold>
732
+ {title}
733
+ </Text>
734
+ <Text> </Text>
735
+ {state === "connecting" ? (
736
+ <Text dimColor>
737
+ {CONNECTION_SPINNER_FRAMES[frameIndex]} Connecting to assistant...
738
+ </Text>
739
+ ) : (
740
+ <>
741
+ <Text color="red">Failed to connect: {errorMessage}</Text>
742
+ <Text> </Text>
743
+ <Text dimColor>Press r to retry or Ctrl+C to quit</Text>
744
+ </>
745
+ )}
746
+ </Box>
747
+ );
748
+ }
749
+
678
750
  export function renderErrorMainScreen(error: unknown): number {
679
751
  const msg = error instanceof Error ? error.message : String(error);
680
752
  console.log(
@@ -1224,6 +1296,11 @@ function ChatApp({
1224
1296
  const [healthStatus, setHealthStatus] = useState<string | undefined>(
1225
1297
  undefined,
1226
1298
  );
1299
+ const [connectionState, setConnectionState] =
1300
+ useState<ConnectionState>("connecting");
1301
+ const [connectionError, setConnectionError] = useState<string | undefined>(
1302
+ undefined,
1303
+ );
1227
1304
  const prevFeedLengthRef = useRef(0);
1228
1305
  const busyRef = useRef(false);
1229
1306
  const connectedRef = useRef(false);
@@ -1240,7 +1317,7 @@ function ChatApp({
1240
1317
  const headerHeight = calculateHeaderHeight(species, terminalColumns);
1241
1318
 
1242
1319
  const isCompact = terminalColumns < COMPACT_THRESHOLD;
1243
- const compactInputAreaHeight = 2; // separator + input row only
1320
+ const compactInputAreaHeight = 1; // input row only, no separators
1244
1321
  const inputAreaHeight = isCompact
1245
1322
  ? compactInputAreaHeight
1246
1323
  : INPUT_AREA_HEIGHT;
@@ -1476,6 +1553,8 @@ function ChatApp({
1476
1553
  return false;
1477
1554
  }
1478
1555
  connectingRef.current = true;
1556
+ setConnectionState("connecting");
1557
+ setConnectionError(undefined);
1479
1558
  const h = handleRef_.current;
1480
1559
 
1481
1560
  h.showSpinner("Connecting...");
@@ -1538,12 +1617,15 @@ function ChatApp({
1538
1617
 
1539
1618
  connectedRef.current = true;
1540
1619
  connectingRef.current = false;
1620
+ setConnectionState("connected");
1541
1621
  return true;
1542
1622
  } catch (err) {
1543
1623
  h.hideSpinner();
1544
1624
  connectingRef.current = false;
1545
1625
  h.updateHealthStatus("unreachable");
1546
1626
  const msg = err instanceof Error ? err.message : String(err);
1627
+ setConnectionState("error");
1628
+ setConnectionError(msg);
1547
1629
  h.addStatus(
1548
1630
  `${statusEmoji("unreachable")} Failed to connect: ${msg}`,
1549
1631
  "red",
@@ -2065,6 +2147,7 @@ function ChatApp({
2065
2147
  role: "assistant",
2066
2148
  content: msg.content,
2067
2149
  });
2150
+ process.stdout.write("\x07");
2068
2151
  h.setBusy(false);
2069
2152
  h.hideSpinner();
2070
2153
  return;
@@ -2174,6 +2257,13 @@ function ChatApp({
2174
2257
  updateHealthStatus,
2175
2258
  ]);
2176
2259
 
2260
+ const retryConnection = useCallback(() => {
2261
+ if (connectingRef.current) return; // already retrying
2262
+ connectedRef.current = false;
2263
+ setConnectionState("connecting");
2264
+ ensureConnected();
2265
+ }, [ensureConnected]);
2266
+
2177
2267
  useEffect(() => {
2178
2268
  ensureConnected();
2179
2269
  }, [ensureConnected]);
@@ -2262,6 +2352,20 @@ function ChatApp({
2262
2352
  }
2263
2353
  }, [selection]);
2264
2354
 
2355
+ if (connectionState !== "connected") {
2356
+ return (
2357
+ <ConnectionScreen
2358
+ state={connectionState}
2359
+ errorMessage={connectionError}
2360
+ species={species}
2361
+ terminalRows={terminalRows}
2362
+ terminalColumns={terminalColumns}
2363
+ onRetry={retryConnection}
2364
+ onExit={onExit}
2365
+ />
2366
+ );
2367
+ }
2368
+
2265
2369
  return (
2266
2370
  <Box flexDirection="column" height={terminalRows}>
2267
2371
  <DefaultMainScreen
@@ -2274,8 +2378,9 @@ function ChatApp({
2274
2378
  <Box flexDirection="column" flexGrow={1} overflow="hidden">
2275
2379
  {visibleWindow.hiddenAbove > 0 ? (
2276
2380
  <Text dimColor>
2277
- {"\u2191"} {visibleWindow.hiddenAbove} more above
2278
- (Shift+\u2191/Cmd+\u2191)
2381
+ {isCompact
2382
+ ? `\u2191 ${visibleWindow.hiddenAbove} more above`
2383
+ : `\u2191 ${visibleWindow.hiddenAbove} more above (Shift+\u2191/Cmd+\u2191)`}
2279
2384
  </Text>
2280
2385
  ) : null}
2281
2386
 
@@ -2341,12 +2446,14 @@ function ChatApp({
2341
2446
 
2342
2447
  {!selection && !secretInput ? (
2343
2448
  <Box flexDirection="column" flexShrink={0}>
2344
- <Text dimColor>
2345
- {unicodeOrFallback("\u2500", "-").repeat(terminalColumns)}
2346
- </Text>
2347
- <Box paddingLeft={1} height={1} flexShrink={0}>
2449
+ {isCompact ? null : (
2450
+ <Text dimColor>
2451
+ {unicodeOrFallback("\u2500", "-").repeat(terminalColumns)}
2452
+ </Text>
2453
+ )}
2454
+ <Box paddingLeft={isCompact ? 0 : 1} height={1} flexShrink={0}>
2348
2455
  <Text color="green" bold>
2349
- you{">"}
2456
+ {isCompact ? ">" : "you>"}
2350
2457
  {" "}
2351
2458
  </Text>
2352
2459
  <TextInput
package/src/index.ts CHANGED
@@ -15,6 +15,13 @@ import { ssh } from "./commands/ssh";
15
15
  import { tunnel } from "./commands/tunnel";
16
16
  import { use } from "./commands/use";
17
17
  import { wake } from "./commands/wake";
18
+ import {
19
+ getActiveAssistant,
20
+ findAssistantByName,
21
+ loadLatestAssistant,
22
+ setActiveAssistant,
23
+ } from "./lib/assistant-config";
24
+ import { checkHealth } from "./lib/health-check";
18
25
 
19
26
  const commands = {
20
27
  clean,
@@ -37,37 +44,106 @@ const commands = {
37
44
 
38
45
  type CommandName = keyof typeof commands;
39
46
 
47
+ function printHelp(): void {
48
+ console.log("Usage: vellum <command> [options]");
49
+ console.log("");
50
+ console.log("Commands:");
51
+ console.log(" clean Kill orphaned vellum processes");
52
+ console.log(" client Connect to a hatched assistant");
53
+ console.log(" hatch Create a new assistant instance");
54
+ console.log(" login Log in to the Vellum platform");
55
+ console.log(" logout Log out of the Vellum platform");
56
+ console.log(" pair Pair with a remote assistant via QR code");
57
+ console.log(
58
+ " ps List assistants (or processes for a specific assistant)",
59
+ );
60
+ console.log(" recover Restore a previously retired local assistant");
61
+ console.log(" retire Delete an assistant instance");
62
+ console.log(" setup Configure API keys interactively");
63
+ console.log(" sleep Stop the assistant process");
64
+ console.log(" ssh SSH into a remote assistant instance");
65
+ console.log(" tunnel Create a tunnel for a locally hosted assistant");
66
+ console.log(" use Set the active assistant for commands");
67
+ console.log(" wake Start the assistant and gateway");
68
+ console.log(" whoami Show current logged-in user");
69
+ console.log("");
70
+ console.log("Options:");
71
+ console.log(
72
+ " --no-color, --plain Disable colored output (honors NO_COLOR env)",
73
+ );
74
+ console.log(" --version, -v Show version");
75
+ console.log(" --help, -h Show this help");
76
+ }
77
+
78
+ /**
79
+ * Check for --no-color / --plain flags and set NO_COLOR env var
80
+ * before any terminal capability detection runs.
81
+ *
82
+ * Per https://no-color.org/, setting NO_COLOR to any non-empty value
83
+ * signals that color output should be suppressed.
84
+ */
85
+ function applyNoColorFlags(argv: string[]): void {
86
+ if (argv.includes("--no-color") || argv.includes("--plain")) {
87
+ process.env.NO_COLOR = "1";
88
+ }
89
+ }
90
+
91
+ /**
92
+ * If a running assistant is detected, launch the TUI client and return true.
93
+ * Otherwise return false so the caller can fall back to help text.
94
+ */
95
+ async function tryLaunchClient(): Promise<boolean> {
96
+ const activeName = getActiveAssistant();
97
+ const entry = activeName
98
+ ? findAssistantByName(activeName)
99
+ : loadLatestAssistant();
100
+
101
+ if (!entry) return false;
102
+
103
+ const url = entry.localUrl || entry.runtimeUrl;
104
+ if (!url) return false;
105
+
106
+ const result = await checkHealth(url, entry.bearerToken);
107
+ if (result.status !== "healthy") return false;
108
+
109
+ // Ensure the resolved assistant is active so client() can find it
110
+ // (client() independently reads the active assistant from config).
111
+ setActiveAssistant(String(entry.assistantId));
112
+
113
+ await client();
114
+ return true;
115
+ }
116
+
40
117
  async function main() {
41
118
  const args = process.argv.slice(2);
42
- const commandName = args[0];
119
+
120
+ // Must run before any command or terminal-capabilities usage
121
+ applyNoColorFlags(args);
122
+
123
+ // Global flags that are not command names
124
+ const GLOBAL_FLAGS = new Set(["--no-color", "--plain"]);
125
+ const commandName = args.find((a) => !GLOBAL_FLAGS.has(a));
126
+
127
+ // Strip global flags from process.argv so subcommands that parse
128
+ // process.argv.slice(3) don't see them as positional arguments.
129
+ const filteredArgs = args.filter((a) => !GLOBAL_FLAGS.has(a));
130
+ process.argv = [...process.argv.slice(0, 2), ...filteredArgs];
43
131
 
44
132
  if (commandName === "--version" || commandName === "-v") {
45
133
  console.log(`@vellumai/cli v${cliPkg.version}`);
46
134
  process.exit(0);
47
135
  }
48
136
 
49
- if (!commandName || commandName === "--help" || commandName === "-h") {
50
- console.log("Usage: vellum <command> [options]");
51
- console.log("");
52
- console.log("Commands:");
53
- console.log(" clean Kill orphaned vellum processes");
54
- console.log(" client Connect to a hatched assistant");
55
- console.log(" hatch Create a new assistant instance");
56
- console.log(" login Log in to the Vellum platform");
57
- console.log(" logout Log out of the Vellum platform");
58
- console.log(" pair Pair with a remote assistant via QR code");
59
- console.log(
60
- " ps List assistants (or processes for a specific assistant)",
61
- );
62
- console.log(" recover Restore a previously retired local assistant");
63
- console.log(" retire Delete an assistant instance");
64
- console.log(" setup Configure API keys interactively");
65
- console.log(" sleep Stop the assistant process");
66
- console.log(" ssh SSH into a remote assistant instance");
67
- console.log(" tunnel Create a tunnel for a locally hosted assistant");
68
- console.log(" use Set the active assistant for commands");
69
- console.log(" wake Start the assistant and gateway");
70
- console.log(" whoami Show current logged-in user");
137
+ if (commandName === "--help" || commandName === "-h") {
138
+ printHelp();
139
+ process.exit(0);
140
+ }
141
+
142
+ if (!commandName) {
143
+ const launched = await tryLaunchClient();
144
+ if (!launched) {
145
+ printHelp();
146
+ }
71
147
  process.exit(0);
72
148
  }
73
149
 
package/src/lib/docker.ts CHANGED
@@ -7,7 +7,6 @@ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
7
7
  import type { AssistantEntry } from "./assistant-config";
8
8
  import { DEFAULT_GATEWAY_PORT } from "./constants";
9
9
  import type { Species } from "./constants";
10
- import { discoverPublicUrl } from "./local";
11
10
  import { generateRandomSuffix } from "./random-name";
12
11
  import { exec, execOutput } from "./step-runner";
13
12
  import {
@@ -34,6 +33,36 @@ async function ensureDockerInstalled(): Promise<void> {
34
33
  }
35
34
 
36
35
  if (!installed) {
36
+ // Check whether Homebrew is available before attempting to use it.
37
+ let hasBrew = false;
38
+ try {
39
+ await execOutput("brew", ["--version"]);
40
+ hasBrew = true;
41
+ } catch {
42
+ // brew not found
43
+ }
44
+
45
+ if (!hasBrew) {
46
+ console.log("🍺 Homebrew not found. Installing Homebrew...");
47
+ try {
48
+ await exec("bash", [
49
+ "-c",
50
+ 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
51
+ ]);
52
+ } catch (err) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ throw new Error(
55
+ `Failed to install Homebrew. Please install Docker manually from https://www.docker.com/products/docker-desktop/\n${message}`,
56
+ );
57
+ }
58
+
59
+ // Homebrew on Apple Silicon installs to /opt/homebrew; add it to PATH
60
+ // so subsequent brew/colima/docker invocations work in this session.
61
+ if (!process.env.PATH?.includes("/opt/homebrew")) {
62
+ process.env.PATH = `/opt/homebrew/bin:/opt/homebrew/sbin:${process.env.PATH}`;
63
+ }
64
+ }
65
+
37
66
  console.log("🐳 Docker not found. Installing via Homebrew...");
38
67
  try {
39
68
  await exec("brew", ["install", "colima", "docker"]);
@@ -58,6 +87,21 @@ async function ensureDockerInstalled(): Promise<void> {
58
87
  try {
59
88
  await exec("docker", ["info"]);
60
89
  } catch {
90
+ let hasColima = false;
91
+ try {
92
+ await execOutput("colima", ["version"]);
93
+ hasColima = true;
94
+ } catch {
95
+ // colima not found
96
+ }
97
+
98
+ if (!hasColima) {
99
+ throw new Error(
100
+ "Docker daemon is not running and Colima is not installed.\n" +
101
+ "Please start Docker Desktop, or install Colima with 'brew install colima' and run 'colima start'.",
102
+ );
103
+ }
104
+
61
105
  console.log("🚀 Docker daemon not running. Starting Colima...");
62
106
  try {
63
107
  await exec("colima", ["start"]);
@@ -326,8 +370,10 @@ export async function hatchDocker(
326
370
  );
327
371
  }
328
372
 
329
- const publicUrl = await discoverPublicUrl(gatewayPort);
330
- const runtimeUrl = publicUrl || `http://localhost:${gatewayPort}`;
373
+ // Docker containers bind to 0.0.0.0 so localhost always works. Skip
374
+ // mDNS/LAN discovery the .local hostname often fails to resolve on the
375
+ // host machine itself (mDNS is designed for cross-device discovery).
376
+ const runtimeUrl = `http://localhost:${gatewayPort}`;
331
377
  const dockerEntry: AssistantEntry = {
332
378
  assistantId: instanceName,
333
379
  runtimeUrl,
@@ -10,6 +10,93 @@ export interface HealthCheckResult {
10
10
  detail: string | null;
11
11
  }
12
12
 
13
+ interface OrgListResponse {
14
+ results: { id: string }[];
15
+ }
16
+
17
+ async function fetchOrganizationId(
18
+ platformUrl: string,
19
+ token: string,
20
+ ): Promise<{ orgId: string } | { error: string }> {
21
+ try {
22
+ const response = await fetch(`${platformUrl}/v1/organizations/`, {
23
+ headers: { "X-Session-Token": token },
24
+ });
25
+ if (!response.ok) {
26
+ return { error: `org lookup failed (${response.status})` };
27
+ }
28
+ const body = (await response.json()) as OrgListResponse;
29
+ const orgId = body.results?.[0]?.id;
30
+ if (!orgId) {
31
+ return { error: "no organization found" };
32
+ }
33
+ return { orgId };
34
+ } catch {
35
+ return { error: "org lookup unreachable" };
36
+ }
37
+ }
38
+
39
+ export async function checkManagedHealth(
40
+ runtimeUrl: string,
41
+ assistantId: string,
42
+ ): Promise<HealthCheckResult> {
43
+ const { readPlatformToken } = await import("./platform-client.js");
44
+ const token = readPlatformToken();
45
+ if (!token) {
46
+ return {
47
+ status: "error (auth)",
48
+ detail: "not logged in — run `vellum login`",
49
+ };
50
+ }
51
+
52
+ const orgResult = await fetchOrganizationId(runtimeUrl, token);
53
+ if ("error" in orgResult) {
54
+ return {
55
+ status: "error (auth)",
56
+ detail: orgResult.error,
57
+ };
58
+ }
59
+ const { orgId } = orgResult;
60
+
61
+ try {
62
+ const url = `${runtimeUrl}/v1/assistants/${encodeURIComponent(assistantId)}/healthz/`;
63
+ const controller = new AbortController();
64
+ const timeoutId = setTimeout(
65
+ () => controller.abort(),
66
+ HEALTH_CHECK_TIMEOUT_MS,
67
+ );
68
+
69
+ const headers: Record<string, string> = {
70
+ "X-Session-Token": token,
71
+ "Vellum-Organization-Id": orgId,
72
+ };
73
+
74
+ const response = await fetch(url, {
75
+ signal: controller.signal,
76
+ headers,
77
+ });
78
+
79
+ clearTimeout(timeoutId);
80
+
81
+ if (!response.ok) {
82
+ return { status: `error (${response.status})`, detail: null };
83
+ }
84
+
85
+ const data = (await response.json()) as HealthResponse;
86
+ const status = data.status || "unknown";
87
+ return {
88
+ status,
89
+ detail: status !== "healthy" ? (data.message ?? null) : null,
90
+ };
91
+ } catch (error) {
92
+ const status =
93
+ error instanceof Error && error.name === "AbortError"
94
+ ? "timeout"
95
+ : "unreachable";
96
+ return { status, detail: null };
97
+ }
98
+ }
99
+
13
100
  export async function checkHealth(
14
101
  runtimeUrl: string,
15
102
  bearerToken?: string,
@@ -107,6 +107,15 @@ export function getTerminalCapabilities(): TerminalCapabilities {
107
107
  return _cached;
108
108
  }
109
109
 
110
+ /**
111
+ * Clear the cached capabilities so the next `getTerminalCapabilities()`
112
+ * call re-detects from the current environment. Useful after modifying
113
+ * `process.env.NO_COLOR` at startup or in tests.
114
+ */
115
+ export function resetCapabilitiesCache(): void {
116
+ _cached = undefined;
117
+ }
118
+
110
119
  // ── Convenience helpers ──────────────────────────────────────
111
120
 
112
121
  /** True when colors should be used (any level above "none"). */