@vellumai/cli 0.8.2 → 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.
@@ -877,7 +877,16 @@ export async function resolveOrHatchTarget(
877
877
  // Hatch a new assistant in the target environment
878
878
  if (targetEnv === "local") {
879
879
  const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
880
- await hatchLocal("vellum", targetName ?? null, false, false, {});
880
+ await hatchLocal(
881
+ "vellum",
882
+ targetName ?? null,
883
+ false,
884
+ false,
885
+ {},
886
+ {
887
+ setupProviderCredentials: false,
888
+ },
889
+ );
881
890
  const entry = targetName
882
891
  ? findAssistantByName(targetName)
883
892
  : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
@@ -892,7 +901,16 @@ export async function resolveOrHatchTarget(
892
901
 
893
902
  if (targetEnv === "docker") {
894
903
  const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
895
- await hatchDocker("vellum", false, targetName ?? null, false, {});
904
+ await hatchDocker(
905
+ "vellum",
906
+ false,
907
+ targetName ?? null,
908
+ false,
909
+ {},
910
+ {
911
+ setupProviderCredentials: false,
912
+ },
913
+ );
896
914
  const entry = targetName
897
915
  ? findAssistantByName(targetName)
898
916
  : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
@@ -1,19 +1,24 @@
1
1
  import {
2
- findAssistantByName,
2
+ formatAssistantLookupError,
3
+ formatAssistantReference,
3
4
  getActiveAssistant,
5
+ lookupAssistantByIdentifier,
4
6
  setActiveAssistant,
5
7
  } from "../lib/assistant-config.js";
8
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
6
9
 
7
10
  export async function use(): Promise<void> {
8
11
  const args = process.argv.slice(3);
9
12
 
10
13
  if (args.includes("--help") || args.includes("-h")) {
11
- console.log("Usage: vellum use [<name>]");
14
+ console.log("Usage: vellum use [<name-or-id>]");
12
15
  console.log("");
13
16
  console.log("Set the active assistant for commands.");
14
17
  console.log("");
15
18
  console.log("Arguments:");
16
- console.log(" <name> Name of the assistant to make active");
19
+ console.log(
20
+ " <name-or-id> Assistant display name or ID to make active",
21
+ );
17
22
  console.log("");
18
23
  console.log(
19
24
  "When called without a name, prints the current active assistant.",
@@ -21,24 +26,33 @@ export async function use(): Promise<void> {
21
26
  process.exit(0);
22
27
  }
23
28
 
24
- const name = args.find((a) => !a.startsWith("-"));
29
+ const name = parseAssistantTargetArg(args);
25
30
 
26
31
  if (!name) {
27
32
  const active = getActiveAssistant();
28
33
  if (active) {
29
- console.log(`Active assistant: ${active}`);
34
+ const result = lookupAssistantByIdentifier(active);
35
+ if (result.status === "found") {
36
+ console.log(
37
+ `Active assistant: ${formatAssistantReference(result.entry)}`,
38
+ );
39
+ } else {
40
+ console.log(`Active assistant: ${active} (not found in lockfile)`);
41
+ }
30
42
  } else {
31
43
  console.log("No active assistant set.");
32
44
  }
33
45
  return;
34
46
  }
35
47
 
36
- const entry = findAssistantByName(name);
37
- if (!entry) {
38
- console.error(`No assistant found with name '${name}'.`);
48
+ const result = lookupAssistantByIdentifier(name);
49
+ if (result.status !== "found") {
50
+ console.error(formatAssistantLookupError(name, result));
39
51
  process.exit(1);
40
52
  }
41
53
 
42
- setActiveAssistant(name);
43
- console.log(`Active assistant set to '${name}'.`);
54
+ setActiveAssistant(result.entry.assistantId);
55
+ console.log(
56
+ `Active assistant set to ${formatAssistantReference(result.entry)}.`,
57
+ );
44
58
  }
@@ -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;
@@ -4,6 +4,8 @@ import {
4
4
  AVATAR_DEVICE_ENV_VAR,
5
5
  dockerResourceNames,
6
6
  resolveAvatarDevicePath,
7
+ resolveDockerHatchMode,
8
+ resolveDockerProviderCredentialSetupAction,
7
9
  type ServiceName,
8
10
  } from "../docker.js";
9
11
  import { buildServiceRunArgs } from "../statefulset.js";
@@ -103,6 +105,51 @@ describe("buildServiceRunArgs — assistant", () => {
103
105
  });
104
106
  });
105
107
 
108
+ describe("resolveDockerProviderCredentialSetupAction", () => {
109
+ test("defers provider setup in detached mode", () => {
110
+ expect(
111
+ resolveDockerProviderCredentialSetupAction({
112
+ provider: "anthropic",
113
+ detached: true,
114
+ }),
115
+ ).toBe("defer");
116
+ });
117
+
118
+ test("reports missing guardian token only when a lease was expected", () => {
119
+ expect(
120
+ resolveDockerProviderCredentialSetupAction({
121
+ provider: "anthropic",
122
+ detached: false,
123
+ }),
124
+ ).toBe("missing-token");
125
+ });
126
+
127
+ test("configures provider setup when a guardian token is available", () => {
128
+ expect(
129
+ resolveDockerProviderCredentialSetupAction({
130
+ provider: "anthropic",
131
+ guardianAccessToken: "guardian-token",
132
+ detached: false,
133
+ }),
134
+ ).toBe("configure");
135
+ });
136
+
137
+ test("skips provider setup for internal hatches and detached keyless hatches", () => {
138
+ expect(
139
+ resolveDockerProviderCredentialSetupAction({
140
+ provider: undefined,
141
+ detached: false,
142
+ }),
143
+ ).toBe("skip");
144
+ expect(
145
+ resolveDockerProviderCredentialSetupAction({
146
+ provider: null,
147
+ detached: true,
148
+ }),
149
+ ).toBe("skip");
150
+ });
151
+ });
152
+
106
153
  describe("buildServiceRunArgs — gateway", () => {
107
154
  const savedVelayBaseUrl = process.env.VELAY_BASE_URL;
108
155
 
@@ -171,3 +218,62 @@ describe("VELLUM_AVATAR_DEVICE passthrough", () => {
171
218
  );
172
219
  });
173
220
  });
221
+
222
+ describe("resolveDockerHatchMode", () => {
223
+ test("defaults to pulling published images when no source flag is set", () => {
224
+ expect(
225
+ resolveDockerHatchMode({
226
+ watch: false,
227
+ buildFromSource: false,
228
+ fullSourceTreeAvailable: true,
229
+ }),
230
+ ).toEqual({ build: false, watcher: false, fellBackToPull: false });
231
+ });
232
+
233
+ test("--source <path> builds without enabling the file watcher", () => {
234
+ expect(
235
+ resolveDockerHatchMode({
236
+ watch: false,
237
+ buildFromSource: true,
238
+ fullSourceTreeAvailable: true,
239
+ }),
240
+ ).toEqual({ build: true, watcher: false, fellBackToPull: false });
241
+ });
242
+
243
+ test("--watch builds and enables the file watcher", () => {
244
+ expect(
245
+ resolveDockerHatchMode({
246
+ watch: true,
247
+ buildFromSource: false,
248
+ fullSourceTreeAvailable: true,
249
+ }),
250
+ ).toEqual({ build: true, watcher: true, fellBackToPull: false });
251
+ });
252
+
253
+ test("--watch + --source <path> still enables the watcher (watch wins)", () => {
254
+ expect(
255
+ resolveDockerHatchMode({
256
+ watch: true,
257
+ buildFromSource: true,
258
+ fullSourceTreeAvailable: true,
259
+ }),
260
+ ).toEqual({ build: true, watcher: true, fellBackToPull: false });
261
+ });
262
+
263
+ test("falls back to pull when source flag is set but source tree is missing", () => {
264
+ expect(
265
+ resolveDockerHatchMode({
266
+ watch: false,
267
+ buildFromSource: true,
268
+ fullSourceTreeAvailable: false,
269
+ }),
270
+ ).toEqual({ build: false, watcher: false, fellBackToPull: true });
271
+ expect(
272
+ resolveDockerHatchMode({
273
+ watch: true,
274
+ buildFromSource: false,
275
+ fullSourceTreeAvailable: false,
276
+ }),
277
+ ).toEqual({ build: false, watcher: false, fellBackToPull: true });
278
+ });
279
+ });
@@ -89,6 +89,8 @@ export interface AssistantEntry {
89
89
  resources?: LocalInstanceResources;
90
90
  /** PID of the file watcher process for docker instances hatched with --watch. */
91
91
  watcherPid?: number;
92
+ /** Local bootstrap secret used to lease guardian tokens for Docker assistants after detached hatch. */
93
+ guardianBootstrapSecret?: string;
92
94
  /** Docker image metadata for rollback. Only present for docker topology entries. */
93
95
  containerInfo?: ContainerInfo;
94
96
  /** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
@@ -107,6 +109,11 @@ export interface AssistantEntry {
107
109
  [key: string]: unknown;
108
110
  }
109
111
 
112
+ export type AssistantLookupResult =
113
+ | { status: "found"; entry: AssistantEntry }
114
+ | { status: "not_found" }
115
+ | { status: "ambiguous"; matches: AssistantEntry[] };
116
+
110
117
  interface LockfileData {
111
118
  assistants?: Record<string, unknown>[];
112
119
  activeAssistant?: string;
@@ -307,8 +314,70 @@ export function loadLatestAssistant(): AssistantEntry | null {
307
314
  }
308
315
 
309
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 {
310
350
  const entries = readAssistants();
311
- 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}'.`;
312
381
  }
313
382
 
314
383
  export function removeAssistantEntry(assistantId: string): void {
@@ -444,13 +513,25 @@ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
444
513
  * 3. Sole lockfile entry (any cloud)
445
514
  */
446
515
  export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
447
- const entry = resolveAssistant(nameArg);
448
- if (entry) return entry;
449
-
450
516
  if (nameArg) {
451
- 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));
452
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
+
453
532
  const all = readAssistants();
533
+ if (all.length === 1) return all[0];
534
+
454
535
  if (all.length === 0) {
455
536
  console.error("No assistant found. Run 'vellum hatch' first.");
456
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
+ }
@@ -32,6 +32,24 @@ export function buildNestedConfig(
32
32
  return config;
33
33
  }
34
34
 
35
+ /**
36
+ * Ensure hatch always provides enough initial LLM config for the assistant to
37
+ * detect a fresh off-platform hatch and seed BYOK profiles.
38
+ */
39
+ export function buildHatchConfigValues(
40
+ configValues: Record<string, string>,
41
+ provider: string | null | undefined,
42
+ ): Record<string, string> {
43
+ if (!provider || configValues["llm.default.provider"]) {
44
+ return configValues;
45
+ }
46
+
47
+ return {
48
+ ...configValues,
49
+ "llm.default.provider": provider,
50
+ };
51
+ }
52
+
35
53
  /**
36
54
  * Write arbitrary key-value pairs to a temporary JSON file and return its
37
55
  * path. The caller passes this path to the daemon via the