@vellumai/cli 0.8.3 → 0.8.4

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.
@@ -1,4 +1,3 @@
1
- import { spawn } from "child_process";
2
1
  import { basename } from "path";
3
2
  import {
4
3
  useCallback,
@@ -10,8 +9,6 @@ import {
10
9
  } from "react";
11
10
  import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
12
11
 
13
- import { removeAssistantEntry } from "../lib/assistant-config";
14
-
15
12
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
16
13
  import { checkHealth } from "../lib/health-check";
17
14
  import { appendHistory, loadHistory } from "../lib/input-history";
@@ -43,9 +40,27 @@ export const SLASH_COMMANDS = [
43
40
  "/help",
44
41
  "/q",
45
42
  "/quit",
46
- "/retire",
47
43
  ];
48
44
 
45
+ const HELP_COMMANDS = [
46
+ {
47
+ command: "/btw <question>",
48
+ description: "Ask a side question while the assistant is working",
49
+ },
50
+ {
51
+ command: "/quit, /exit, /q",
52
+ description: "Disconnect and exit",
53
+ },
54
+ {
55
+ command: "/clear",
56
+ description: "Clear the screen",
57
+ },
58
+ {
59
+ command: "/help, ?",
60
+ description: "Show this help",
61
+ },
62
+ ] as const;
63
+
49
64
  const SEND_TIMEOUT_MS = 5000;
50
65
 
51
66
  // ── Layout constants ──────────────────────────────────────
@@ -100,12 +115,11 @@ const MIN_FEED_ROWS = 3;
100
115
  // Feed item height estimation
101
116
  const TOOL_CALL_CHROME_LINES = 2; // header (┌) + footer (└)
102
117
  const MESSAGE_SPACING = 1;
103
- const HELP_DISPLAY_HEIGHT = 7;
118
+ const HELP_DISPLAY_HEIGHT = HELP_COMMANDS.length + 1;
104
119
 
105
120
  interface ListMessagesResponse {
106
121
  messages: RuntimeMessage[];
107
122
  nextCursor?: string;
108
- interfaces?: string[];
109
123
  }
110
124
 
111
125
  interface SendMessageResponse {
@@ -742,26 +756,12 @@ function HelpDisplay(): ReactElement {
742
756
  return (
743
757
  <Box flexDirection="column">
744
758
  <Text bold>Commands:</Text>
745
- <Text>
746
- {" /btw <question> "}
747
- <Text dimColor>Ask a side question while the assistant is working</Text>
748
- </Text>
749
- <Text>
750
- {" /retire "}
751
- <Text dimColor>Retire the remote instance and exit</Text>
752
- </Text>
753
- <Text>
754
- {" /quit, /exit, /q "}
755
- <Text dimColor>Disconnect and exit</Text>
756
- </Text>
757
- <Text>
758
- {" /clear "}
759
- <Text dimColor>Clear the screen</Text>
760
- </Text>
761
- <Text>
762
- {" /help, ? "}
763
- <Text dimColor>Show this help</Text>
764
- </Text>
759
+ {HELP_COMMANDS.map((entry) => (
760
+ <Text key={entry.command}>
761
+ {` ${entry.command.padEnd(17)} `}
762
+ <Text dimColor>{entry.description}</Text>
763
+ </Text>
764
+ ))}
765
765
  </Box>
766
766
  );
767
767
  }
@@ -1391,8 +1391,6 @@ interface ChatAppProps {
1391
1391
  /** Pre-built auth headers (e.g. { Authorization: "Bearer ..." } for local,
1392
1392
  * { "X-Session-Token": "...", "Vellum-Organization-Id": "..." } for platform). */
1393
1393
  auth?: Record<string, string>;
1394
- project?: string;
1395
- zone?: string;
1396
1394
  onExit: () => void;
1397
1395
  handleRef: (handle: ChatAppHandle) => void;
1398
1396
  }
@@ -1403,8 +1401,6 @@ function ChatApp({
1403
1401
  assistantName,
1404
1402
  species,
1405
1403
  auth,
1406
- project,
1407
- zone,
1408
1404
  onExit,
1409
1405
  handleRef,
1410
1406
  }: ChatAppProps): ReactElement {
@@ -1954,86 +1950,6 @@ function ChatApp({
1954
1950
  return;
1955
1951
  }
1956
1952
 
1957
- if (trimmed === "/retire") {
1958
- if (!project || !zone) {
1959
- h.showError(
1960
- "No instance info available. Connect to a hatched instance first.",
1961
- );
1962
- return;
1963
- }
1964
-
1965
- const confirmIndex = await h.showSelection(`Retire ${assistantId}?`, [
1966
- "Yes, retire",
1967
- "Cancel",
1968
- ]);
1969
- if (confirmIndex !== 0) {
1970
- h.addStatus("Cancelled.");
1971
- return;
1972
- }
1973
-
1974
- h.showSpinner(`Retiring instance ${assistantId}...`);
1975
-
1976
- try {
1977
- const labelChild = spawn(
1978
- "gcloud",
1979
- [
1980
- "compute",
1981
- "instances",
1982
- "add-labels",
1983
- assistantId,
1984
- `--project=${project}`,
1985
- `--zone=${zone}`,
1986
- "--labels=retired-by=vel",
1987
- ],
1988
- { stdio: "pipe" },
1989
- );
1990
- await new Promise<void>((resolve) => {
1991
- labelChild.on("close", () => resolve());
1992
- labelChild.on("error", () => resolve());
1993
- });
1994
- } catch {
1995
- // Best-effort labeling before deletion
1996
- }
1997
-
1998
- const child = spawn(
1999
- "gcloud",
2000
- [
2001
- "compute",
2002
- "instances",
2003
- "delete",
2004
- assistantId,
2005
- `--project=${project}`,
2006
- `--zone=${zone}`,
2007
- "--quiet",
2008
- ],
2009
- { stdio: "pipe" },
2010
- );
2011
-
2012
- child.on("close", (code) => {
2013
- handleRef_.current?.hideSpinner();
2014
- if (code === 0) {
2015
- removeAssistantEntry(assistantId);
2016
- handleRef_.current?.addStatus(
2017
- `Removed ${assistantId} from lockfile.json`,
2018
- );
2019
- } else {
2020
- handleRef_.current?.showError(
2021
- `Failed to delete instance (exit code ${code})`,
2022
- );
2023
- }
2024
- cleanup();
2025
- process.exit(code === 0 ? 0 : 1);
2026
- });
2027
-
2028
- child.on("error", (err) => {
2029
- handleRef_.current?.hideSpinner();
2030
- handleRef_.current?.showError(
2031
- `Failed to retire instance: ${err.message}`,
2032
- );
2033
- });
2034
- return;
2035
- }
2036
-
2037
1953
  // If a connection attempt is already in progress, don't silently drop input
2038
1954
  if (connectingRef.current) {
2039
1955
  h.addStatus(
@@ -2218,7 +2134,7 @@ function ChatApp({
2218
2134
  // racing with SSE events that may arrive during the sendMessage await.
2219
2135
  h.showSpinner("Working...");
2220
2136
  },
2221
- [runtimeUrl, assistantId, auth, project, zone, cleanup, ensureConnected],
2137
+ [runtimeUrl, assistantId, auth, cleanup, ensureConnected],
2222
2138
  );
2223
2139
 
2224
2140
  const handleSubmit = useCallback(
@@ -2530,8 +2446,6 @@ export function renderChatApp(
2530
2446
  onExit: () => void,
2531
2447
  options?: {
2532
2448
  auth?: Record<string, string>;
2533
- project?: string;
2534
- zone?: string;
2535
2449
  assistantName?: string;
2536
2450
  },
2537
2451
  ): ChatAppInstance {
@@ -2544,8 +2458,6 @@ export function renderChatApp(
2544
2458
  assistantName={options?.assistantName}
2545
2459
  species={species}
2546
2460
  auth={options?.auth}
2547
- project={options?.project}
2548
- zone={options?.zone}
2549
2461
  onExit={onExit}
2550
2462
  handleRef={(h) => {
2551
2463
  chatHandle = h;
@@ -109,6 +109,11 @@ export interface AssistantEntry {
109
109
  [key: string]: unknown;
110
110
  }
111
111
 
112
+ export type AssistantLookupResult =
113
+ | { status: "found"; entry: AssistantEntry }
114
+ | { status: "not_found" }
115
+ | { status: "ambiguous"; matches: AssistantEntry[] };
116
+
112
117
  interface LockfileData {
113
118
  assistants?: Record<string, unknown>[];
114
119
  activeAssistant?: string;
@@ -309,8 +314,70 @@ export function loadLatestAssistant(): AssistantEntry | null {
309
314
  }
310
315
 
311
316
  export function findAssistantByName(name: string): AssistantEntry | null {
317
+ return readAssistants().find((entry) => entry.assistantId === name) ?? null;
318
+ }
319
+
320
+ export function getAssistantDisplayName(entry: AssistantEntry): string {
321
+ const primary = entry.name?.trim();
322
+ if (primary) return primary;
323
+
324
+ const legacy = entry.assistantName?.trim();
325
+ if (legacy) return legacy;
326
+
327
+ return entry.assistantId;
328
+ }
329
+
330
+ export function formatAssistantReference(entry: AssistantEntry): string {
331
+ const displayName = getAssistantDisplayName(entry);
332
+ return displayName === entry.assistantId
333
+ ? entry.assistantId
334
+ : `${displayName} (${entry.assistantId})`;
335
+ }
336
+
337
+ function getAssistantDisplayNameCandidates(entry: AssistantEntry): string[] {
338
+ return Array.from(
339
+ new Set(
340
+ [entry.name?.trim(), entry.assistantName?.trim()].filter(
341
+ (value): value is string => typeof value === "string" && value !== "",
342
+ ),
343
+ ),
344
+ );
345
+ }
346
+
347
+ export function lookupAssistantByIdentifier(
348
+ identifier: string,
349
+ ): AssistantLookupResult {
312
350
  const entries = readAssistants();
313
- return entries.find((e) => e.assistantId === name) ?? null;
351
+ const exactId = entries.find((entry) => entry.assistantId === identifier);
352
+ if (exactId) {
353
+ return { status: "found", entry: exactId };
354
+ }
355
+
356
+ const displayMatches = entries.filter((entry) =>
357
+ getAssistantDisplayNameCandidates(entry).includes(identifier),
358
+ );
359
+ if (displayMatches.length === 1) {
360
+ return { status: "found", entry: displayMatches[0] };
361
+ }
362
+ if (displayMatches.length > 1) {
363
+ return { status: "ambiguous", matches: displayMatches };
364
+ }
365
+
366
+ return { status: "not_found" };
367
+ }
368
+
369
+ export function formatAssistantLookupError(
370
+ identifier: string,
371
+ result: AssistantLookupResult = lookupAssistantByIdentifier(identifier),
372
+ ): string {
373
+ if (result.status === "ambiguous") {
374
+ const matches = result.matches
375
+ .map((entry) => formatAssistantReference(entry))
376
+ .join(", ");
377
+ return `Multiple assistants match '${identifier}': ${matches}. Use the assistant ID to disambiguate.`;
378
+ }
379
+
380
+ return `No assistant found with name or ID '${identifier}'.`;
314
381
  }
315
382
 
316
383
  export function removeAssistantEntry(assistantId: string): void {
@@ -446,13 +513,25 @@ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
446
513
  * 3. Sole lockfile entry (any cloud)
447
514
  */
448
515
  export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
449
- const entry = resolveAssistant(nameArg);
450
- if (entry) return entry;
451
-
452
516
  if (nameArg) {
453
- console.error(`No assistant found with name '${nameArg}'.`);
517
+ const result = lookupAssistantByIdentifier(nameArg);
518
+ if (result.status === "found") return result.entry;
519
+ console.error(formatAssistantLookupError(nameArg, result));
454
520
  } else {
521
+ const active = getActiveAssistant();
522
+ if (active) {
523
+ const result = lookupAssistantByIdentifier(active);
524
+ if (result.status === "found") return result.entry;
525
+ if (result.status === "ambiguous") {
526
+ console.error(formatAssistantLookupError(active, result));
527
+ process.exit(1);
528
+ }
529
+ // Active assistant no longer exists in lockfile — fall through.
530
+ }
531
+
455
532
  const all = readAssistants();
533
+ if (all.length === 1) return all[0];
534
+
456
535
  if (all.length === 0) {
457
536
  console.error("No assistant found. Run 'vellum hatch' first.");
458
537
  } else {
@@ -0,0 +1,21 @@
1
+ export function parseAssistantTargetArg(
2
+ args: string[],
3
+ flagsWithValues: readonly string[] = [],
4
+ ): string | undefined {
5
+ const flagsWithValuesSet = new Set(flagsWithValues);
6
+ const parts: string[] = [];
7
+
8
+ for (let i = 0; i < args.length; i++) {
9
+ const arg = args[i];
10
+ if (flagsWithValuesSet.has(arg)) {
11
+ i++;
12
+ continue;
13
+ }
14
+ if (arg.startsWith("-")) {
15
+ continue;
16
+ }
17
+ parts.push(arg);
18
+ }
19
+
20
+ return parts.length > 0 ? parts.join(" ") : undefined;
21
+ }
package/src/lib/docker.ts CHANGED
@@ -21,7 +21,15 @@ import { leaseGuardianToken } from "./guardian-token";
21
21
  import { logHatchNextSteps } from "./hatch-next-steps.js";
22
22
  import { isVellumProcess, stopProcess } from "./process";
23
23
  import { generateInstanceName } from "./random-name";
24
- import { resolveImageRefs } from "./platform-releases.js";
24
+ import {
25
+ HOST_IMAGE_LOADER_URL,
26
+ isLocalBuildRef,
27
+ loadImageViaHost,
28
+ } from "./host-image-loader.js";
29
+ import {
30
+ fetchLatestStableVersion,
31
+ resolveImageRefs,
32
+ } from "./platform-releases.js";
25
33
  import {
26
34
  configureHatchProviderApiKey,
27
35
  formatProviderName,
@@ -1059,8 +1067,23 @@ export async function hatchDocker(
1059
1067
  imageSource = "env override";
1060
1068
  log("Using image overrides from environment variables");
1061
1069
  } else {
1062
- const version = cliPkg.version;
1063
- const versionTag = version ? `v${version}` : "latest";
1070
+ // Resolve image refs from a remote source that may have dev/local
1071
+ // builds. If resolution is unavailable, fall back to the CLI's own
1072
+ // version so a default tag can still be resolved.
1073
+ log("🔍 Fetching latest stable release...");
1074
+ const latestVersion = await fetchLatestStableVersion();
1075
+ let versionTag: string;
1076
+ if (latestVersion) {
1077
+ versionTag = latestVersion.startsWith("v")
1078
+ ? latestVersion
1079
+ : `v${latestVersion}`;
1080
+ } else {
1081
+ const fallback = cliPkg.version;
1082
+ versionTag = fallback ? `v${fallback}` : "latest";
1083
+ log(
1084
+ `⚠️ Platform releases unavailable; falling back to CLI version ${versionTag}`,
1085
+ );
1086
+ }
1064
1087
  log("🔍 Resolving image references...");
1065
1088
  const resolved = await resolveImageRefs(versionTag, log);
1066
1089
  imageTags.assistant = resolved.imageTags.assistant;
@@ -1078,11 +1101,25 @@ export async function hatchDocker(
1078
1101
  log(` credential-executor: ${imageTags["credential-executor"]}`);
1079
1102
  log("");
1080
1103
 
1081
- log("📦 Pulling Docker images...");
1082
- await exec("docker", ["pull", imageTags.assistant]);
1083
- await exec("docker", ["pull", imageTags.gateway]);
1084
- await exec("docker", ["pull", imageTags["credential-executor"]]);
1085
- log("✅ Docker images pulled");
1104
+ // Per-ref branching: local-build refs need the image-loader; external
1105
+ // registry refs get a normal `docker pull`. The two transports compose
1106
+ // cleanly a release can mix different sources for different images.
1107
+ log("📦 Acquiring Docker images...");
1108
+ for (const service of [
1109
+ "assistant",
1110
+ "gateway",
1111
+ "credential-executor",
1112
+ ] as const) {
1113
+ const ref = imageTags[service];
1114
+ if (isLocalBuildRef(ref)) {
1115
+ log(` ↪ loading ${ref} via host image-loader`);
1116
+ await loadImageViaHost(HOST_IMAGE_LOADER_URL, ref, log);
1117
+ } else {
1118
+ log(` ↪ pulling ${ref}`);
1119
+ await exec("docker", ["pull", ref]);
1120
+ }
1121
+ }
1122
+ log("✅ Docker images acquired");
1086
1123
  }
1087
1124
 
1088
1125
  const res = dockerResourceNames(instanceName);
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Client for the host-side image-loader endpoint. Used to acquire image refs
3
+ * that aren't pullable from any external registry.
4
+ *
5
+ * The endpoint URL is a well-known convention — port 5500 on 127.0.0.1.
6
+ * The CLI calls in whenever it sees a ref that starts with `vellum-local/`,
7
+ * which are image refs that only exist in a local docker daemon and can't be
8
+ * `docker pull`'d from any external registry.
9
+ *
10
+ * The endpoint contract is intentionally minimal — POST a ref as JSON, get
11
+ * back a 200 once the image is in the host docker daemon, or a non-2xx
12
+ * with a descriptive error message. The client doesn't know (or care) what
13
+ * transport the server uses to put the image there.
14
+ */
15
+
16
+ /**
17
+ * Well-known URL of the host-side image-loader server.
18
+ */
19
+ export const HOST_IMAGE_LOADER_URL = "http://127.0.0.1:5500/v1/images/load";
20
+
21
+ /**
22
+ * Prefix for image refs that only exist in a local docker daemon.
23
+ * These cannot be `docker pull`'d from any external registry; the CLI must
24
+ * route them through the host image-loader instead.
25
+ */
26
+ const LOCAL_BUILD_REF_PREFIX = "vellum-local/";
27
+
28
+ /** Whether `ref` points at a local-build image that requires the host loader. */
29
+ export function isLocalBuildRef(ref: string): boolean {
30
+ return ref.startsWith(LOCAL_BUILD_REF_PREFIX);
31
+ }
32
+
33
+ /** Default timeout for image-load requests. Large `docker save | docker load`
34
+ * pipelines for full assistant images can run for a minute or two on cold
35
+ * caches, so we give plenty of headroom. */
36
+ const LOAD_TIMEOUT_MS = 120_000;
37
+
38
+ export interface HostImageLoaderResponse {
39
+ loaded?: boolean;
40
+ ref?: string;
41
+ error?: string;
42
+ }
43
+
44
+ export class HostImageLoaderError extends Error {
45
+ readonly url: string;
46
+ readonly ref: string;
47
+ readonly status?: number;
48
+
49
+ constructor(message: string, url: string, ref: string, status?: number) {
50
+ super(message);
51
+ this.name = "HostImageLoaderError";
52
+ this.url = url;
53
+ this.ref = ref;
54
+ this.status = status;
55
+ }
56
+ }
57
+
58
+ function isConnectionRefused(err: unknown): boolean {
59
+ if (!err || typeof err !== "object") return false;
60
+ const e = err as { cause?: { code?: string }; code?: string };
61
+ return e.cause?.code === "ECONNREFUSED" || e.code === "ECONNREFUSED";
62
+ }
63
+
64
+ /**
65
+ * Ask the host-side loader to acquire `ref` into the host docker daemon.
66
+ *
67
+ * Resolves when the server returns 200; throws a {@link HostImageLoaderError}
68
+ * with a user-actionable message on any failure (network, timeout, non-2xx).
69
+ *
70
+ * The `log` callback receives one-line status updates; pass the same logger
71
+ * the surrounding command uses.
72
+ */
73
+ /** Minimal fetch signature accepted for test injection. */
74
+ export type FetchLike = (
75
+ input: string | URL,
76
+ init?: {
77
+ method?: string;
78
+ headers?: Record<string, string>;
79
+ body?: string;
80
+ signal?: AbortSignal;
81
+ },
82
+ ) => Promise<Response>;
83
+
84
+ export async function loadImageViaHost(
85
+ url: string,
86
+ ref: string,
87
+ log: (msg: string) => void,
88
+ options: { timeoutMs?: number; fetchImpl?: FetchLike } = {},
89
+ ): Promise<void> {
90
+ const timeoutMs = options.timeoutMs ?? LOAD_TIMEOUT_MS;
91
+ const fetchImpl: FetchLike =
92
+ options.fetchImpl ?? (fetch as unknown as FetchLike);
93
+
94
+ log(` ↪ ${ref}`);
95
+
96
+ let response: Response;
97
+ try {
98
+ response = await fetchImpl(url, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ ref }),
102
+ signal: AbortSignal.timeout(timeoutMs),
103
+ });
104
+ } catch (err) {
105
+ if (isConnectionRefused(err)) {
106
+ throw new HostImageLoaderError(
107
+ `Could not reach image-loader at ${url}. The ref \`${ref}\` is a ` +
108
+ `local-build image that requires the loader. Is the loader running? ` +
109
+ `Start it, or set VELLUM_ASSISTANT_IMAGE / VELLUM_GATEWAY_IMAGE / ` +
110
+ `VELLUM_CREDENTIAL_EXECUTOR_IMAGE to bypass image resolution.`,
111
+ url,
112
+ ref,
113
+ );
114
+ }
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ throw new HostImageLoaderError(
117
+ `Image-loader request for ${ref} failed: ${message}`,
118
+ url,
119
+ ref,
120
+ );
121
+ }
122
+
123
+ if (!response.ok) {
124
+ let body: HostImageLoaderResponse | null = null;
125
+ try {
126
+ body = (await response.json()) as HostImageLoaderResponse;
127
+ } catch {
128
+ // Server returned non-JSON; fall through with status-only error.
129
+ }
130
+ const detail = body?.error ? `: ${body.error}` : "";
131
+ throw new HostImageLoaderError(
132
+ `Image-loader returned HTTP ${response.status} for ${ref}${detail}`,
133
+ url,
134
+ ref,
135
+ response.status,
136
+ );
137
+ }
138
+ }
@@ -46,7 +46,10 @@ export async function resolveImageRefs(
46
46
  const platformRefs = await fetchPlatformImageRefs(version, log);
47
47
  if (platformRefs) {
48
48
  log?.("Resolved image refs from platform API");
49
- return { imageTags: platformRefs, source: "platform" };
49
+ return {
50
+ imageTags: platformRefs.imageTags,
51
+ source: "platform",
52
+ };
50
53
  }
51
54
 
52
55
  log?.("Falling back to DockerHub tags");
@@ -68,7 +71,9 @@ export async function resolveImageRefs(
68
71
  async function fetchPlatformImageRefs(
69
72
  version: string,
70
73
  log?: (msg: string) => void,
71
- ): Promise<Record<ServiceName, string> | null> {
74
+ ): Promise<{
75
+ imageTags: Record<ServiceName, string>;
76
+ } | null> {
72
77
  try {
73
78
  const platformUrl = getPlatformUrl();
74
79
  const url = `${platformUrl}/v1/releases/?stable=true`;
@@ -123,9 +128,11 @@ async function fetchPlatformImageRefs(
123
128
  }
124
129
 
125
130
  return {
126
- assistant: assistantImage,
127
- "credential-executor": credentialExecutorImage,
128
- gateway: gatewayImage,
131
+ imageTags: {
132
+ assistant: assistantImage,
133
+ "credential-executor": credentialExecutorImage,
134
+ gateway: gatewayImage,
135
+ },
129
136
  };
130
137
  } catch (err) {
131
138
  const message = err instanceof Error ? err.message : String(err);