@vellumai/cli 0.4.56 → 0.5.0

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/AGENTS.md CHANGED
@@ -6,14 +6,11 @@ The `cli/` package (`@vellumai/cli`) manages the **lifecycle of Vellum assistant
6
6
 
7
7
  This contrasts with `assistant/src/cli/`, where commands are scoped to a **single running assistant** and operate on its local state (config, memory, contacts, etc.).
8
8
 
9
- ## When a command belongs here vs `assistant/src/cli/`
9
+ ## Scope
10
10
 
11
- | `cli/` (this package) | `assistant/src/cli/` |
12
- | ----------------------------------------------- | --------------------------------------------------- |
13
- | Operates on or across assistant instances | Operates within a single assistant's workspace |
14
- | Manages lifecycle (create, start, stop, delete) | Manages instance-local state (config, memory, etc.) |
15
- | Requires specifying which assistant to target | Implicitly scoped to the running assistant |
16
- | Works without an assistant process running | May require or start the daemon |
11
+ Commands here operate on or across **assistant instances** — creating, starting, stopping, connecting to, and deleting them. They require specifying which assistant to target and work without an assistant process running.
12
+
13
+ For commands scoped to a **single running assistant's** local state (config, memory, contacts), see `assistant/src/cli/AGENTS.md`.
17
14
 
18
15
  Examples: `hatch`, `wake`, `sleep`, `retire`, `ps`, `ssh` belong here. `config`, `contacts`, `memory` belong in `assistant/src/cli/`.
19
16
 
@@ -30,9 +27,7 @@ Commands that act on a specific assistant should accept an assistant name or ID
30
27
 
31
28
  ## Help Text Standards
32
29
 
33
- Every command must have high-quality `--help` output optimized for AI/LLM
34
- consumption. Help text is a primary interface — both humans and AI agents read
35
- it to understand what a command does and how to use it.
30
+ Every command must have high-quality `--help` output. Follow the same standards as `assistant/src/cli/AGENTS.md` § Help Text Standards, adapted for this package's manual argv parsing (no Commander.js).
36
31
 
37
32
  ### Requirements
38
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.56",
3
+ "version": "0.5.0",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,6 +5,11 @@ import { resolve } from "node:path";
5
5
  import { expect, test } from "bun:test";
6
6
 
7
7
  const EXCLUDE_PATTERNS = [".test.ts", ".d.ts"];
8
+ const EXCLUDE_DIRS = [
9
+ // Ink components import yoga-layout whose WASM binary crashes
10
+ // intermittently during headless import (null reference in za()).
11
+ "components/",
12
+ ];
8
13
  const EXCLUDE_FILES = [
9
14
  // index.ts calls main() at module level, causing side effects on import
10
15
  "index.ts",
@@ -15,6 +20,7 @@ async function importAllModules(dir: string): Promise<string[]> {
15
20
  const files = [...glob.scanSync(dir)].filter(
16
21
  (f) =>
17
22
  !EXCLUDE_PATTERNS.some((pattern) => f.endsWith(pattern)) &&
23
+ !EXCLUDE_DIRS.some((dir) => f.startsWith(dir)) &&
18
24
  !EXCLUDE_FILES.some((excluded) => f === excluded) &&
19
25
  !f.includes("__tests__"),
20
26
  );
@@ -6,6 +6,7 @@ import {
6
6
  loadLatestAssistant,
7
7
  } from "../lib/assistant-config";
8
8
  import { DAEMON_INTERNAL_ASSISTANT_ID, GATEWAY_PORT, type Species } from "../lib/constants";
9
+ import { loadGuardianToken } from "../lib/guardian-token";
9
10
  import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
10
11
 
11
12
  const ANSI = {
@@ -83,7 +84,7 @@ function parseArgs(): ParsedArgs {
83
84
 
84
85
  let runtimeUrl = entry?.localUrl || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
85
86
  let assistantId = entry?.assistantId || DAEMON_INTERNAL_ASSISTANT_ID;
86
- const bearerToken = entry?.bearerToken || undefined;
87
+ const bearerToken = loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined;
87
88
  const species: Species = (entry?.species as Species) ?? "vellum";
88
89
 
89
90
  for (let i = 0; i < flagArgs.length; i++) {
@@ -383,7 +383,7 @@ export async function watchHatching(
383
383
  );
384
384
  console.log(` Monitor with: vel logs ${instanceName}`);
385
385
  console.log("");
386
- resolve({ success: true, errorContent: lastErrorContent });
386
+ resolve({ success: false, errorContent: lastErrorContent });
387
387
  return;
388
388
  }
389
389
 
@@ -428,7 +428,7 @@ function watchHatchingDesktop(
428
428
  `Timed out after ${formatElapsed(elapsed)}. Instance is still running.`,
429
429
  );
430
430
  desktopLog(`Monitor with: vel logs ${instanceName}`);
431
- resolve({ success: true, errorContent: lastErrorContent });
431
+ resolve({ success: false, errorContent: lastErrorContent });
432
432
  return;
433
433
  }
434
434
 
@@ -701,32 +701,36 @@ async function hatchLocal(
701
701
 
702
702
  await startLocalDaemon(watch, resources);
703
703
 
704
- let runtimeUrl: string;
705
- try {
706
- runtimeUrl = await startGateway(watch, resources);
707
- } catch (error) {
708
- // Gateway failed — stop the daemon we just started so we don't leave
709
- // orphaned processes with no lock file entry.
710
- console.error(
711
- `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
712
- );
713
- await stopLocalProcesses(resources);
714
- throw error;
715
- }
704
+ // When daemonOnly is set, skip gateway and ngrok — the caller only wants
705
+ // the daemon restarted (e.g. macOS app bootstrap retry).
706
+ let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
707
+ if (!daemonOnly) {
708
+ try {
709
+ runtimeUrl = await startGateway(watch, resources);
710
+ } catch (error) {
711
+ // Gateway failed — stop the daemon we just started so we don't leave
712
+ // orphaned processes with no lock file entry.
713
+ console.error(
714
+ `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
715
+ );
716
+ await stopLocalProcesses(resources);
717
+ throw error;
718
+ }
716
719
 
717
- // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
718
- // Set BASE_DATA_DIR so ngrok reads the correct instance config.
719
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
720
- process.env.BASE_DATA_DIR = resources.instanceDir;
721
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
722
- if (ngrokChild?.pid) {
723
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
724
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
725
- }
726
- if (prevBaseDataDir !== undefined) {
727
- process.env.BASE_DATA_DIR = prevBaseDataDir;
728
- } else {
729
- delete process.env.BASE_DATA_DIR;
720
+ // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
721
+ // Set BASE_DATA_DIR so ngrok reads the correct instance config.
722
+ const prevBaseDataDir = process.env.BASE_DATA_DIR;
723
+ process.env.BASE_DATA_DIR = resources.instanceDir;
724
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
725
+ if (ngrokChild?.pid) {
726
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
727
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
728
+ }
729
+ if (prevBaseDataDir !== undefined) {
730
+ process.env.BASE_DATA_DIR = prevBaseDataDir;
731
+ } else {
732
+ delete process.env.BASE_DATA_DIR;
733
+ }
730
734
  }
731
735
 
732
736
  const localEntry: AssistantEntry = {
@@ -757,7 +761,12 @@ async function hatchLocal(
757
761
  }
758
762
 
759
763
  if (keepAlive) {
760
- const gatewayHealthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
764
+ // When --daemon-only is set, no gateway is running — poll the daemon
765
+ // health endpoint instead of the gateway to avoid self-termination.
766
+ const healthUrl = daemonOnly
767
+ ? `http://127.0.0.1:${resources.daemonPort}/healthz`
768
+ : `http://127.0.0.1:${resources.gatewayPort}/healthz`;
769
+ const healthTarget = daemonOnly ? "Assistant" : "Gateway";
761
770
  const POLL_INTERVAL_MS = 5000;
762
771
  const MAX_FAILURES = 3;
763
772
  let consecutiveFailures = 0;
@@ -771,11 +780,11 @@ async function hatchLocal(
771
780
  process.on("SIGTERM", () => void shutdown());
772
781
  process.on("SIGINT", () => void shutdown());
773
782
 
774
- // Poll the gateway health endpoint until it stops responding.
783
+ // Poll the health endpoint until it stops responding.
775
784
  while (true) {
776
785
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
777
786
  try {
778
- const res = await fetch(gatewayHealthUrl, {
787
+ const res = await fetch(healthUrl, {
779
788
  signal: AbortSignal.timeout(3000),
780
789
  });
781
790
  if (res.ok) {
@@ -787,7 +796,9 @@ async function hatchLocal(
787
796
  consecutiveFailures++;
788
797
  }
789
798
  if (consecutiveFailures >= MAX_FAILURES) {
790
- console.log("\n⚠️ Gateway stopped responding — shutting down.");
799
+ console.log(
800
+ `\n⚠️ ${healthTarget} stopped responding — shutting down.`,
801
+ );
791
802
  await stopLocalProcesses(resources);
792
803
  process.exit(1);
793
804
  }
@@ -7,6 +7,8 @@ import { PNG } from "pngjs";
7
7
  import { saveAssistantEntry } from "../lib/assistant-config";
8
8
  import type { AssistantEntry } from "../lib/assistant-config";
9
9
  import type { Species } from "../lib/constants";
10
+ import { saveGuardianToken } from "../lib/guardian-token";
11
+ import type { GuardianTokenData } from "../lib/guardian-token";
10
12
  import { generateInstanceName } from "../lib/random-name";
11
13
 
12
14
  interface QRPairingPayload {
@@ -168,13 +170,27 @@ export async function pair(): Promise<void> {
168
170
  const customEntry: AssistantEntry = {
169
171
  assistantId: instanceName,
170
172
  runtimeUrl,
171
- bearerToken,
172
173
  cloud: "custom",
173
174
  species,
174
175
  hatchedAt: new Date().toISOString(),
175
176
  };
176
177
  saveAssistantEntry(customEntry);
177
178
 
179
+ if (bearerToken) {
180
+ const tokenData: GuardianTokenData = {
181
+ guardianPrincipalId: "",
182
+ accessToken: bearerToken,
183
+ accessTokenExpiresAt: "",
184
+ refreshToken: "",
185
+ refreshTokenExpiresAt: "",
186
+ refreshAfter: "",
187
+ isNew: true,
188
+ deviceId: getDeviceId(),
189
+ leasedAt: new Date().toISOString(),
190
+ };
191
+ saveGuardianToken(instanceName, tokenData);
192
+ }
193
+
178
194
  console.log("");
179
195
  console.log("Successfully paired with remote assistant!");
180
196
  console.log("Instance details:");
@@ -6,7 +6,9 @@ import {
6
6
  loadAllAssistants,
7
7
  type AssistantEntry,
8
8
  } from "../lib/assistant-config";
9
+ import { loadGuardianToken } from "../lib/guardian-token";
9
10
  import { checkHealth, checkManagedHealth } from "../lib/health-check";
11
+ import { dockerResourceNames } from "../lib/docker";
10
12
  import {
11
13
  classifyProcess,
12
14
  detectOrphanedProcesses,
@@ -248,6 +250,73 @@ async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
248
250
  }));
249
251
  }
250
252
 
253
+ async function getDockerContainerState(
254
+ containerName: string,
255
+ ): Promise<string | null> {
256
+ try {
257
+ const output = await execOutput("docker", [
258
+ "inspect",
259
+ "--format",
260
+ "{{.State.Status}}",
261
+ containerName,
262
+ ]);
263
+ return output.trim() || "unknown";
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ function isLocalProcessAlive(pid: number): boolean {
270
+ try {
271
+ process.kill(pid, 0);
272
+ return true;
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ async function getDockerProcesses(entry: AssistantEntry): Promise<TableRow[]> {
279
+ const res = dockerResourceNames(entry.assistantId);
280
+
281
+ const containers: { name: string; containerName: string }[] = [
282
+ { name: "assistant", containerName: res.assistantContainer },
283
+ { name: "gateway", containerName: res.gatewayContainer },
284
+ { name: "credential-executor", containerName: res.cesContainer },
285
+ ];
286
+
287
+ const results = await Promise.all(
288
+ containers.map(async ({ name, containerName }) => {
289
+ const state = await getDockerContainerState(containerName);
290
+ if (!state) {
291
+ return {
292
+ name,
293
+ status: withStatusEmoji("not found"),
294
+ info: `container ${containerName}`,
295
+ };
296
+ }
297
+ return {
298
+ name,
299
+ status: withStatusEmoji(state === "running" ? "running" : state),
300
+ info: `container ${containerName}`,
301
+ };
302
+ }),
303
+ );
304
+
305
+ // Show the file watcher process if the instance was hatched with --watch.
306
+ const watcherPid =
307
+ typeof entry.watcherPid === "number" ? entry.watcherPid : null;
308
+ if (watcherPid !== null) {
309
+ const alive = isLocalProcessAlive(watcherPid);
310
+ results.push({
311
+ name: "file-watcher",
312
+ status: withStatusEmoji(alive ? "running" : "not running"),
313
+ info: alive ? `PID ${watcherPid}` : "not detected",
314
+ });
315
+ }
316
+
317
+ return results;
318
+ }
319
+
251
320
  async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
252
321
  const cloud = resolveCloud(entry);
253
322
 
@@ -259,6 +328,12 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
259
328
  return;
260
329
  }
261
330
 
331
+ if (cloud === "docker") {
332
+ const rows = await getDockerProcesses(entry);
333
+ printTable(rows);
334
+ return;
335
+ }
336
+
262
337
  let output: string;
263
338
  try {
264
339
  if (cloud === "gcp") {
@@ -357,12 +432,23 @@ async function listAllAssistants(): Promise<void> {
357
432
  if (!alive) {
358
433
  health = { status: "sleeping", detail: null };
359
434
  } else {
360
- health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
435
+ const token = loadGuardianToken(a.assistantId)?.accessToken;
436
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
437
+ }
438
+ } else if (a.cloud === "docker") {
439
+ const res = dockerResourceNames(a.assistantId);
440
+ const state = await getDockerContainerState(res.assistantContainer);
441
+ if (!state || state !== "running") {
442
+ health = { status: "sleeping", detail: null };
443
+ } else {
444
+ const token = loadGuardianToken(a.assistantId)?.accessToken;
445
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
361
446
  }
362
447
  } else if (a.cloud === "vellum") {
363
448
  health = await checkManagedHealth(a.runtimeUrl, a.assistantId);
364
449
  } else {
365
- health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
450
+ const token = loadGuardianToken(a.assistantId)?.accessToken;
451
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
366
452
  }
367
453
 
368
454
  const infoParts = [a.runtimeUrl];