@vellumai/cli 0.4.48 → 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.
@@ -6,6 +6,9 @@ interface TextInputProps {
6
6
  value: string;
7
7
  onChange: (value: string) => void;
8
8
  onSubmit?: (value: string) => void;
9
+ onHistoryUp?: () => void;
10
+ onHistoryDown?: () => void;
11
+ completionCommands?: string[];
9
12
  focus?: boolean;
10
13
  placeholder?: string;
11
14
  }
@@ -14,12 +17,19 @@ function TextInput({
14
17
  value,
15
18
  onChange,
16
19
  onSubmit,
20
+ onHistoryUp,
21
+ onHistoryDown,
22
+ completionCommands,
17
23
  focus = true,
18
24
  placeholder = "",
19
25
  }: TextInputProps): ReactElement {
20
26
  const cursorOffsetRef = useRef(value.length);
21
27
  const valueRef = useRef(value);
22
28
 
29
+ // Tab completion state
30
+ const [completionIndex, setCompletionIndex] = useState(-1);
31
+ const [completionMatches, setCompletionMatches] = useState<string[]>([]);
32
+
23
33
  valueRef.current = value;
24
34
 
25
35
  if (cursorOffsetRef.current > value.length) {
@@ -28,40 +38,162 @@ function TextInput({
28
38
 
29
39
  const [, setRenderTick] = useState(0);
30
40
 
41
+ const clearCompletion = () => {
42
+ setCompletionIndex(-1);
43
+ setCompletionMatches([]);
44
+ };
45
+
46
+ const getMatches = (text: string): string[] => {
47
+ if (!completionCommands || !text.startsWith("/") || text.includes(" ")) {
48
+ return [];
49
+ }
50
+ const prefix = text.toLowerCase();
51
+ return completionCommands.filter((cmd) =>
52
+ cmd.toLowerCase().startsWith(prefix),
53
+ );
54
+ };
55
+
31
56
  useInput(
32
57
  (input, key) => {
33
- if (
34
- key.upArrow ||
35
- key.downArrow ||
36
- (key.ctrl && input === "c") ||
37
- key.tab ||
38
- (key.shift && key.tab)
39
- ) {
58
+ if (key.upArrow && !key.shift && !key.meta) {
59
+ clearCompletion();
60
+ onHistoryUp?.();
61
+ cursorOffsetRef.current = Infinity;
62
+ setRenderTick((t) => t + 1);
63
+ return;
64
+ }
65
+ if (key.downArrow && !key.shift && !key.meta) {
66
+ clearCompletion();
67
+ onHistoryDown?.();
68
+ cursorOffsetRef.current = Infinity;
69
+ setRenderTick((t) => t + 1);
70
+ return;
71
+ }
72
+ if (key.ctrl && input === "c") {
73
+ return;
74
+ }
75
+
76
+ // Tab completion handling
77
+ if (key.tab) {
78
+ const currentValue = valueRef.current;
79
+
80
+ if (completionMatches.length > 0) {
81
+ // Already in completion mode — cycle through matches
82
+ const direction = key.shift ? -1 : 1;
83
+ const nextIndex =
84
+ (completionIndex + direction + completionMatches.length) %
85
+ completionMatches.length;
86
+ setCompletionIndex(nextIndex);
87
+
88
+ const completed = completionMatches[nextIndex]!;
89
+ valueRef.current = completed;
90
+ cursorOffsetRef.current = completed.length;
91
+ onChange(completed);
92
+ setRenderTick((t) => t + 1);
93
+ return;
94
+ }
95
+
96
+ // Start completion mode
97
+ const matches = getMatches(currentValue);
98
+ if (matches.length === 1) {
99
+ // Single match — accept immediately with trailing space
100
+ const completed = matches[0]! + " ";
101
+ valueRef.current = completed;
102
+ cursorOffsetRef.current = completed.length;
103
+ onChange(completed);
104
+ setRenderTick((t) => t + 1);
105
+ } else if (matches.length > 1) {
106
+ setCompletionMatches(matches);
107
+ const idx = key.shift ? matches.length - 1 : 0;
108
+ setCompletionIndex(idx);
109
+
110
+ const completed = matches[idx]!;
111
+ valueRef.current = completed;
112
+ cursorOffsetRef.current = completed.length;
113
+ onChange(completed);
114
+ setRenderTick((t) => t + 1);
115
+ }
40
116
  return;
41
117
  }
42
118
 
119
+ // Escape cancels completion mode
120
+ if (key.escape) {
121
+ if (completionMatches.length > 0) {
122
+ clearCompletion();
123
+ setRenderTick((t) => t + 1);
124
+ return;
125
+ }
126
+ }
127
+
128
+ // Enter accepts completion and submits
43
129
  if (key.return) {
44
- onSubmit?.(valueRef.current);
130
+ if (completionMatches.length > 0) {
131
+ // Append trailing space so the command is recognized by handleInput
132
+ const completed = valueRef.current + " ";
133
+ valueRef.current = completed;
134
+ cursorOffsetRef.current = completed.length;
135
+ onChange(completed);
136
+ clearCompletion();
137
+ onSubmit?.(completed);
138
+ } else {
139
+ clearCompletion();
140
+ onSubmit?.(valueRef.current);
141
+ }
45
142
  return;
46
143
  }
47
144
 
145
+ // Space accepts completion, then continues editing
146
+ if (input === " " && completionMatches.length > 0) {
147
+ clearCompletion();
148
+ // Let the space be inserted normally below
149
+ } else if (completionMatches.length > 0) {
150
+ // Any other key exits completion mode
151
+ clearCompletion();
152
+ }
153
+
48
154
  const currentValue = valueRef.current;
49
155
  const currentOffset = cursorOffsetRef.current;
50
156
  let nextValue = currentValue;
51
157
  let nextOffset = currentOffset;
52
158
 
53
- if (key.leftArrow) {
159
+ if (key.ctrl && input === "a") {
160
+ // Ctrl+A — move cursor to start
161
+ nextOffset = 0;
162
+ } else if (key.ctrl && input === "e") {
163
+ // Ctrl+E — move cursor to end
164
+ nextOffset = currentValue.length;
165
+ } else if (key.ctrl && input === "u") {
166
+ // Ctrl+U — clear line before cursor
167
+ nextValue = currentValue.slice(currentOffset);
168
+ nextOffset = 0;
169
+ } else if (key.ctrl && input === "k") {
170
+ // Ctrl+K — kill from cursor to end
171
+ nextValue = currentValue.slice(0, currentOffset);
172
+ } else if (key.ctrl && input === "w") {
173
+ // Ctrl+W — delete word backwards (handles tabs and other whitespace)
174
+ const before = currentValue.slice(0, currentOffset);
175
+ // Skip trailing whitespace, then find previous whitespace boundary
176
+ const match = before.match(/^(.*\s)?\S+\s*$/);
177
+ const wordStart = match?.[1]?.length ?? 0;
178
+ nextValue =
179
+ currentValue.slice(0, wordStart) + currentValue.slice(currentOffset);
180
+ nextOffset = wordStart;
181
+ } else if (key.leftArrow) {
54
182
  nextOffset = Math.max(0, currentOffset - 1);
55
183
  } else if (key.rightArrow) {
56
184
  nextOffset = Math.min(currentValue.length, currentOffset + 1);
57
185
  } else if (key.backspace || key.delete) {
58
186
  if (currentOffset > 0) {
59
- nextValue = currentValue.slice(0, currentOffset - 1) + currentValue.slice(currentOffset);
187
+ nextValue =
188
+ currentValue.slice(0, currentOffset - 1) +
189
+ currentValue.slice(currentOffset);
60
190
  nextOffset = currentOffset - 1;
61
191
  }
62
192
  } else {
63
193
  nextValue =
64
- currentValue.slice(0, currentOffset) + input + currentValue.slice(currentOffset);
194
+ currentValue.slice(0, currentOffset) +
195
+ input +
196
+ currentValue.slice(currentOffset);
65
197
  nextOffset = currentOffset + input.length;
66
198
  }
67
199
 
@@ -78,6 +210,14 @@ function TextInput({
78
210
  );
79
211
 
80
212
  const cursorOffset = cursorOffsetRef.current;
213
+ const isCompleting = completionMatches.length > 0;
214
+
215
+ // Build completion hint text
216
+ let completionHint = "";
217
+ if (isCompleting && completionMatches.length > 1) {
218
+ completionHint = ` [${completionIndex + 1}/${completionMatches.length}]`;
219
+ }
220
+
81
221
  let renderedValue: string;
82
222
  let renderedPlaceholder: string | undefined;
83
223
 
@@ -97,6 +237,9 @@ function TextInput({
97
237
  if (cursorOffset === value.length) {
98
238
  renderedValue += chalk.inverse(" ");
99
239
  }
240
+ if (completionHint) {
241
+ renderedValue += chalk.grey(completionHint);
242
+ }
100
243
  } else {
101
244
  renderedValue = chalk.inverse(" ");
102
245
  }
@@ -107,7 +250,11 @@ function TextInput({
107
250
 
108
251
  return (
109
252
  <Text>
110
- {placeholder ? (value.length > 0 ? renderedValue : renderedPlaceholder) : renderedValue}
253
+ {placeholder
254
+ ? value.length > 0
255
+ ? renderedValue
256
+ : renderedPlaceholder
257
+ : renderedValue}
111
258
  </Text>
112
259
  );
113
260
  }
package/src/index.ts CHANGED
@@ -9,11 +9,19 @@ import { pair } from "./commands/pair";
9
9
  import { ps } from "./commands/ps";
10
10
  import { recover } from "./commands/recover";
11
11
  import { retire } from "./commands/retire";
12
+ import { setup } from "./commands/setup";
12
13
  import { sleep } from "./commands/sleep";
13
14
  import { ssh } from "./commands/ssh";
14
15
  import { tunnel } from "./commands/tunnel";
15
16
  import { use } from "./commands/use";
16
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";
17
25
 
18
26
  const commands = {
19
27
  clean,
@@ -25,6 +33,7 @@ const commands = {
25
33
  ps,
26
34
  recover,
27
35
  retire,
36
+ setup,
28
37
  sleep,
29
38
  ssh,
30
39
  tunnel,
@@ -35,36 +44,106 @@ const commands = {
35
44
 
36
45
  type CommandName = keyof typeof commands;
37
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
+
38
117
  async function main() {
39
118
  const args = process.argv.slice(2);
40
- 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];
41
131
 
42
132
  if (commandName === "--version" || commandName === "-v") {
43
133
  console.log(`@vellumai/cli v${cliPkg.version}`);
44
134
  process.exit(0);
45
135
  }
46
136
 
47
- if (!commandName || commandName === "--help" || commandName === "-h") {
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(" sleep Stop the assistant process");
63
- console.log(" ssh SSH into a remote assistant instance");
64
- console.log(" tunnel Create a tunnel for a locally hosted assistant");
65
- console.log(" use Set the active assistant for commands");
66
- console.log(" wake Start the assistant and gateway");
67
- 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
+ }
68
147
  process.exit(0);
69
148
  }
70
149
 
@@ -190,11 +190,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
190
190
  mutated = true;
191
191
  }
192
192
  if (typeof res.pidFile !== "string") {
193
- res.pidFile = join(
194
- res.instanceDir as string,
195
- ".vellum",
196
- "vellum.pid",
197
- );
193
+ res.pidFile = join(res.instanceDir as string, ".vellum", "vellum.pid");
198
194
  mutated = true;
199
195
  }
200
196
  }
@@ -363,7 +359,6 @@ export async function allocateLocalResources(
363
359
  if (existingLocals.length === 0) {
364
360
  const home = homedir();
365
361
  const vellumDir = join(home, ".vellum");
366
- mkdirSync(vellumDir, { recursive: true });
367
362
  return {
368
363
  instanceDir: home,
369
364
  daemonPort: DEFAULT_DAEMON_PORT,
@@ -8,7 +8,13 @@ export const DEFAULT_DAEMON_PORT = 7821;
8
8
  export const DEFAULT_GATEWAY_PORT = 7830;
9
9
  export const DEFAULT_QDRANT_PORT = 6333;
10
10
 
11
- export const VALID_REMOTE_HOSTS = ["local", "gcp", "aws", "docker", "custom"] as const;
11
+ export const VALID_REMOTE_HOSTS = [
12
+ "local",
13
+ "gcp",
14
+ "aws",
15
+ "docker",
16
+ "custom",
17
+ ] as const;
12
18
  export type RemoteHost = (typeof VALID_REMOTE_HOSTS)[number];
13
19
  export const VALID_SPECIES = ["openclaw", "vellum"] as const;
14
20
  export type Species = (typeof VALID_SPECIES)[number];
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 {
@@ -19,6 +18,102 @@ import {
19
18
 
20
19
  const _require = createRequire(import.meta.url);
21
20
 
21
+ /**
22
+ * Checks whether the `docker` CLI and daemon are available on the system.
23
+ * Installs Colima and Docker via Homebrew if the CLI is missing, and starts
24
+ * Colima if the Docker daemon is not reachable.
25
+ */
26
+ async function ensureDockerInstalled(): Promise<void> {
27
+ let installed = false;
28
+ try {
29
+ await execOutput("docker", ["--version"]);
30
+ installed = true;
31
+ } catch {
32
+ // docker CLI not found — install it
33
+ }
34
+
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
+
66
+ console.log("🐳 Docker not found. Installing via Homebrew...");
67
+ try {
68
+ await exec("brew", ["install", "colima", "docker"]);
69
+ } catch (err) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ throw new Error(
72
+ `Failed to install Docker via Homebrew. Please install Docker manually.\n${message}`,
73
+ );
74
+ }
75
+
76
+ try {
77
+ await execOutput("docker", ["--version"]);
78
+ } catch {
79
+ throw new Error(
80
+ "Docker was installed but is still not available on PATH. " +
81
+ "You may need to restart your terminal.",
82
+ );
83
+ }
84
+ }
85
+
86
+ // Verify the Docker daemon is reachable; start Colima if it isn't
87
+ try {
88
+ await exec("docker", ["info"]);
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
+
105
+ console.log("🚀 Docker daemon not running. Starting Colima...");
106
+ try {
107
+ await exec("colima", ["start"]);
108
+ } catch (err) {
109
+ const message = err instanceof Error ? err.message : String(err);
110
+ throw new Error(
111
+ `Failed to start Colima. Please run 'colima start' manually.\n${message}`,
112
+ );
113
+ }
114
+ }
115
+ }
116
+
22
117
  interface DockerRoot {
23
118
  /** Directory to use as the Docker build context */
24
119
  root: string;
@@ -180,6 +275,8 @@ export async function hatchDocker(
180
275
  ): Promise<void> {
181
276
  resetLogFile("hatch.log");
182
277
 
278
+ await ensureDockerInstalled();
279
+
183
280
  let repoRoot: string;
184
281
  let dockerfileDir: string;
185
282
  try {
@@ -251,12 +348,7 @@ export async function hatchDocker(
251
348
  ];
252
349
 
253
350
  // Pass through environment variables the assistant needs
254
- for (const envVar of [
255
- "ANTHROPIC_API_KEY",
256
- "GATEWAY_RUNTIME_PROXY_ENABLED",
257
- "RUNTIME_PROXY_BEARER_TOKEN",
258
- "VELLUM_ASSISTANT_PLATFORM_URL",
259
- ]) {
351
+ for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
260
352
  if (process.env[envVar]) {
261
353
  runArgs.push("-e", `${envVar}=${process.env[envVar]}`);
262
354
  }
@@ -278,8 +370,10 @@ export async function hatchDocker(
278
370
  );
279
371
  }
280
372
 
281
- const publicUrl = await discoverPublicUrl(gatewayPort);
282
- 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}`;
283
377
  const dockerEntry: AssistantEntry = {
284
378
  assistantId: instanceName,
285
379
  runtimeUrl,
@@ -1,4 +1,4 @@
1
- const DOCTOR_URL = process.env.DOCTOR_URL || "https://doctor.vellum.ai";
1
+ const DOCTOR_URL = "https://doctor.vellum.ai";
2
2
 
3
3
  export type ProgressPhase =
4
4
  | "invoking_prompt"
package/src/lib/gcp.ts CHANGED
@@ -637,7 +637,7 @@ export async function hatchGcp(
637
637
  species === "vellum" &&
638
638
  (await checkCurlFailure(instanceName, project, zone, account))
639
639
  ) {
640
- const installScriptUrl = `${process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? "https://assistant.vellum.ai"}/install.sh`;
640
+ const installScriptUrl = `${process.env.VELLUM_PLATFORM_URL ?? "https://assistant.vellum.ai"}/install.sh`;
641
641
  console.log(
642
642
  `\ud83d\udd04 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`,
643
643
  );
@@ -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,