@vellumai/cli 0.1.9 → 0.1.11

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.
@@ -0,0 +1,322 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ import {
6
+ findAssistantByName,
7
+ loadAllAssistants,
8
+ type AssistantEntry,
9
+ } from "../lib/assistant-config";
10
+ import { checkHealth } from "../lib/health-check";
11
+ import { withStatusEmoji } from "../lib/status-emoji";
12
+ import { execOutput } from "../lib/step-runner";
13
+
14
+ // ── Table formatting helpers ────────────────────────────────────
15
+
16
+ interface TableRow {
17
+ name: string;
18
+ status: string;
19
+ info: string;
20
+ }
21
+
22
+ interface ColWidths {
23
+ name: number;
24
+ status: number;
25
+ info: number;
26
+ }
27
+
28
+ function pad(s: string, w: number): string {
29
+ return s + " ".repeat(Math.max(0, w - s.length));
30
+ }
31
+
32
+ function computeColWidths(rows: TableRow[]): ColWidths {
33
+ const headers: TableRow = { name: "NAME", status: "STATUS", info: "INFO" };
34
+ const all = [headers, ...rows];
35
+ return {
36
+ name: Math.max(...all.map((r) => r.name.length)),
37
+ status: Math.max(...all.map((r) => r.status.length), "checking...".length),
38
+ info: Math.max(...all.map((r) => r.info.length)),
39
+ };
40
+ }
41
+
42
+ function formatRow(r: TableRow, colWidths: ColWidths): string {
43
+ return ` ${pad(r.name, colWidths.name)} ${pad(r.status, colWidths.status)} ${r.info}`;
44
+ }
45
+
46
+ function printTable(rows: TableRow[]): void {
47
+ const colWidths = computeColWidths(rows);
48
+ const headers: TableRow = { name: "PROCESS", status: "STATUS", info: "INFO" };
49
+ console.log(formatRow(headers, colWidths));
50
+ const sep = ` ${"-".repeat(colWidths.name)} ${"-".repeat(colWidths.status)} ${"-".repeat(colWidths.info)}`;
51
+ console.log(sep);
52
+ for (const row of rows) {
53
+ console.log(formatRow(row, colWidths));
54
+ }
55
+ }
56
+
57
+ // ── Remote process listing via SSH ──────────────────────────────
58
+
59
+ const SSH_OPTS = [
60
+ "-o", "StrictHostKeyChecking=no",
61
+ "-o", "UserKnownHostsFile=/dev/null",
62
+ "-o", "ConnectTimeout=10",
63
+ "-o", "LogLevel=ERROR",
64
+ ];
65
+
66
+ const REMOTE_PS_CMD = [
67
+ // List vellum-related processes: daemon, gateway, qdrant, and any bun children
68
+ "ps ax -o pid=,ppid=,args=",
69
+ "| grep -E 'vellum|gateway|qdrant|openclaw'",
70
+ "| grep -v grep",
71
+ ].join(" ");
72
+
73
+ interface RemoteProcess {
74
+ pid: string;
75
+ ppid: string;
76
+ command: string;
77
+ }
78
+
79
+ function classifyProcess(command: string): string {
80
+ if (/qdrant/.test(command)) return "qdrant";
81
+ if (/gateway/.test(command)) return "gateway";
82
+ if (/openclaw/.test(command)) return "openclaw-adapter";
83
+ if (/daemon\s+(start|restart)/.test(command)) return "daemon";
84
+ if (/vellum/.test(command)) return "vellum";
85
+ return "unknown";
86
+ }
87
+
88
+ function parseRemotePs(output: string): RemoteProcess[] {
89
+ return output
90
+ .trim()
91
+ .split("\n")
92
+ .filter((line) => line.trim().length > 0)
93
+ .map((line) => {
94
+ const trimmed = line.trim();
95
+ const parts = trimmed.split(/\s+/);
96
+ const pid = parts[0];
97
+ const ppid = parts[1];
98
+ const command = parts.slice(2).join(" ");
99
+ return { pid, ppid, command };
100
+ });
101
+ }
102
+
103
+ function extractHostFromUrl(url: string): string {
104
+ try {
105
+ const parsed = new URL(url);
106
+ return parsed.hostname;
107
+ } catch {
108
+ return url.replace(/^https?:\/\//, "").split(":")[0];
109
+ }
110
+ }
111
+
112
+ function resolveCloud(entry: AssistantEntry): string {
113
+ if (entry.cloud) return entry.cloud;
114
+ if (entry.project) return "gcp";
115
+ if (entry.sshUser) return "custom";
116
+ return "local";
117
+ }
118
+
119
+ async function getRemoteProcessesGcp(
120
+ entry: AssistantEntry,
121
+ ): Promise<string> {
122
+ return execOutput("gcloud", [
123
+ "compute",
124
+ "ssh",
125
+ `${entry.sshUser ?? entry.assistantId}@${entry.assistantId}`,
126
+ `--zone=${entry.zone}`,
127
+ `--project=${entry.project}`,
128
+ `--command=${REMOTE_PS_CMD}`,
129
+ "--ssh-flag=-o StrictHostKeyChecking=no",
130
+ "--ssh-flag=-o UserKnownHostsFile=/dev/null",
131
+ "--ssh-flag=-o ConnectTimeout=10",
132
+ "--ssh-flag=-o LogLevel=ERROR",
133
+ ]);
134
+ }
135
+
136
+ async function getRemoteProcessesCustom(
137
+ entry: AssistantEntry,
138
+ ): Promise<string> {
139
+ const host = extractHostFromUrl(entry.runtimeUrl);
140
+ const sshUser = entry.sshUser ?? "root";
141
+ return execOutput("ssh", [
142
+ ...SSH_OPTS,
143
+ `${sshUser}@${host}`,
144
+ REMOTE_PS_CMD,
145
+ ]);
146
+ }
147
+
148
+ async function getLocalProcesses(): Promise<TableRow[]> {
149
+ const rows: TableRow[] = [];
150
+ const vellumDir = join(homedir(), ".vellum");
151
+
152
+ // Check daemon PID
153
+ const pidFile = join(vellumDir, "vellum.pid");
154
+ if (existsSync(pidFile)) {
155
+ const pid = readFileSync(pidFile, "utf-8").trim();
156
+ let status = "running";
157
+ try {
158
+ process.kill(parseInt(pid, 10), 0);
159
+ } catch {
160
+ status = "not running";
161
+ }
162
+ rows.push({ name: "daemon", status: withStatusEmoji(status), info: `PID ${pid}` });
163
+ } else {
164
+ rows.push({ name: "daemon", status: withStatusEmoji("not running"), info: "no PID file" });
165
+ }
166
+
167
+ // Check qdrant PID
168
+ const qdrantPidFile = join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid");
169
+ if (existsSync(qdrantPidFile)) {
170
+ const pid = readFileSync(qdrantPidFile, "utf-8").trim();
171
+ let status = "running";
172
+ try {
173
+ process.kill(parseInt(pid, 10), 0);
174
+ } catch {
175
+ status = "not running";
176
+ }
177
+ rows.push({ name: "qdrant", status: withStatusEmoji(status), info: `PID ${pid} | port 6333` });
178
+ } else {
179
+ rows.push({ name: "qdrant", status: withStatusEmoji("not running"), info: "no PID file" });
180
+ }
181
+
182
+ // Check gateway via ps
183
+ try {
184
+ const output = await execOutput("ps", ["ax", "-o", "pid=,command="]);
185
+ const gatewayLines = output
186
+ .split("\n")
187
+ .filter((l) => /gateway\/src\/index\.ts/.test(l) && !l.includes("grep"));
188
+ if (gatewayLines.length > 0) {
189
+ const trimmed = gatewayLines[0].trim();
190
+ const pid = trimmed.split(/\s+/)[0];
191
+ rows.push({ name: "gateway", status: withStatusEmoji("running"), info: `PID ${pid} | port 7830` });
192
+ } else {
193
+ rows.push({ name: "gateway", status: withStatusEmoji("not running"), info: "" });
194
+ }
195
+ } catch {
196
+ rows.push({ name: "gateway", status: withStatusEmoji("unknown"), info: "" });
197
+ }
198
+
199
+ return rows;
200
+ }
201
+
202
+ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
203
+ const cloud = resolveCloud(entry);
204
+
205
+ console.log(`Processes for ${entry.assistantId} (${cloud}):\n`);
206
+
207
+ if (cloud === "local") {
208
+ const rows = await getLocalProcesses();
209
+ printTable(rows);
210
+ return;
211
+ }
212
+
213
+ let output: string;
214
+ try {
215
+ if (cloud === "gcp") {
216
+ output = await getRemoteProcessesGcp(entry);
217
+ } else if (cloud === "custom") {
218
+ output = await getRemoteProcessesCustom(entry);
219
+ } else {
220
+ console.error(`Unsupported cloud type '${cloud}' for process listing.`);
221
+ process.exit(1);
222
+ }
223
+ } catch (error) {
224
+ console.error(`Failed to list processes: ${error instanceof Error ? error.message : error}`);
225
+ process.exit(1);
226
+ }
227
+
228
+ const procs = parseRemotePs(output);
229
+
230
+ if (procs.length === 0) {
231
+ console.log("No vellum processes found on the remote instance.");
232
+ return;
233
+ }
234
+
235
+ const rows: TableRow[] = procs.map((p) => ({
236
+ name: classifyProcess(p.command),
237
+ status: withStatusEmoji("running"),
238
+ info: `PID ${p.pid} | ${p.command.slice(0, 80)}`,
239
+ }));
240
+
241
+ printTable(rows);
242
+ }
243
+
244
+ // ── List all assistants (no arg) ────────────────────────────────
245
+
246
+ async function listAllAssistants(): Promise<void> {
247
+ const assistants = loadAllAssistants();
248
+
249
+ if (assistants.length === 0) {
250
+ console.log("No assistants found.");
251
+ return;
252
+ }
253
+
254
+ const rows: TableRow[] = assistants.map((a) => {
255
+ const infoParts = [a.runtimeUrl];
256
+ if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
257
+ if (a.species) infoParts.push(`species: ${a.species}`);
258
+
259
+ return {
260
+ name: a.assistantId,
261
+ status: withStatusEmoji("checking..."),
262
+ info: infoParts.join(" | "),
263
+ };
264
+ });
265
+
266
+ const colWidths = computeColWidths(rows);
267
+
268
+ const headers: TableRow = { name: "NAME", status: "STATUS", info: "INFO" };
269
+ console.log(formatRow(headers, colWidths));
270
+ const sep = ` ${"-".repeat(colWidths.name)} ${"-".repeat(colWidths.status)} ${"-".repeat(colWidths.info)}`;
271
+ console.log(sep);
272
+ for (const row of rows) {
273
+ console.log(formatRow(row, colWidths));
274
+ }
275
+
276
+ const totalDataRows = rows.length;
277
+
278
+ await Promise.all(
279
+ assistants.map(async (a, rowIndex) => {
280
+ const health = await checkHealth(a.runtimeUrl);
281
+
282
+ const infoParts = [a.runtimeUrl];
283
+ if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
284
+ if (a.species) infoParts.push(`species: ${a.species}`);
285
+ if (health.detail) infoParts.push(health.detail);
286
+
287
+ const updatedRow: TableRow = {
288
+ name: a.assistantId,
289
+ status: withStatusEmoji(health.status),
290
+ info: infoParts.join(" | "),
291
+ };
292
+
293
+ const linesUp = totalDataRows - rowIndex;
294
+ process.stdout.write(
295
+ `\x1b[${linesUp}A` +
296
+ `\r\x1b[K` +
297
+ formatRow(updatedRow, colWidths) +
298
+ `\n` +
299
+ (linesUp > 1 ? `\x1b[${linesUp - 1}B` : ""),
300
+ );
301
+ }),
302
+ );
303
+ }
304
+
305
+ // ── Entry point ─────────────────────────────────────────────────
306
+
307
+ export async function ps(): Promise<void> {
308
+ const assistantId = process.argv[3];
309
+
310
+ if (!assistantId) {
311
+ await listAllAssistants();
312
+ return;
313
+ }
314
+
315
+ const entry = findAssistantByName(assistantId);
316
+ if (!entry) {
317
+ console.error(`No assistant found with name '${assistantId}'.`);
318
+ process.exit(1);
319
+ }
320
+
321
+ await showAssistantProcesses(entry);
322
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { existsSync, readFileSync, rmSync, unlinkSync } from "fs";
2
+ import { rmSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { join } from "path";
5
5
 
@@ -7,6 +7,7 @@ import { findAssistantByName, removeAssistantEntry } from "../lib/assistant-conf
7
7
  import type { AssistantEntry } from "../lib/assistant-config";
8
8
  import { retireInstance as retireAwsInstance } from "../lib/aws";
9
9
  import { retireInstance as retireGcpInstance } from "../lib/gcp";
10
+ import { stopProcessByPidFile } from "../lib/process";
10
11
  import { exec } from "../lib/step-runner";
11
12
 
12
13
  function resolveCloud(entry: AssistantEntry): string {
@@ -37,39 +38,17 @@ async function retireLocal(): Promise<void> {
37
38
  const vellumDir = join(homedir(), ".vellum");
38
39
  const isDesktopApp = !!process.env.VELLUM_DESKTOP_APP;
39
40
 
40
- // Stop daemon via PID file (works for both desktop app and standalone)
41
- const pidFile = join(vellumDir, "vellum.pid");
41
+ // Stop daemon via PID file
42
+ const daemonPidFile = join(vellumDir, "vellum.pid");
42
43
  const socketFile = join(vellumDir, "vellum.sock");
44
+ await stopProcessByPidFile(daemonPidFile, "daemon", [socketFile]);
43
45
 
44
- if (existsSync(pidFile)) {
45
- try {
46
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
47
- if (!isNaN(pid)) {
48
- try {
49
- process.kill(pid, 0); // Check if alive
50
- process.kill(pid, "SIGTERM");
51
- const deadline = Date.now() + 2000;
52
- while (Date.now() < deadline) {
53
- try {
54
- process.kill(pid, 0);
55
- await new Promise((r) => setTimeout(r, 100));
56
- } catch {
57
- break;
58
- }
59
- }
60
- try {
61
- process.kill(pid, 0);
62
- process.kill(pid, "SIGKILL");
63
- } catch {}
64
- } catch {}
65
- }
66
- } catch {}
67
- try { unlinkSync(pidFile); } catch {}
68
- try { unlinkSync(socketFile); } catch {}
69
- }
46
+ // Stop gateway via PID file
47
+ const gatewayPidFile = join(vellumDir, "gateway.pid");
48
+ await stopProcessByPidFile(gatewayPidFile, "gateway");
70
49
 
71
50
  if (!isDesktopApp) {
72
- // Non-desktop: also stop daemon via bunx (fallback) and kill gateway
51
+ // Non-desktop: also stop daemon via bunx (fallback)
73
52
  try {
74
53
  const child = spawn("bunx", ["vellum", "daemon", "stop"], {
75
54
  stdio: "inherit",
@@ -81,17 +60,6 @@ async function retireLocal(): Promise<void> {
81
60
  });
82
61
  } catch {}
83
62
 
84
- try {
85
- const killGateway = spawn("pkill", ["-f", "gateway/src/index.ts"], {
86
- stdio: "ignore",
87
- });
88
-
89
- await new Promise<void>((resolve) => {
90
- killGateway.on("close", () => resolve());
91
- killGateway.on("error", () => resolve());
92
- });
93
- } catch {}
94
-
95
63
  // Only delete ~/.vellum in non-desktop mode
96
64
  rmSync(vellumDir, { recursive: true, force: true });
97
65
  }
@@ -1,63 +1,35 @@
1
- import { existsSync, readFileSync, unlinkSync } from "fs";
2
1
  import { homedir } from "os";
3
2
  import { join } from "path";
4
3
 
5
- export async function sleep(): Promise<void> {
6
- const vellumDir = join(homedir(), ".vellum");
7
- const pidFile = join(vellumDir, "vellum.pid");
8
- const socketFile = join(vellumDir, "vellum.sock");
9
-
10
- if (!existsSync(pidFile)) {
11
- console.log("No daemon PID file found — nothing to stop.");
12
- process.exit(0);
13
- }
14
-
15
- const pidStr = readFileSync(pidFile, "utf-8").trim();
16
- const pid = parseInt(pidStr, 10);
17
-
18
- if (isNaN(pid)) {
19
- console.log("Invalid PID file contents — cleaning up.");
20
- try { unlinkSync(pidFile); } catch {}
21
- try { unlinkSync(socketFile); } catch {}
22
- process.exit(0);
23
- }
4
+ import { loadAllAssistants } from "../lib/assistant-config";
5
+ import { stopProcessByPidFile } from "../lib/process";
24
6
 
25
- // Check if process is alive
26
- try {
27
- process.kill(pid, 0);
28
- } catch {
29
- console.log(`Daemon process ${pid} is not running cleaning up stale files.`);
30
- try { unlinkSync(pidFile); } catch {}
31
- try { unlinkSync(socketFile); } catch {}
32
- process.exit(0);
7
+ export async function sleep(): Promise<void> {
8
+ const assistants = loadAllAssistants();
9
+ const hasLocal = assistants.some((a) => a.cloud === "local");
10
+ if (!hasLocal) {
11
+ console.error("Error: No local assistant found in lock file. Run 'vellum hatch local' first.");
12
+ process.exit(1);
33
13
  }
34
14
 
35
- console.log(`Stopping daemon (pid ${pid})...`);
36
- process.kill(pid, "SIGTERM");
37
-
38
- // Wait up to 2s for graceful exit
39
- const deadline = Date.now() + 2000;
40
- while (Date.now() < deadline) {
41
- try {
42
- process.kill(pid, 0);
43
- await new Promise((r) => setTimeout(r, 100));
44
- } catch {
45
- break; // Process exited
46
- }
15
+ const vellumDir = join(homedir(), ".vellum");
16
+ const daemonPidFile = join(vellumDir, "vellum.pid");
17
+ const socketFile = join(vellumDir, "vellum.sock");
18
+ const gatewayPidFile = join(vellumDir, "gateway.pid");
19
+
20
+ // Stop daemon
21
+ const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [socketFile]);
22
+ if (!daemonStopped) {
23
+ console.log("Daemon is not running.");
24
+ } else {
25
+ console.log("Daemon stopped.");
47
26
  }
48
27
 
49
- // Force kill if still alive
50
- try {
51
- process.kill(pid, 0);
52
- console.log("Daemon did not exit after SIGTERM, sending SIGKILL...");
53
- process.kill(pid, "SIGKILL");
54
- } catch {
55
- // Already dead
28
+ // Stop gateway
29
+ const gatewayStopped = await stopProcessByPidFile(gatewayPidFile, "gateway");
30
+ if (!gatewayStopped) {
31
+ console.log("Gateway is not running.");
32
+ } else {
33
+ console.log("Gateway stopped.");
56
34
  }
57
-
58
- // Clean up PID and socket files
59
- try { unlinkSync(pidFile); } catch {}
60
- try { unlinkSync(socketFile); } catch {}
61
-
62
- console.log("Daemon stopped.");
63
35
  }
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ import { loadAllAssistants } from "../lib/assistant-config";
6
+ import { isProcessAlive } from "../lib/process";
7
+ import { startLocalDaemon, startGateway } from "../lib/local";
8
+
9
+ export async function wake(): Promise<void> {
10
+ const assistants = loadAllAssistants();
11
+ const hasLocal = assistants.some((a) => a.cloud === "local");
12
+ if (!hasLocal) {
13
+ console.error("Error: No local assistant found in lock file. Run 'vellum hatch local' first.");
14
+ process.exit(1);
15
+ }
16
+
17
+ const vellumDir = join(homedir(), ".vellum");
18
+ const pidFile = join(vellumDir, "vellum.pid");
19
+
20
+ // Check if daemon is already running
21
+ let daemonRunning = false;
22
+ if (existsSync(pidFile)) {
23
+ const pidStr = readFileSync(pidFile, "utf-8").trim();
24
+ const pid = parseInt(pidStr, 10);
25
+ if (!isNaN(pid)) {
26
+ try {
27
+ process.kill(pid, 0);
28
+ daemonRunning = true;
29
+ console.log(`Daemon already running (pid ${pid}).`);
30
+ } catch {
31
+ // Process not alive, will start below
32
+ }
33
+ }
34
+ }
35
+
36
+ if (!daemonRunning) {
37
+ await startLocalDaemon();
38
+ }
39
+
40
+ // Start gateway (non-desktop only)
41
+ if (!process.env.VELLUM_DESKTOP_APP) {
42
+ const gatewayPidFile = join(vellumDir, "gateway.pid");
43
+ const { alive, pid } = isProcessAlive(gatewayPidFile);
44
+ if (alive) {
45
+ console.log(`Gateway already running (pid ${pid}).`);
46
+ } else {
47
+ await startGateway();
48
+ }
49
+ }
50
+
51
+ console.log("✅ Wake complete.");
52
+ }
package/src/index.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { hatch } from "./commands/hatch";
4
+ import { ps } from "./commands/ps";
4
5
  import { retire } from "./commands/retire";
5
6
  import { sleep } from "./commands/sleep";
7
+ import { wake } from "./commands/wake";
6
8
 
7
9
  const commands = {
8
10
  hatch,
11
+ ps,
9
12
  retire,
10
13
  sleep,
14
+ wake,
11
15
  } as const;
12
16
 
13
17
  type CommandName = keyof typeof commands;
@@ -21,8 +25,10 @@ async function main() {
21
25
  console.log("");
22
26
  console.log("Commands:");
23
27
  console.log(" hatch Create a new assistant instance");
28
+ console.log(" ps List assistants (or processes for a specific assistant)");
24
29
  console.log(" retire Delete an assistant instance");
25
30
  console.log(" sleep Stop the daemon process");
31
+ console.log(" wake Start the daemon and gateway");
26
32
  process.exit(0);
27
33
  }
28
34
 
@@ -89,6 +89,10 @@ export function removeAssistantEntry(assistantId: string): void {
89
89
  writeAssistants(entries.filter((e) => e.assistantId !== assistantId));
90
90
  }
91
91
 
92
+ export function loadAllAssistants(): AssistantEntry[] {
93
+ return readAssistants();
94
+ }
95
+
92
96
  export function saveAssistantEntry(entry: AssistantEntry): void {
93
97
  const entries = readAssistants();
94
98
  entries.unshift(entry);
package/src/lib/aws.ts CHANGED
@@ -556,11 +556,29 @@ async function getInstanceIdByName(
556
556
  }
557
557
  }
558
558
 
559
+ async function checkAwsCliAvailable(): Promise<boolean> {
560
+ try {
561
+ await execOutput("aws", ["--version"]);
562
+ return true;
563
+ } catch {
564
+ return false;
565
+ }
566
+ }
567
+
559
568
  export async function retireInstance(
560
569
  name: string,
561
570
  region: string,
562
571
  source?: string,
563
572
  ): Promise<void> {
573
+ const awsOk = await checkAwsCliAvailable();
574
+ if (!awsOk) {
575
+ throw new Error(
576
+ `Cannot retire AWS instance '${name}': AWS CLI is not installed or not in PATH. ` +
577
+ `Please install the AWS CLI and try again, or terminate the instance manually ` +
578
+ `via the AWS Console (region=${region}).`,
579
+ );
580
+ }
581
+
564
582
  const instanceId = await getInstanceIdByName(name, region);
565
583
  if (!instanceId) {
566
584
  console.warn(