@vellumai/cli 0.4.36 → 0.4.40

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,18 +1,25 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
- import { homedir } from "os";
3
2
  import { join } from "path";
4
3
 
5
- import { loadAllAssistants } from "../lib/assistant-config";
4
+ import {
5
+ defaultLocalResources,
6
+ resolveTargetAssistant,
7
+ } from "../lib/assistant-config.js";
6
8
  import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
7
9
  import { startLocalDaemon, startGateway } from "../lib/local";
8
10
 
9
11
  export async function wake(): Promise<void> {
10
12
  const args = process.argv.slice(3);
11
13
  if (args.includes("--help") || args.includes("-h")) {
12
- console.log("Usage: vellum wake [options]");
14
+ console.log("Usage: vellum wake [<name>] [options]");
13
15
  console.log("");
14
16
  console.log("Start the assistant and gateway processes.");
15
17
  console.log("");
18
+ console.log("Arguments:");
19
+ console.log(
20
+ " <name> Name of the assistant to start (default: active or only local)",
21
+ );
22
+ console.log("");
16
23
  console.log("Options:");
17
24
  console.log(
18
25
  " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
@@ -21,19 +28,20 @@ export async function wake(): Promise<void> {
21
28
  }
22
29
 
23
30
  const watch = args.includes("--watch");
31
+ const nameArg = args.find((a) => !a.startsWith("-"));
32
+ const entry = resolveTargetAssistant(nameArg);
24
33
 
25
- const assistants = loadAllAssistants();
26
- const hasLocal = assistants.some((a) => a.cloud === "local");
27
- if (!hasLocal) {
34
+ if (entry.cloud && entry.cloud !== "local") {
28
35
  console.error(
29
- "Error: No local assistant found in lock file. Run 'vellum hatch local' first.",
36
+ `Error: 'vellum wake' only works with local assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
30
37
  );
31
38
  process.exit(1);
32
39
  }
33
40
 
34
- const vellumDir = join(homedir(), ".vellum");
35
- const pidFile = join(vellumDir, "vellum.pid");
36
- const socketFile = join(vellumDir, "vellum.sock");
41
+ const resources = entry.resources ?? defaultLocalResources();
42
+
43
+ const pidFile = resources.pidFile;
44
+ const socketFile = resources.socketPath;
37
45
 
38
46
  // Check if daemon is already running
39
47
  let daemonRunning = false;
@@ -61,11 +69,12 @@ export async function wake(): Promise<void> {
61
69
  }
62
70
 
63
71
  if (!daemonRunning) {
64
- await startLocalDaemon(watch);
72
+ await startLocalDaemon(watch, resources);
65
73
  }
66
74
 
67
- // Start gateway (non-desktop only)
68
- if (!process.env.VELLUM_DESKTOP_APP) {
75
+ // Start gateway
76
+ {
77
+ const vellumDir = join(resources.instanceDir, ".vellum");
69
78
  const gatewayPidFile = join(vellumDir, "gateway.pid");
70
79
  const { alive, pid } = isProcessAlive(gatewayPidFile);
71
80
  if (alive) {
@@ -75,14 +84,14 @@ export async function wake(): Promise<void> {
75
84
  `Gateway running (pid ${pid}) — restarting in watch mode...`,
76
85
  );
77
86
  await stopProcessByPidFile(gatewayPidFile, "gateway");
78
- await startGateway(undefined, watch);
87
+ await startGateway(undefined, watch, resources);
79
88
  } else {
80
89
  console.log(`Gateway already running (pid ${pid}).`);
81
90
  }
82
91
  } else {
83
- await startGateway(undefined, watch);
92
+ await startGateway(undefined, watch, resources);
84
93
  }
85
94
  }
86
95
 
87
- console.log("Wake complete.");
96
+ console.log("Wake complete.");
88
97
  }
package/src/index.ts CHANGED
@@ -1,11 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { existsSync } from "node:fs";
4
- import { createRequire } from "node:module";
5
- import { dirname, join } from "node:path";
6
- import { spawn } from "node:child_process";
7
- import { fileURLToPath } from "node:url";
8
-
9
3
  import cliPkg from "../package.json";
10
4
  import { client } from "./commands/client";
11
5
  import { hatch } from "./commands/hatch";
@@ -18,6 +12,7 @@ import { skills } from "./commands/skills";
18
12
  import { sleep } from "./commands/sleep";
19
13
  import { ssh } from "./commands/ssh";
20
14
  import { tunnel } from "./commands/tunnel";
15
+ import { use } from "./commands/use";
21
16
  import { wake } from "./commands/wake";
22
17
 
23
18
  const commands = {
@@ -33,37 +28,13 @@ const commands = {
33
28
  sleep,
34
29
  ssh,
35
30
  tunnel,
31
+ use,
36
32
  wake,
37
33
  whoami,
38
34
  } as const;
39
35
 
40
36
  type CommandName = keyof typeof commands;
41
37
 
42
- function resolveAssistantEntry(): string | undefined {
43
- // When installed globally, resolve from node_modules
44
- try {
45
- const require = createRequire(import.meta.url);
46
- const assistantPkgPath =
47
- require.resolve("@vellumai/assistant/package.json");
48
- return join(dirname(assistantPkgPath), "src", "index.ts");
49
- } catch {
50
- // For local development, resolve from sibling directory
51
- const __dirname = dirname(fileURLToPath(import.meta.url));
52
- const localPath = join(
53
- __dirname,
54
- "..",
55
- "..",
56
- "assistant",
57
- "src",
58
- "index.ts",
59
- );
60
- if (existsSync(localPath)) {
61
- return localPath;
62
- }
63
- }
64
- return undefined;
65
- }
66
-
67
38
  async function main() {
68
39
  const args = process.argv.slice(2);
69
40
  const commandName = args[0];
@@ -77,11 +48,7 @@ async function main() {
77
48
  console.log("Usage: vellum <command> [options]");
78
49
  console.log("");
79
50
  console.log("Commands:");
80
- console.log(" autonomy View and configure autonomy tiers");
81
51
  console.log(" client Connect to a hatched assistant");
82
- console.log(" config Manage configuration");
83
- console.log(" contacts Manage assistant contacts");
84
- console.log(" email Email operations (provider-agnostic)");
85
52
  console.log(" hatch Create a new assistant instance");
86
53
  console.log(" login Log in to the Vellum platform");
87
54
  console.log(" logout Log out of the Vellum platform");
@@ -95,6 +62,7 @@ async function main() {
95
62
  console.log(" sleep Stop the assistant process");
96
63
  console.log(" ssh SSH into a remote assistant instance");
97
64
  console.log(" tunnel Create a tunnel for a locally hosted assistant");
65
+ console.log(" use Set the active assistant for commands");
98
66
  console.log(" wake Start the assistant and gateway");
99
67
  console.log(" whoami Show current logged-in user");
100
68
  process.exit(0);
@@ -103,20 +71,8 @@ async function main() {
103
71
  const command = commands[commandName as CommandName];
104
72
 
105
73
  if (!command) {
106
- const assistantEntry = resolveAssistantEntry();
107
- if (assistantEntry) {
108
- const child = spawn("bun", ["run", assistantEntry, ...args], {
109
- stdio: "inherit",
110
- });
111
- child.on("exit", (code) => {
112
- process.exit(code ?? 1);
113
- });
114
- } else {
115
- console.error(`Unknown command: ${commandName}`);
116
- console.error("Install the full stack with: bun install -g vellum");
117
- process.exit(1);
118
- }
119
- return;
74
+ console.error(`Unknown command: ${commandName}`);
75
+ process.exit(1);
120
76
  }
121
77
 
122
78
  try {
@@ -1,10 +1,49 @@
1
- import { existsSync, readFileSync, writeFileSync } from "fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
+ import {
6
+ DEFAULT_DAEMON_PORT,
7
+ DEFAULT_GATEWAY_PORT,
8
+ DEFAULT_QDRANT_PORT,
9
+ } from "./constants.js";
10
+ import { probePort } from "./port-probe.js";
11
+
12
+ /**
13
+ * Per-instance resource paths and ports. Each local assistant instance gets
14
+ * its own directory tree, ports, and socket so multiple instances can run
15
+ * side-by-side without conflicts.
16
+ */
17
+ export interface LocalInstanceResources {
18
+ /**
19
+ * Instance-specific data root. The first local assistant uses `~` (workspace
20
+ * at `~/.vellum`); subsequent assistants use
21
+ * `~/.local/share/vellum/assistants/<name>/` (workspace at
22
+ * `~/.local/share/vellum/assistants/<name>/.vellum`).
23
+ * The daemon's `.vellum/` directory lives inside it. Equivalent to
24
+ * `AssistantEntry.baseDataDir` minus the trailing `/.vellum` suffix —
25
+ * `baseDataDir` is kept on the flat entry for legacy lockfile compat.
26
+ */
27
+ instanceDir: string;
28
+ /** HTTP port for the daemon runtime server */
29
+ daemonPort: number;
30
+ /** HTTP port for the gateway */
31
+ gatewayPort: number;
32
+ /** HTTP port for the Qdrant vector store */
33
+ qdrantPort: number;
34
+ /** Absolute path to the Unix domain socket for IPC */
35
+ socketPath: string;
36
+ /** Absolute path to the daemon PID file */
37
+ pidFile: string;
38
+ }
39
+
5
40
  export interface AssistantEntry {
6
41
  assistantId: string;
7
42
  runtimeUrl: string;
43
+ /** Loopback URL for same-machine health checks (e.g. `http://127.0.0.1:7831`).
44
+ * Avoids mDNS resolution issues when the machine checks its own gateway. */
45
+ localUrl?: string;
46
+ /** @deprecated Use `resources.instanceDir` for multi-instance entries. Legacy equivalent of `join(instanceDir, ".vellum")`. */
8
47
  baseDataDir?: string;
9
48
  bearerToken?: string;
10
49
  cloud: string;
@@ -16,10 +55,13 @@ export interface AssistantEntry {
16
55
  sshUser?: string;
17
56
  zone?: string;
18
57
  hatchedAt?: string;
58
+ /** Per-instance resource config. Present for local entries in multi-instance setups. */
59
+ resources?: LocalInstanceResources;
19
60
  }
20
61
 
21
62
  interface LockfileData {
22
63
  assistants?: AssistantEntry[];
64
+ activeAssistant?: string;
23
65
  platformBaseUrl?: string;
24
66
  [key: string]: unknown;
25
67
  }
@@ -91,14 +133,70 @@ export function findAssistantByName(name: string): AssistantEntry | null {
91
133
  }
92
134
 
93
135
  export function removeAssistantEntry(assistantId: string): void {
94
- const entries = readAssistants();
95
- writeAssistants(entries.filter((e) => e.assistantId !== assistantId));
136
+ const data = readLockfile();
137
+ const entries = (data.assistants ?? []).filter(
138
+ (e: AssistantEntry) => e.assistantId !== assistantId,
139
+ );
140
+ data.assistants = entries;
141
+ // Clear active assistant if it matches the removed entry
142
+ if (data.activeAssistant === assistantId) {
143
+ delete data.activeAssistant;
144
+ }
145
+ writeLockfile(data);
96
146
  }
97
147
 
98
148
  export function loadAllAssistants(): AssistantEntry[] {
99
149
  return readAssistants();
100
150
  }
101
151
 
152
+ export function getActiveAssistant(): string | null {
153
+ const data = readLockfile();
154
+ return data.activeAssistant ?? null;
155
+ }
156
+
157
+ export function setActiveAssistant(assistantId: string): void {
158
+ const data = readLockfile();
159
+ data.activeAssistant = assistantId;
160
+ writeLockfile(data);
161
+ }
162
+
163
+ /**
164
+ * Resolve which assistant to target for a command. Priority:
165
+ * 1. Explicit name argument
166
+ * 2. Active assistant set via `vellum use`
167
+ * 3. Sole local assistant (when exactly one exists)
168
+ */
169
+ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
170
+ if (nameArg) {
171
+ const entry = findAssistantByName(nameArg);
172
+ if (!entry) {
173
+ console.error(`No assistant found with name '${nameArg}'.`);
174
+ process.exit(1);
175
+ }
176
+ return entry;
177
+ }
178
+
179
+ const active = getActiveAssistant();
180
+ if (active) {
181
+ const entry = findAssistantByName(active);
182
+ if (entry) return entry;
183
+ // Active assistant no longer exists in lockfile — fall through
184
+ }
185
+
186
+ const all = readAssistants();
187
+ const locals = all.filter((e) => e.cloud === "local");
188
+ if (locals.length === 1) return locals[0];
189
+
190
+ if (locals.length === 0) {
191
+ console.error("No local assistant found. Run 'vellum hatch local' first.");
192
+ } else {
193
+ console.error(
194
+ `Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,
195
+ );
196
+ }
197
+ process.exit(1);
198
+ }
199
+
102
200
  export function saveAssistantEntry(entry: AssistantEntry): void {
103
201
  const entries = readAssistants().filter(
104
202
  (e) => e.assistantId !== entry.assistantId,
@@ -107,6 +205,131 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
107
205
  writeAssistants(entries);
108
206
  }
109
207
 
208
+ /**
209
+ * Scan upward from `basePort` to find an available port. A port is considered
210
+ * available when `probePort()` returns false (nothing listening). Scans up to
211
+ * 100 ports above the base before giving up.
212
+ */
213
+ async function findAvailablePort(
214
+ basePort: number,
215
+ excludedPorts: number[] = [],
216
+ ): Promise<number> {
217
+ const maxOffset = 100;
218
+ for (let offset = 0; offset < maxOffset; offset++) {
219
+ const port = basePort + offset;
220
+ if (excludedPorts.includes(port)) continue;
221
+ const inUse = await probePort(port);
222
+ if (!inUse) return port;
223
+ }
224
+ throw new Error(
225
+ `Could not find an available port scanning from ${basePort} to ${basePort + maxOffset - 1}`,
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Allocate an isolated set of resources for a named local instance.
231
+ * The first local assistant gets `instanceDir = ~` with default ports (same as
232
+ * legacy single-instance layout). Subsequent assistants are placed under
233
+ * `~/.local/share/vellum/assistants/<name>/` with scanned ports.
234
+ */
235
+ export async function allocateLocalResources(
236
+ instanceName: string,
237
+ ): Promise<LocalInstanceResources> {
238
+ // First local assistant gets the home directory — identical to legacy layout.
239
+ const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
240
+ if (existingLocals.length === 0) {
241
+ return defaultLocalResources();
242
+ }
243
+
244
+ const instanceDir = join(
245
+ homedir(),
246
+ ".local",
247
+ "share",
248
+ "vellum",
249
+ "assistants",
250
+ instanceName,
251
+ );
252
+ mkdirSync(instanceDir, { recursive: true });
253
+
254
+ // Collect ports already assigned to other local instances in the lockfile.
255
+ // Even if those instances are stopped, we must avoid reusing their ports
256
+ // to prevent binding collisions when both are woken.
257
+ const reservedPorts: number[] = [];
258
+ for (const entry of loadAllAssistants()) {
259
+ if (entry.cloud !== "local") continue;
260
+ if (entry.resources) {
261
+ reservedPorts.push(
262
+ entry.resources.daemonPort,
263
+ entry.resources.gatewayPort,
264
+ entry.resources.qdrantPort,
265
+ );
266
+ } else {
267
+ // Legacy entries without resources use the default ports
268
+ reservedPorts.push(
269
+ DEFAULT_DAEMON_PORT,
270
+ DEFAULT_GATEWAY_PORT,
271
+ DEFAULT_QDRANT_PORT,
272
+ );
273
+ }
274
+ }
275
+
276
+ // Allocate ports sequentially to avoid overlapping ranges assigning the
277
+ // same port to multiple services (e.g. daemon 7821-7920 overlaps gateway 7830-7929).
278
+ const daemonPort = await findAvailablePort(
279
+ DEFAULT_DAEMON_PORT,
280
+ reservedPorts,
281
+ );
282
+ const gatewayPort = await findAvailablePort(DEFAULT_GATEWAY_PORT, [
283
+ ...reservedPorts,
284
+ daemonPort,
285
+ ]);
286
+ const qdrantPort = await findAvailablePort(DEFAULT_QDRANT_PORT, [
287
+ ...reservedPorts,
288
+ daemonPort,
289
+ gatewayPort,
290
+ ]);
291
+
292
+ return {
293
+ instanceDir,
294
+ daemonPort,
295
+ gatewayPort,
296
+ qdrantPort,
297
+ socketPath: join(instanceDir, ".vellum", "vellum.sock"),
298
+ pidFile: join(instanceDir, ".vellum", "vellum.pid"),
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Return default resources representing the legacy single-instance layout.
304
+ * Used to normalize existing lockfile entries so callers can treat all local
305
+ * entries uniformly.
306
+ */
307
+ export function defaultLocalResources(): LocalInstanceResources {
308
+ const vellumDir = join(homedir(), ".vellum");
309
+ return {
310
+ instanceDir: homedir(),
311
+ daemonPort: DEFAULT_DAEMON_PORT,
312
+ gatewayPort: DEFAULT_GATEWAY_PORT,
313
+ qdrantPort: DEFAULT_QDRANT_PORT,
314
+ socketPath: join(vellumDir, "vellum.sock"),
315
+ pidFile: join(vellumDir, "vellum.pid"),
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Normalize existing lockfile entries so local entries include resource fields.
321
+ * Remote entries are left untouched. Returns a new array (does not mutate input).
322
+ */
323
+ export function normalizeExistingEntryResources(
324
+ entries: AssistantEntry[],
325
+ ): AssistantEntry[] {
326
+ return entries.map((entry) => {
327
+ if (entry.cloud !== "local") return entry;
328
+ if (entry.resources) return entry;
329
+ return { ...entry, resources: defaultLocalResources() };
330
+ });
331
+ }
332
+
110
333
  /**
111
334
  * Read the assistant config file and sync client-relevant values to the
112
335
  * lockfile. This lets external tools (e.g. vel) discover the platform URL
@@ -2,6 +2,12 @@ export const FIREWALL_TAG = "vellum-assistant";
2
2
  export const GATEWAY_PORT = process.env.GATEWAY_PORT
3
3
  ? Number(process.env.GATEWAY_PORT)
4
4
  : 7830;
5
+
6
+ /** Default ports used as scan start points for multi-instance allocation. */
7
+ export const DEFAULT_DAEMON_PORT = 7821;
8
+ export const DEFAULT_GATEWAY_PORT = 7830;
9
+ export const DEFAULT_QDRANT_PORT = 6333;
10
+
5
11
  export const VALID_REMOTE_HOSTS = ["local", "gcp", "aws", "custom"] as const;
6
12
  export type RemoteHost = (typeof VALID_REMOTE_HOSTS)[number];
7
13
  export const VALID_SPECIES = ["openclaw", "vellum"] as const;