@vellumai/cli 0.4.29 → 0.4.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.29",
3
+ "version": "0.4.31",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -82,10 +82,7 @@ interface ContactChannel {
82
82
  interface Contact {
83
83
  id: string;
84
84
  displayName: string;
85
- relationship: string | null;
86
- importance: number;
87
- responseExpectation: string | null;
88
- preferredTone: string | null;
85
+ notes: string | null;
89
86
  lastInteraction: number | null;
90
87
  interactionCount: number;
91
88
  channels: ContactChannel[];
@@ -109,10 +106,7 @@ function formatContact(c: Contact): string {
109
106
  const lines = [
110
107
  ` ID: ${c.id}`,
111
108
  ` Name: ${c.displayName}`,
112
- ` Relationship: ${c.relationship ?? "(none)"}`,
113
- ` Importance: ${c.importance.toFixed(2)}`,
114
- ` Response: ${c.responseExpectation ?? "(none)"}`,
115
- ` Tone: ${c.preferredTone ?? "(none)"}`,
109
+ ` Notes: ${c.notes ?? "(none)"}`,
116
110
  ` Interactions: ${c.interactionCount}`,
117
111
  ];
118
112
  if (c.lastInteraction) {
@@ -136,7 +130,7 @@ function printUsage(): void {
136
130
  console.log("Usage: vellum contacts <subcommand> [options]");
137
131
  console.log("");
138
132
  console.log("Subcommands:");
139
- console.log(" list [--limit N] List all contacts");
133
+ console.log(" list [--limit N] [--role ROLE] List all contacts");
140
134
  console.log(" get <id> Get a contact by ID");
141
135
  console.log(" merge <keepId> <mergeId> Merge two contacts");
142
136
  console.log("");
@@ -161,7 +155,9 @@ export async function contacts(): Promise<void> {
161
155
  switch (subcommand) {
162
156
  case "list": {
163
157
  const limit = getFlagValue(args, "--limit") ?? "50";
164
- const data = (await apiGet(`contacts?limit=${limit}`)) as {
158
+ const role = getFlagValue(args, "--role");
159
+ const query = `contacts?limit=${limit}${role ? `&role=${encodeURIComponent(role)}` : ""}`;
160
+ const data = (await apiGet(query)) as {
165
161
  ok: boolean;
166
162
  contacts: Contact[];
167
163
  };
@@ -39,7 +39,6 @@ import type { PollResult, WatchHatchingResult } from "../lib/gcp";
39
39
  import {
40
40
  startLocalDaemon,
41
41
  startGateway,
42
- startOutboundProxy,
43
42
  stopLocalProcesses,
44
43
  } from "../lib/local";
45
44
  import { probePort } from "../lib/port-probe";
@@ -757,8 +756,6 @@ async function hatchLocal(
757
756
  throw error;
758
757
  }
759
758
 
760
- await startOutboundProxy(watch);
761
-
762
759
  // Read the bearer token written by the daemon so the client can authenticate
763
760
  // with the gateway (which requires auth by default).
764
761
  let bearerToken: string | undefined;
@@ -220,8 +220,6 @@ function formatDetectionInfo(proc: DetectedProcess): string {
220
220
  async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
221
221
  const vellumDir = entry.baseDataDir ?? join(homedir(), ".vellum");
222
222
 
223
- const PROXY_PORT = Number(process.env.PROXY_PORT) || 7829;
224
-
225
223
  const specs: ProcessSpec[] = [
226
224
  {
227
225
  name: "assistant",
@@ -241,12 +239,6 @@ async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
241
239
  port: GATEWAY_PORT,
242
240
  pidFile: join(vellumDir, "gateway.pid"),
243
241
  },
244
- {
245
- name: "outbound-proxy",
246
- pgrepName: "outbound-proxy",
247
- port: PROXY_PORT,
248
- pidFile: join(vellumDir, "outbound-proxy.pid"),
249
- },
250
242
  {
251
243
  name: "embed-worker",
252
244
  pgrepName: "embed-worker",
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { renameSync, writeFileSync } from "fs";
2
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { basename, dirname, join } from "path";
5
5
 
@@ -40,10 +40,14 @@ function extractHostFromUrl(url: string): string {
40
40
  }
41
41
  }
42
42
 
43
+ function getBaseDir(): string {
44
+ return process.env.BASE_DATA_DIR?.trim() || homedir();
45
+ }
46
+
43
47
  async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
44
48
  console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
45
49
 
46
- const vellumDir = join(homedir(), ".vellum");
50
+ const vellumDir = join(getBaseDir(), ".vellum");
47
51
 
48
52
  // Stop daemon via PID file
49
53
  const daemonPidFile = join(vellumDir, "vellum.pid");
@@ -56,22 +60,27 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
56
60
  const gatewayPidFile = join(vellumDir, "gateway.pid");
57
61
  await stopProcessByPidFile(gatewayPidFile, "gateway");
58
62
 
59
- // Stop outbound proxy via PID file
60
- const outboundProxyPidFile = join(vellumDir, "outbound-proxy.pid");
61
- await stopProcessByPidFile(outboundProxyPidFile, "outbound-proxy");
62
-
63
63
  // If the PID file didn't track a running daemon, scan for orphaned
64
64
  // daemon processes that may have been started without writing a PID.
65
65
  if (!daemonStopped) {
66
66
  await stopOrphanedDaemonProcesses();
67
67
  }
68
68
 
69
- // Move ~/.vellum out of the way so the path is immediately available for the
70
- // next hatch, then kick off the tar archive in the background.
69
+ // Move the data directory out of the way so the path is immediately available
70
+ // for the next hatch, then kick off the tar archive in the background.
71
71
  const archivePath = getArchivePath(name);
72
72
  const metadataPath = getMetadataPath(name);
73
73
  const stagingDir = `${archivePath}.staging`;
74
74
 
75
+ if (!existsSync(vellumDir)) {
76
+ console.log(` No data directory at ${vellumDir} — nothing to archive.`);
77
+ console.log("\u2705 Local instance retired.");
78
+ return;
79
+ }
80
+
81
+ // Ensure the retired archive directory exists before attempting the rename
82
+ mkdirSync(dirname(stagingDir), { recursive: true });
83
+
75
84
  try {
76
85
  renameSync(vellumDir, stagingDir);
77
86
  } catch (err) {
@@ -0,0 +1,389 @@
1
+ import { execSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+ import { gunzipSync } from "node:zlib";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Path helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function getRootDir(): string {
19
+ return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
20
+ }
21
+
22
+ function getSkillsDir(): string {
23
+ return join(getRootDir(), "workspace", "skills");
24
+ }
25
+
26
+ function getSkillsIndexPath(): string {
27
+ return join(getSkillsDir(), "SKILLS.md");
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Platform API client
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function getConfigPlatformUrl(): string | undefined {
35
+ try {
36
+ const configPath = join(getRootDir(), "workspace", "config.json");
37
+ if (!existsSync(configPath)) return undefined;
38
+ const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
39
+ string,
40
+ unknown
41
+ >;
42
+ const platform = raw.platform as Record<string, unknown> | undefined;
43
+ const baseUrl = platform?.baseUrl;
44
+ if (typeof baseUrl === "string" && baseUrl.trim()) return baseUrl.trim();
45
+ } catch {
46
+ // ignore
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function getPlatformUrl(): string {
52
+ return (
53
+ process.env.VELLUM_ASSISTANT_PLATFORM_URL ??
54
+ getConfigPlatformUrl() ??
55
+ "https://platform.vellum.ai"
56
+ );
57
+ }
58
+
59
+ function getPlatformToken(): string | null {
60
+ try {
61
+ return readFileSync(join(getRootDir(), "platform-token"), "utf-8").trim();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function buildHeaders(): Record<string, string> {
68
+ const headers: Record<string, string> = {};
69
+ const token = getPlatformToken();
70
+ if (token) {
71
+ headers["X-Session-Token"] = token;
72
+ }
73
+ return headers;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Types
78
+ // ---------------------------------------------------------------------------
79
+
80
+ interface CatalogSkill {
81
+ id: string;
82
+ name: string;
83
+ description: string;
84
+ emoji?: string;
85
+ includes?: string[];
86
+ version?: string;
87
+ }
88
+
89
+ interface CatalogManifest {
90
+ version: number;
91
+ skills: CatalogSkill[];
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Catalog operations
96
+ // ---------------------------------------------------------------------------
97
+
98
+ async function fetchCatalog(): Promise<CatalogSkill[]> {
99
+ const url = `${getPlatformUrl()}/v1/skills/`;
100
+ const response = await fetch(url, {
101
+ headers: buildHeaders(),
102
+ signal: AbortSignal.timeout(10000),
103
+ });
104
+
105
+ if (!response.ok) {
106
+ throw new Error(
107
+ `Platform API error ${response.status}: ${response.statusText}`,
108
+ );
109
+ }
110
+
111
+ const manifest = (await response.json()) as CatalogManifest;
112
+ if (!Array.isArray(manifest.skills)) {
113
+ throw new Error("Platform catalog has invalid skills array");
114
+ }
115
+ return manifest.skills;
116
+ }
117
+
118
+ /**
119
+ * Extract all files from a tar archive (uncompressed) into a directory.
120
+ * Returns true if a SKILL.md was found in the archive.
121
+ */
122
+ function extractTarToDir(tarBuffer: Buffer, destDir: string): boolean {
123
+ let foundSkillMd = false;
124
+ let offset = 0;
125
+ while (offset + 512 <= tarBuffer.length) {
126
+ const header = tarBuffer.subarray(offset, offset + 512);
127
+
128
+ // End-of-archive (two consecutive zero blocks)
129
+ if (header.every((b) => b === 0)) break;
130
+
131
+ // Filename (bytes 0-99, null-terminated)
132
+ const nameEnd = header.indexOf(0, 0);
133
+ const name = header
134
+ .subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100))
135
+ .toString("utf-8");
136
+
137
+ // File type (byte 156): '5' = directory, '0' or '\0' = regular file
138
+ const typeFlag = header[156];
139
+
140
+ // File size (bytes 124-135, octal)
141
+ const sizeStr = header.subarray(124, 136).toString("utf-8").trim();
142
+ const size = parseInt(sizeStr, 8) || 0;
143
+
144
+ offset += 512; // past header
145
+
146
+ // Skip directories and empty names
147
+ if (name && typeFlag !== 53 /* '5' */) {
148
+ // Prevent path traversal
149
+ const normalizedName = name.replace(/^\.\//, "");
150
+ if (!normalizedName.startsWith("..") && !normalizedName.includes("/..")) {
151
+ const destPath = join(destDir, normalizedName);
152
+ mkdirSync(dirname(destPath), { recursive: true });
153
+ writeFileSync(destPath, tarBuffer.subarray(offset, offset + size));
154
+
155
+ if (
156
+ normalizedName === "SKILL.md" ||
157
+ normalizedName.endsWith("/SKILL.md")
158
+ ) {
159
+ foundSkillMd = true;
160
+ }
161
+ }
162
+ }
163
+
164
+ // Skip to next header (data padded to 512 bytes)
165
+ offset += Math.ceil(size / 512) * 512;
166
+ }
167
+ return foundSkillMd;
168
+ }
169
+
170
+ async function fetchAndExtractSkill(
171
+ skillId: string,
172
+ destDir: string,
173
+ ): Promise<void> {
174
+ const url = `${getPlatformUrl()}/v1/skills/${encodeURIComponent(skillId)}/`;
175
+ const response = await fetch(url, {
176
+ headers: buildHeaders(),
177
+ signal: AbortSignal.timeout(15000),
178
+ });
179
+
180
+ if (!response.ok) {
181
+ throw new Error(
182
+ `Failed to fetch skill "${skillId}": HTTP ${response.status}`,
183
+ );
184
+ }
185
+
186
+ const gzipBuffer = Buffer.from(await response.arrayBuffer());
187
+ const tarBuffer = gunzipSync(gzipBuffer);
188
+ const foundSkillMd = extractTarToDir(tarBuffer, destDir);
189
+
190
+ if (!foundSkillMd) {
191
+ throw new Error(`SKILL.md not found in archive for "${skillId}"`);
192
+ }
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Managed skill installation
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function atomicWriteFile(filePath: string, content: string): void {
200
+ const dir = dirname(filePath);
201
+ mkdirSync(dir, { recursive: true });
202
+ const tmpPath = join(dir, `.tmp-${randomUUID()}`);
203
+ writeFileSync(tmpPath, content, "utf-8");
204
+ renameSync(tmpPath, filePath);
205
+ }
206
+
207
+ function upsertSkillsIndex(id: string): void {
208
+ const indexPath = getSkillsIndexPath();
209
+ let lines: string[] = [];
210
+ if (existsSync(indexPath)) {
211
+ lines = readFileSync(indexPath, "utf-8").split("\n");
212
+ }
213
+
214
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
215
+ const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
216
+ if (lines.some((line) => pattern.test(line))) return;
217
+
218
+ const nonEmpty = lines.filter((l) => l.trim());
219
+ nonEmpty.push(`- ${id}`);
220
+ const content = nonEmpty.join("\n");
221
+ atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
222
+ }
223
+
224
+ async function installSkillLocally(
225
+ skillId: string,
226
+ catalogEntry: CatalogSkill,
227
+ overwrite: boolean,
228
+ ): Promise<void> {
229
+ const skillDir = join(getSkillsDir(), skillId);
230
+ const skillFilePath = join(skillDir, "SKILL.md");
231
+
232
+ if (existsSync(skillFilePath) && !overwrite) {
233
+ throw new Error(
234
+ `Skill "${skillId}" is already installed. Use --overwrite to replace it.`,
235
+ );
236
+ }
237
+
238
+ mkdirSync(skillDir, { recursive: true });
239
+
240
+ // Extract all files from the archive into the skill directory
241
+ await fetchAndExtractSkill(skillId, skillDir);
242
+
243
+ // Write version metadata
244
+ if (catalogEntry.version) {
245
+ const meta = {
246
+ version: catalogEntry.version,
247
+ installedAt: new Date().toISOString(),
248
+ };
249
+ atomicWriteFile(
250
+ join(skillDir, "version.json"),
251
+ JSON.stringify(meta, null, 2) + "\n",
252
+ );
253
+ }
254
+
255
+ // Install npm dependencies if the skill has a package.json
256
+ if (existsSync(join(skillDir, "package.json"))) {
257
+ const bunPath = `${process.env.HOME || "/root"}/.bun/bin`;
258
+ execSync("bun install", {
259
+ cwd: skillDir,
260
+ stdio: "inherit",
261
+ env: { ...process.env, PATH: `${bunPath}:${process.env.PATH}` },
262
+ });
263
+ }
264
+
265
+ // Register in SKILLS.md only after all steps succeed
266
+ upsertSkillsIndex(skillId);
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Helpers
271
+ // ---------------------------------------------------------------------------
272
+
273
+ function hasFlag(args: string[], flag: string): boolean {
274
+ return args.includes(flag);
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Usage
279
+ // ---------------------------------------------------------------------------
280
+
281
+ function printUsage(): void {
282
+ console.log("Usage: vellum skills <subcommand> [options]");
283
+ console.log("");
284
+ console.log("Subcommands:");
285
+ console.log(
286
+ " list List available catalog skills",
287
+ );
288
+ console.log(
289
+ " install <skill-id> [--overwrite] Install a skill from the catalog",
290
+ );
291
+ console.log("");
292
+ console.log("Options:");
293
+ console.log(" --json Machine-readable JSON output");
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Command entry point
298
+ // ---------------------------------------------------------------------------
299
+
300
+ export async function skills(): Promise<void> {
301
+ const args = process.argv.slice(3);
302
+ const subcommand = args[0];
303
+ const json = hasFlag(args, "--json");
304
+
305
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
306
+ printUsage();
307
+ return;
308
+ }
309
+
310
+ switch (subcommand) {
311
+ case "list": {
312
+ try {
313
+ const catalog = await fetchCatalog();
314
+
315
+ if (json) {
316
+ console.log(JSON.stringify({ ok: true, skills: catalog }));
317
+ return;
318
+ }
319
+
320
+ if (catalog.length === 0) {
321
+ console.log("No skills available in the catalog.");
322
+ return;
323
+ }
324
+
325
+ console.log(`Available skills (${catalog.length}):\n`);
326
+ for (const s of catalog) {
327
+ const emoji = s.emoji ? `${s.emoji} ` : "";
328
+ const deps = s.includes?.length
329
+ ? ` (requires: ${s.includes.join(", ")})`
330
+ : "";
331
+ console.log(` ${emoji}${s.id}`);
332
+ console.log(` ${s.name} — ${s.description}${deps}`);
333
+ }
334
+ } catch (err) {
335
+ const msg = err instanceof Error ? err.message : String(err);
336
+ if (json) {
337
+ console.log(JSON.stringify({ ok: false, error: msg }));
338
+ } else {
339
+ console.error(`Error: ${msg}`);
340
+ }
341
+ process.exitCode = 1;
342
+ }
343
+ break;
344
+ }
345
+
346
+ case "install": {
347
+ const skillId = args.find((a) => !a.startsWith("--") && a !== "install");
348
+ if (!skillId) {
349
+ console.error("Usage: vellum skills install <skill-id>");
350
+ process.exit(1);
351
+ }
352
+
353
+ const overwrite = hasFlag(args, "--overwrite");
354
+
355
+ try {
356
+ // Verify skill exists in catalog
357
+ const catalog = await fetchCatalog();
358
+ const entry = catalog.find((s) => s.id === skillId);
359
+ if (!entry) {
360
+ throw new Error(`Skill "${skillId}" not found in the Vellum catalog`);
361
+ }
362
+
363
+ // Fetch, extract, and install
364
+ await installSkillLocally(skillId, entry, overwrite);
365
+
366
+ if (json) {
367
+ console.log(JSON.stringify({ ok: true, skillId }));
368
+ } else {
369
+ console.log(`Installed skill "${skillId}".`);
370
+ }
371
+ } catch (err) {
372
+ const msg = err instanceof Error ? err.message : String(err);
373
+ if (json) {
374
+ console.log(JSON.stringify({ ok: false, error: msg }));
375
+ } else {
376
+ console.error(`Error: ${msg}`);
377
+ }
378
+ process.exitCode = 1;
379
+ }
380
+ break;
381
+ }
382
+
383
+ default: {
384
+ console.error(`Unknown skills subcommand: ${subcommand}`);
385
+ printUsage();
386
+ process.exit(1);
387
+ }
388
+ }
389
+ }
@@ -8,7 +8,7 @@ export async function sleep(): Promise<void> {
8
8
  if (args.includes("--help") || args.includes("-h")) {
9
9
  console.log("Usage: vellum sleep");
10
10
  console.log("");
11
- console.log("Stop the assistant, gateway, and outbound-proxy processes.");
11
+ console.log("Stop the assistant and gateway processes.");
12
12
  process.exit(0);
13
13
  }
14
14
 
@@ -16,7 +16,6 @@ export async function sleep(): Promise<void> {
16
16
  const daemonPidFile = join(vellumDir, "vellum.pid");
17
17
  const socketFile = join(vellumDir, "vellum.sock");
18
18
  const gatewayPidFile = join(vellumDir, "gateway.pid");
19
- const outboundProxyPidFile = join(vellumDir, "outbound-proxy.pid");
20
19
 
21
20
  // Stop daemon
22
21
  const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
@@ -36,14 +35,4 @@ export async function sleep(): Promise<void> {
36
35
  console.log("Gateway stopped.");
37
36
  }
38
37
 
39
- // Stop outbound proxy
40
- const outboundProxyStopped = await stopProcessByPidFile(
41
- outboundProxyPidFile,
42
- "outbound-proxy",
43
- );
44
- if (!outboundProxyStopped) {
45
- console.log("Outbound proxy is not running.");
46
- } else {
47
- console.log("Outbound proxy stopped.");
48
- }
49
38
  }
@@ -3,22 +3,25 @@ import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
5
  import { loadAllAssistants } from "../lib/assistant-config";
6
- import { isProcessAlive } from "../lib/process";
7
- import {
8
- startLocalDaemon,
9
- startGateway,
10
- startOutboundProxy,
11
- } from "../lib/local";
6
+ import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
7
+ import { startLocalDaemon, startGateway } from "../lib/local";
12
8
 
13
9
  export async function wake(): Promise<void> {
14
10
  const args = process.argv.slice(3);
15
11
  if (args.includes("--help") || args.includes("-h")) {
16
- console.log("Usage: vellum wake");
12
+ console.log("Usage: vellum wake [options]");
17
13
  console.log("");
18
14
  console.log("Start the assistant and gateway processes.");
15
+ console.log("");
16
+ console.log("Options:");
17
+ console.log(
18
+ " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
19
+ );
19
20
  process.exit(0);
20
21
  }
21
22
 
23
+ const watch = args.includes("--watch");
24
+
22
25
  const assistants = loadAllAssistants();
23
26
  const hasLocal = assistants.some((a) => a.cloud === "local");
24
27
  if (!hasLocal) {
@@ -30,6 +33,7 @@ export async function wake(): Promise<void> {
30
33
 
31
34
  const vellumDir = join(homedir(), ".vellum");
32
35
  const pidFile = join(vellumDir, "vellum.pid");
36
+ const socketFile = join(vellumDir, "vellum.sock");
33
37
 
34
38
  // Check if daemon is already running
35
39
  let daemonRunning = false;
@@ -40,7 +44,16 @@ export async function wake(): Promise<void> {
40
44
  try {
41
45
  process.kill(pid, 0);
42
46
  daemonRunning = true;
43
- console.log(`Assistant already running (pid ${pid}).`);
47
+ if (watch) {
48
+ // Restart in watch mode
49
+ console.log(
50
+ `Assistant running (pid ${pid}) — restarting in watch mode...`,
51
+ );
52
+ await stopProcessByPidFile(pidFile, "assistant", [socketFile]);
53
+ daemonRunning = false;
54
+ } else {
55
+ console.log(`Assistant already running (pid ${pid}).`);
56
+ }
44
57
  } catch {
45
58
  // Process not alive, will start below
46
59
  }
@@ -48,7 +61,7 @@ export async function wake(): Promise<void> {
48
61
  }
49
62
 
50
63
  if (!daemonRunning) {
51
- await startLocalDaemon();
64
+ await startLocalDaemon(watch);
52
65
  }
53
66
 
54
67
  // Start gateway (non-desktop only)
@@ -56,14 +69,20 @@ export async function wake(): Promise<void> {
56
69
  const gatewayPidFile = join(vellumDir, "gateway.pid");
57
70
  const { alive, pid } = isProcessAlive(gatewayPidFile);
58
71
  if (alive) {
59
- console.log(`Gateway already running (pid ${pid}).`);
72
+ if (watch) {
73
+ // Restart in watch mode
74
+ console.log(
75
+ `Gateway running (pid ${pid}) — restarting in watch mode...`,
76
+ );
77
+ await stopProcessByPidFile(gatewayPidFile, "gateway");
78
+ await startGateway(undefined, watch);
79
+ } else {
80
+ console.log(`Gateway already running (pid ${pid}).`);
81
+ }
60
82
  } else {
61
- await startGateway();
83
+ await startGateway(undefined, watch);
62
84
  }
63
85
  }
64
86
 
65
- // Start outbound proxy
66
- await startOutboundProxy();
67
-
68
87
  console.log("✅ Wake complete.");
69
88
  }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import { pair } from "./commands/pair";
18
18
  import { ps } from "./commands/ps";
19
19
  import { recover } from "./commands/recover";
20
20
  import { retire } from "./commands/retire";
21
+ import { skills } from "./commands/skills";
21
22
  import { sleep } from "./commands/sleep";
22
23
  import { ssh } from "./commands/ssh";
23
24
  import { tunnel } from "./commands/tunnel";
@@ -36,6 +37,7 @@ const commands = {
36
37
  ps,
37
38
  recover,
38
39
  retire,
40
+ skills,
39
41
  sleep,
40
42
  ssh,
41
43
  tunnel,
@@ -97,6 +99,7 @@ async function main() {
97
99
  );
98
100
  console.log(" recover Restore a previously retired local assistant");
99
101
  console.log(" retire Delete an assistant instance");
102
+ console.log(" skills Browse and install skills from the Vellum catalog");
100
103
  console.log(" sleep Stop the assistant process");
101
104
  console.log(" ssh SSH into a remote assistant instance");
102
105
  console.log(" tunnel Create a tunnel for a locally hosted assistant");
package/src/lib/local.ts CHANGED
@@ -79,18 +79,6 @@ function findGatewaySourceFromCwd(): string | undefined {
79
79
  }
80
80
  }
81
81
 
82
- function isOutboundProxySourceDir(dir: string): boolean {
83
- const pkgPath = join(dir, "package.json");
84
- if (!existsSync(pkgPath) || !existsSync(join(dir, "src", "main.ts")))
85
- return false;
86
- try {
87
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
88
- return pkg.name === "@vellumai/outbound-proxy";
89
- } catch {
90
- return false;
91
- }
92
- }
93
-
94
82
  function resolveAssistantIndexPath(): string | undefined {
95
83
  // Source tree layout: cli/src/lib/ -> ../../.. -> repo root -> assistant/src/index.ts
96
84
  const sourceTreeIndex = join(
@@ -354,21 +342,6 @@ function resolveGatewayDir(): string {
354
342
  }
355
343
  }
356
344
 
357
- function resolveOutboundProxyDir(): string | undefined {
358
- // Compiled binary: outbound-proxy/ bundled adjacent to the CLI executable.
359
- const binProxy = join(dirname(process.execPath), "outbound-proxy");
360
- if (isOutboundProxySourceDir(binProxy)) {
361
- return binProxy;
362
- }
363
-
364
- try {
365
- const pkgPath = _require.resolve("@vellumai/outbound-proxy/package.json");
366
- return dirname(pkgPath);
367
- } catch {
368
- return undefined;
369
- }
370
- }
371
-
372
345
  function normalizeIngressUrl(value: unknown): string | undefined {
373
346
  if (typeof value !== "string") return undefined;
374
347
  const normalized = value.trim().replace(/\/+$/, "");
@@ -949,130 +922,10 @@ export async function startGateway(
949
922
  return gatewayUrl;
950
923
  }
951
924
 
952
- export async function startOutboundProxy(
953
- watch: boolean = false,
954
- ): Promise<void> {
955
- const proxyDir = resolveOutboundProxyDir();
956
- if (!proxyDir) {
957
- console.log(" ⚠️ Outbound proxy not found — skipping");
958
- return;
959
- }
960
-
961
- console.log("🔒 Starting outbound proxy...");
962
-
963
- const vellumDir = join(homedir(), ".vellum");
964
- mkdirSync(vellumDir, { recursive: true });
965
-
966
- const pidFile = join(vellumDir, "outbound-proxy.pid");
967
-
968
- // Check if already running
969
- if (existsSync(pidFile)) {
970
- try {
971
- const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
972
- if (!isNaN(pid)) {
973
- try {
974
- process.kill(pid, 0);
975
- console.log(` Outbound proxy already running (pid ${pid})\n`);
976
- return;
977
- } catch {
978
- try {
979
- unlinkSync(pidFile);
980
- } catch {}
981
- }
982
- }
983
- } catch {}
984
- }
985
-
986
- const proxyEnv: Record<string, string> = {
987
- ...(process.env as Record<string, string>),
988
- PROXY_PORT: process.env.PROXY_PORT || "7829",
989
- PROXY_HEALTH_PORT: process.env.PROXY_HEALTH_PORT || "7828",
990
- };
991
-
992
- const proxyLogFd = openLogFile("hatch.log");
993
-
994
- let proxy;
995
- if (process.env.VELLUM_DESKTOP_APP && !watch) {
996
- const proxyBinary = join(
997
- dirname(process.execPath),
998
- "vellum-outbound-proxy",
999
- );
1000
- if (!existsSync(proxyBinary)) {
1001
- console.log(
1002
- " ⚠️ Outbound proxy binary not found — falling back to source",
1003
- );
1004
- const bunArgs = watch
1005
- ? ["--watch", "run", "src/main.ts"]
1006
- : ["run", "src/main.ts"];
1007
- proxy = spawn("bun", bunArgs, {
1008
- cwd: proxyDir,
1009
- detached: true,
1010
- stdio: ["ignore", "pipe", "pipe"],
1011
- env: proxyEnv,
1012
- });
1013
- } else {
1014
- proxy = spawn(proxyBinary, [], {
1015
- detached: true,
1016
- stdio: ["ignore", "pipe", "pipe"],
1017
- env: proxyEnv,
1018
- });
1019
- }
1020
- } else {
1021
- const bunArgs = watch
1022
- ? ["--watch", "run", "src/main.ts"]
1023
- : ["run", "src/main.ts"];
1024
- proxy = spawn("bun", bunArgs, {
1025
- cwd: proxyDir,
1026
- detached: true,
1027
- stdio: ["ignore", "pipe", "pipe"],
1028
- env: proxyEnv,
1029
- });
1030
- }
1031
-
1032
- pipeToLogFile(proxy, proxyLogFd, "outbound-proxy");
1033
- proxy.unref();
1034
-
1035
- if (proxy.pid) {
1036
- writeFileSync(pidFile, String(proxy.pid), "utf-8");
1037
- }
1038
-
1039
- if (watch) {
1040
- console.log(" Outbound proxy started in watch mode (bun --watch)");
1041
- }
1042
-
1043
- // Wait for the health endpoint to respond
1044
- const healthPort = Number(process.env.PROXY_HEALTH_PORT) || 7828;
1045
- const start = Date.now();
1046
- const timeoutMs = 15000;
1047
- let ready = false;
1048
- while (Date.now() - start < timeoutMs) {
1049
- try {
1050
- const res = await fetch(`http://localhost:${healthPort}/healthz`, {
1051
- signal: AbortSignal.timeout(2000),
1052
- });
1053
- if (res.ok) {
1054
- ready = true;
1055
- break;
1056
- }
1057
- } catch {
1058
- // Not ready yet
1059
- }
1060
- await new Promise((r) => setTimeout(r, 250));
1061
- }
1062
-
1063
- if (!ready) {
1064
- console.warn(
1065
- " ⚠️ Outbound proxy started but health check did not respond within 15s",
1066
- );
1067
- }
1068
-
1069
- console.log("✅ Outbound proxy started\n");
1070
- }
1071
-
1072
925
  /**
1073
- * Stop any locally-running daemon, gateway, and outbound-proxy processes
1074
- * and clean up PID/socket files. Called when hatch fails partway through
1075
- * so we don't leave orphaned processes with no lock file entry.
926
+ * Stop any locally-running daemon and gateway processes and clean up
927
+ * PID/socket files. Called when hatch fails partway through so we don't
928
+ * leave orphaned processes with no lock file entry.
1076
929
  */
1077
930
  export async function stopLocalProcesses(): Promise<void> {
1078
931
  const vellumDir = join(homedir(), ".vellum");
@@ -1083,6 +936,4 @@ export async function stopLocalProcesses(): Promise<void> {
1083
936
  const gatewayPidFile = join(vellumDir, "gateway.pid");
1084
937
  await stopProcessByPidFile(gatewayPidFile, "gateway");
1085
938
 
1086
- const outboundProxyPidFile = join(vellumDir, "outbound-proxy.pid");
1087
- await stopProcessByPidFile(outboundProxyPidFile, "outbound-proxy");
1088
939
  }