@vellumai/cli 0.4.29 → 0.4.30

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.30",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -136,7 +136,7 @@ function printUsage(): void {
136
136
  console.log("Usage: vellum contacts <subcommand> [options]");
137
137
  console.log("");
138
138
  console.log("Subcommands:");
139
- console.log(" list [--limit N] List all contacts");
139
+ console.log(" list [--limit N] [--role ROLE] List all contacts");
140
140
  console.log(" get <id> Get a contact by ID");
141
141
  console.log(" merge <keepId> <mergeId> Merge two contacts");
142
142
  console.log("");
@@ -161,7 +161,9 @@ export async function contacts(): Promise<void> {
161
161
  switch (subcommand) {
162
162
  case "list": {
163
163
  const limit = getFlagValue(args, "--limit") ?? "50";
164
- const data = (await apiGet(`contacts?limit=${limit}`)) as {
164
+ const role = getFlagValue(args, "--role");
165
+ const query = `contacts?limit=${limit}${role ? `&role=${encodeURIComponent(role)}` : ""}`;
166
+ const data = (await apiGet(query)) as {
165
167
  ok: boolean;
166
168
  contacts: Contact[];
167
169
  };
@@ -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");
@@ -66,12 +70,21 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
66
70
  await stopOrphanedDaemonProcesses();
67
71
  }
68
72
 
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.
73
+ // Move the data directory out of the way so the path is immediately available
74
+ // for the next hatch, then kick off the tar archive in the background.
71
75
  const archivePath = getArchivePath(name);
72
76
  const metadataPath = getMetadataPath(name);
73
77
  const stagingDir = `${archivePath}.staging`;
74
78
 
79
+ if (!existsSync(vellumDir)) {
80
+ console.log(` No data directory at ${vellumDir} — nothing to archive.`);
81
+ console.log("\u2705 Local instance retired.");
82
+ return;
83
+ }
84
+
85
+ // Ensure the retired archive directory exists before attempting the rename
86
+ mkdirSync(dirname(stagingDir), { recursive: true });
87
+
75
88
  try {
76
89
  renameSync(vellumDir, stagingDir);
77
90
  } 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
+ }
@@ -3,7 +3,7 @@ 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";
6
+ import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
7
7
  import {
8
8
  startLocalDaemon,
9
9
  startGateway,
@@ -13,12 +13,19 @@ import {
13
13
  export async function wake(): Promise<void> {
14
14
  const args = process.argv.slice(3);
15
15
  if (args.includes("--help") || args.includes("-h")) {
16
- console.log("Usage: vellum wake");
16
+ console.log("Usage: vellum wake [options]");
17
17
  console.log("");
18
18
  console.log("Start the assistant and gateway processes.");
19
+ console.log("");
20
+ console.log("Options:");
21
+ console.log(
22
+ " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
23
+ );
19
24
  process.exit(0);
20
25
  }
21
26
 
27
+ const watch = args.includes("--watch");
28
+
22
29
  const assistants = loadAllAssistants();
23
30
  const hasLocal = assistants.some((a) => a.cloud === "local");
24
31
  if (!hasLocal) {
@@ -30,6 +37,7 @@ export async function wake(): Promise<void> {
30
37
 
31
38
  const vellumDir = join(homedir(), ".vellum");
32
39
  const pidFile = join(vellumDir, "vellum.pid");
40
+ const socketFile = join(vellumDir, "vellum.sock");
33
41
 
34
42
  // Check if daemon is already running
35
43
  let daemonRunning = false;
@@ -40,7 +48,16 @@ export async function wake(): Promise<void> {
40
48
  try {
41
49
  process.kill(pid, 0);
42
50
  daemonRunning = true;
43
- console.log(`Assistant already running (pid ${pid}).`);
51
+ if (watch) {
52
+ // Restart in watch mode
53
+ console.log(
54
+ `Assistant running (pid ${pid}) — restarting in watch mode...`,
55
+ );
56
+ await stopProcessByPidFile(pidFile, "assistant", [socketFile]);
57
+ daemonRunning = false;
58
+ } else {
59
+ console.log(`Assistant already running (pid ${pid}).`);
60
+ }
44
61
  } catch {
45
62
  // Process not alive, will start below
46
63
  }
@@ -48,7 +65,7 @@ export async function wake(): Promise<void> {
48
65
  }
49
66
 
50
67
  if (!daemonRunning) {
51
- await startLocalDaemon();
68
+ await startLocalDaemon(watch);
52
69
  }
53
70
 
54
71
  // Start gateway (non-desktop only)
@@ -56,14 +73,32 @@ export async function wake(): Promise<void> {
56
73
  const gatewayPidFile = join(vellumDir, "gateway.pid");
57
74
  const { alive, pid } = isProcessAlive(gatewayPidFile);
58
75
  if (alive) {
59
- console.log(`Gateway already running (pid ${pid}).`);
76
+ if (watch) {
77
+ // Restart in watch mode
78
+ console.log(
79
+ `Gateway running (pid ${pid}) — restarting in watch mode...`,
80
+ );
81
+ await stopProcessByPidFile(gatewayPidFile, "gateway");
82
+ await startGateway(undefined, watch);
83
+ } else {
84
+ console.log(`Gateway already running (pid ${pid}).`);
85
+ }
60
86
  } else {
61
- await startGateway();
87
+ await startGateway(undefined, watch);
62
88
  }
63
89
  }
64
90
 
65
91
  // Start outbound proxy
66
- await startOutboundProxy();
92
+ const outboundProxyPidFile = join(vellumDir, "outbound-proxy.pid");
93
+ const outboundProxyStatus = isProcessAlive(outboundProxyPidFile);
94
+ if (outboundProxyStatus.alive && watch) {
95
+ // Restart in watch mode
96
+ console.log(
97
+ `Outbound proxy running (pid ${outboundProxyStatus.pid}) — restarting in watch mode...`,
98
+ );
99
+ await stopProcessByPidFile(outboundProxyPidFile, "outbound-proxy");
100
+ }
101
+ await startOutboundProxy(watch);
67
102
 
68
103
  console.log("✅ Wake complete.");
69
104
  }
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");