@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 +5 -10
- package/package.json +1 -1
- package/src/__tests__/coverage.test.ts +6 -0
- package/src/commands/client.ts +2 -1
- package/src/commands/hatch.ts +42 -31
- package/src/commands/pair.ts +17 -1
- package/src/commands/ps.ts +88 -2
- package/src/commands/upgrade.ts +366 -0
- package/src/index.ts +6 -1
- package/src/lib/assistant-config.ts +2 -0
- package/src/lib/aws.ts +1 -3
- package/src/lib/docker.ts +458 -307
- package/src/lib/gcp.ts +1 -3
- package/src/lib/guardian-token.ts +17 -0
- package/src/lib/ngrok.ts +40 -23
- package/src/lib/process.ts +1 -1
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
|
-
##
|
|
9
|
+
## Scope
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
@@ -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
|
);
|
package/src/commands/client.ts
CHANGED
|
@@ -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?.
|
|
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++) {
|
package/src/commands/hatch.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
799
|
+
console.log(
|
|
800
|
+
`\n⚠️ ${healthTarget} stopped responding — shutting down.`,
|
|
801
|
+
);
|
|
791
802
|
await stopLocalProcesses(resources);
|
|
792
803
|
process.exit(1);
|
|
793
804
|
}
|
package/src/commands/pair.ts
CHANGED
|
@@ -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:");
|
package/src/commands/ps.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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];
|