@vellumai/cli 0.3.11 → 0.3.12

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,355 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, dirname } from "node:path";
10
+ import { gunzipSync } from "node:zlib";
11
+ import { randomUUID } from "node:crypto";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Path helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function getRootDir(): string {
18
+ return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
19
+ }
20
+
21
+ function getSkillsDir(): string {
22
+ return join(getRootDir(), "workspace", "skills");
23
+ }
24
+
25
+ function getSkillsIndexPath(): string {
26
+ return join(getSkillsDir(), "SKILLS.md");
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Platform API client
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function getConfigPlatformUrl(): string | undefined {
34
+ try {
35
+ const configPath = join(getRootDir(), "workspace", "config.json");
36
+ if (!existsSync(configPath)) return undefined;
37
+ const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
38
+ string,
39
+ unknown
40
+ >;
41
+ const platform = raw.platform as Record<string, unknown> | undefined;
42
+ const baseUrl = platform?.baseUrl;
43
+ if (typeof baseUrl === "string" && baseUrl.trim()) return baseUrl.trim();
44
+ } catch {
45
+ // ignore
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function getPlatformUrl(): string {
51
+ return (
52
+ process.env.VELLUM_ASSISTANT_PLATFORM_URL ??
53
+ getConfigPlatformUrl() ??
54
+ "https://platform.vellum.ai"
55
+ );
56
+ }
57
+
58
+ function getPlatformToken(): string | null {
59
+ try {
60
+ return readFileSync(join(getRootDir(), "platform-token"), "utf-8").trim();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function buildHeaders(): Record<string, string> {
67
+ const headers: Record<string, string> = {};
68
+ const token = getPlatformToken();
69
+ if (token) {
70
+ headers["X-Session-Token"] = token;
71
+ }
72
+ return headers;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Types
77
+ // ---------------------------------------------------------------------------
78
+
79
+ interface CatalogSkill {
80
+ id: string;
81
+ name: string;
82
+ description: string;
83
+ emoji?: string;
84
+ includes?: string[];
85
+ version?: string;
86
+ }
87
+
88
+ interface CatalogManifest {
89
+ version: number;
90
+ skills: CatalogSkill[];
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Catalog operations
95
+ // ---------------------------------------------------------------------------
96
+
97
+ async function fetchCatalog(): Promise<CatalogSkill[]> {
98
+ const url = `${getPlatformUrl()}/v1/skills/`;
99
+ const response = await fetch(url, {
100
+ headers: buildHeaders(),
101
+ signal: AbortSignal.timeout(10000),
102
+ });
103
+
104
+ if (!response.ok) {
105
+ throw new Error(`Platform API error ${response.status}: ${response.statusText}`);
106
+ }
107
+
108
+ const manifest = (await response.json()) as CatalogManifest;
109
+ if (!Array.isArray(manifest.skills)) {
110
+ throw new Error("Platform catalog has invalid skills array");
111
+ }
112
+ return manifest.skills;
113
+ }
114
+
115
+ /**
116
+ * Extract SKILL.md content from a tar archive (uncompressed).
117
+ */
118
+ function extractSkillMdFromTar(tarBuffer: Buffer): string | null {
119
+ let offset = 0;
120
+ while (offset + 512 <= tarBuffer.length) {
121
+ const header = tarBuffer.subarray(offset, offset + 512);
122
+
123
+ // End-of-archive (two consecutive zero blocks)
124
+ if (header.every((b) => b === 0)) break;
125
+
126
+ // Filename (bytes 0-99, null-terminated)
127
+ const nameEnd = header.indexOf(0, 0);
128
+ const name = header
129
+ .subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100))
130
+ .toString("utf-8");
131
+
132
+ // File size (bytes 124-135, octal)
133
+ const sizeStr = header.subarray(124, 136).toString("utf-8").trim();
134
+ const size = parseInt(sizeStr, 8) || 0;
135
+
136
+ offset += 512; // past header
137
+
138
+ if (name.endsWith("SKILL.md") || name === "SKILL.md") {
139
+ return tarBuffer.subarray(offset, offset + size).toString("utf-8");
140
+ }
141
+
142
+ // Skip to next header (data padded to 512 bytes)
143
+ offset += Math.ceil(size / 512) * 512;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ async function fetchSkillContent(skillId: string): Promise<string> {
149
+ const url = `${getPlatformUrl()}/v1/skills/${encodeURIComponent(skillId)}/`;
150
+ const response = await fetch(url, {
151
+ headers: buildHeaders(),
152
+ signal: AbortSignal.timeout(15000),
153
+ });
154
+
155
+ if (!response.ok) {
156
+ throw new Error(
157
+ `Failed to fetch skill "${skillId}": HTTP ${response.status}`,
158
+ );
159
+ }
160
+
161
+ const gzipBuffer = Buffer.from(await response.arrayBuffer());
162
+ const tarBuffer = gunzipSync(gzipBuffer);
163
+ const skillMd = extractSkillMdFromTar(tarBuffer);
164
+
165
+ if (!skillMd) {
166
+ throw new Error(`SKILL.md not found in archive for "${skillId}"`);
167
+ }
168
+
169
+ return skillMd;
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Managed skill installation
174
+ // ---------------------------------------------------------------------------
175
+
176
+ function atomicWriteFile(filePath: string, content: string): void {
177
+ const dir = dirname(filePath);
178
+ mkdirSync(dir, { recursive: true });
179
+ const tmpPath = join(dir, `.tmp-${randomUUID()}`);
180
+ writeFileSync(tmpPath, content, "utf-8");
181
+ renameSync(tmpPath, filePath);
182
+ }
183
+
184
+ function upsertSkillsIndex(id: string): void {
185
+ const indexPath = getSkillsIndexPath();
186
+ let lines: string[] = [];
187
+ if (existsSync(indexPath)) {
188
+ lines = readFileSync(indexPath, "utf-8").split("\n");
189
+ }
190
+
191
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
192
+ const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
193
+ if (lines.some((line) => pattern.test(line))) return;
194
+
195
+ const nonEmpty = lines.filter((l) => l.trim());
196
+ nonEmpty.push(`- ${id}`);
197
+ const content = nonEmpty.join("\n");
198
+ atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
199
+ }
200
+
201
+ function installSkillLocally(
202
+ skillId: string,
203
+ skillMdContent: string,
204
+ catalogEntry: CatalogSkill,
205
+ overwrite: boolean,
206
+ ): void {
207
+ const skillDir = join(getSkillsDir(), skillId);
208
+ const skillFilePath = join(skillDir, "SKILL.md");
209
+
210
+ if (existsSync(skillFilePath) && !overwrite) {
211
+ throw new Error(
212
+ `Skill "${skillId}" is already installed. Use --overwrite to replace it.`,
213
+ );
214
+ }
215
+
216
+ mkdirSync(skillDir, { recursive: true });
217
+ atomicWriteFile(skillFilePath, skillMdContent);
218
+
219
+ // Write version metadata
220
+ if (catalogEntry.version) {
221
+ const meta = {
222
+ version: catalogEntry.version,
223
+ installedAt: new Date().toISOString(),
224
+ };
225
+ atomicWriteFile(
226
+ join(skillDir, "version.json"),
227
+ JSON.stringify(meta, null, 2) + "\n",
228
+ );
229
+ }
230
+
231
+ upsertSkillsIndex(skillId);
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Helpers
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function hasFlag(args: string[], flag: string): boolean {
239
+ return args.includes(flag);
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Usage
244
+ // ---------------------------------------------------------------------------
245
+
246
+ function printUsage(): void {
247
+ console.log("Usage: vellum skills <subcommand> [options]");
248
+ console.log("");
249
+ console.log("Subcommands:");
250
+ console.log(" list List available catalog skills");
251
+ console.log(
252
+ " install <skill-id> [--overwrite] Install a skill from the catalog",
253
+ );
254
+ console.log("");
255
+ console.log("Options:");
256
+ console.log(" --json Machine-readable JSON output");
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Command entry point
261
+ // ---------------------------------------------------------------------------
262
+
263
+ export async function skills(): Promise<void> {
264
+ const args = process.argv.slice(3);
265
+ const subcommand = args[0];
266
+ const json = hasFlag(args, "--json");
267
+
268
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
269
+ printUsage();
270
+ return;
271
+ }
272
+
273
+ switch (subcommand) {
274
+ case "list": {
275
+ try {
276
+ const catalog = await fetchCatalog();
277
+
278
+ if (json) {
279
+ console.log(JSON.stringify({ ok: true, skills: catalog }));
280
+ return;
281
+ }
282
+
283
+ if (catalog.length === 0) {
284
+ console.log("No skills available in the catalog.");
285
+ return;
286
+ }
287
+
288
+ console.log(`Available skills (${catalog.length}):\n`);
289
+ for (const s of catalog) {
290
+ const emoji = s.emoji ? `${s.emoji} ` : "";
291
+ const deps = s.includes?.length
292
+ ? ` (requires: ${s.includes.join(", ")})`
293
+ : "";
294
+ console.log(` ${emoji}${s.id}`);
295
+ console.log(` ${s.name} — ${s.description}${deps}`);
296
+ }
297
+ } catch (err) {
298
+ const msg = err instanceof Error ? err.message : String(err);
299
+ if (json) {
300
+ console.log(JSON.stringify({ ok: false, error: msg }));
301
+ } else {
302
+ console.error(`Error: ${msg}`);
303
+ }
304
+ process.exitCode = 1;
305
+ }
306
+ break;
307
+ }
308
+
309
+ case "install": {
310
+ const skillId = args.find((a) => !a.startsWith("--") && a !== "install");
311
+ if (!skillId) {
312
+ console.error("Usage: vellum skills install <skill-id>");
313
+ process.exit(1);
314
+ }
315
+
316
+ const overwrite = hasFlag(args, "--overwrite");
317
+
318
+ try {
319
+ // Verify skill exists in catalog
320
+ const catalog = await fetchCatalog();
321
+ const entry = catalog.find((s) => s.id === skillId);
322
+ if (!entry) {
323
+ throw new Error(`Skill "${skillId}" not found in the Vellum catalog`);
324
+ }
325
+
326
+ // Fetch SKILL.md from platform
327
+ const content = await fetchSkillContent(skillId);
328
+
329
+ // Install locally
330
+ installSkillLocally(skillId, content, entry, overwrite);
331
+
332
+ if (json) {
333
+ console.log(JSON.stringify({ ok: true, skillId }));
334
+ } else {
335
+ console.log(`Installed skill "${skillId}".`);
336
+ }
337
+ } catch (err) {
338
+ const msg = err instanceof Error ? err.message : String(err);
339
+ if (json) {
340
+ console.log(JSON.stringify({ ok: false, error: msg }));
341
+ } else {
342
+ console.error(`Error: ${msg}`);
343
+ }
344
+ process.exitCode = 1;
345
+ }
346
+ break;
347
+ }
348
+
349
+ default: {
350
+ console.error(`Unknown skills subcommand: ${subcommand}`);
351
+ printUsage();
352
+ process.exit(1);
353
+ }
354
+ }
355
+ }
@@ -40,7 +40,7 @@ export async function ssh(): Promise<void> {
40
40
  if (name) {
41
41
  console.error(`No assistant instance found with name '${name}'.`);
42
42
  } else {
43
- console.error("No assistant instance found. Run `vellum-cli hatch` first.");
43
+ console.error("No assistant instance found. Run `vellum hatch` first.");
44
44
  }
45
45
  process.exit(1);
46
46
  }
@@ -50,7 +50,7 @@ export async function ssh(): Promise<void> {
50
50
  if (cloud === "local") {
51
51
  console.error(
52
52
  "Cannot SSH into a local assistant. Local assistants run directly on this machine.\n" +
53
- `Use 'vellum-cli ps ${entry.assistantId}' to check its processes instead.`,
53
+ `Use 'vellum ps ${entry.assistantId}' to check its processes instead.`,
54
54
  );
55
55
  process.exit(1);
56
56
  }
@@ -81,6 +81,9 @@ export async function ssh(): Promise<void> {
81
81
  ["compute", "ssh", sshTarget, `--project=${project}`, `--zone=${zone}`],
82
82
  { stdio: "inherit" },
83
83
  );
84
+ } else if (cloud === "vellum") {
85
+ console.error("SSH to Vellum-managed instances is not yet supported.");
86
+ process.exit(1);
84
87
  } else if (cloud === "custom") {
85
88
  const host = extractHostFromUrl(entry.runtimeUrl);
86
89
  const sshUser = entry.sshUser ?? "root";
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "child_process";
2
- import { randomUUID } from "crypto";
2
+ import { randomBytes, randomUUID } from "crypto";
3
3
  import { basename } from "path";
4
+ import qrcode from "qrcode-terminal";
4
5
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
5
6
  import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
6
7
 
@@ -23,7 +24,7 @@ export const ANSI = {
23
24
  gray: "\x1b[90m",
24
25
  } as const;
25
26
 
26
- export const SLASH_COMMANDS = ["/clear", "/doctor", "/exit", "/help", "/q", "/quit", "/retire"];
27
+ export const SLASH_COMMANDS = ["/clear", "/doctor", "/exit", "/help", "/pair", "/q", "/quit", "/retire"];
27
28
 
28
29
  const POLL_INTERVAL_MS = 3000;
29
30
  const SEND_TIMEOUT_MS = 5000;
@@ -157,7 +158,7 @@ async function sendMessage(
157
158
  "/messages",
158
159
  {
159
160
  method: "POST",
160
- body: JSON.stringify({ conversationKey: assistantId, content }),
161
+ body: JSON.stringify({ conversationKey: assistantId, content, sourceChannel: "vellum", interface: "cli" }),
161
162
  signal,
162
163
  },
163
164
  bearerToken,
@@ -529,6 +530,10 @@ function HelpDisplay(): ReactElement {
529
530
  {" /clear "}
530
531
  <Text dimColor>Clear the screen</Text>
531
532
  </Text>
533
+ <Text>
534
+ {" /pair "}
535
+ <Text dimColor>Generate a QR code for mobile device pairing</Text>
536
+ </Text>
532
537
  <Text>
533
538
  {" /help, ? "}
534
539
  <Text dimColor>Show this help</Text>
@@ -1280,6 +1285,65 @@ function ChatApp({
1280
1285
  return;
1281
1286
  }
1282
1287
 
1288
+ if (trimmed === "/pair") {
1289
+ h.showSpinner("Generating pairing credentials...");
1290
+
1291
+ const isConnected = await ensureConnected();
1292
+ if (!isConnected) {
1293
+ h.hideSpinner();
1294
+ h.showError("Cannot pair — not connected to the assistant runtime.");
1295
+ return;
1296
+ }
1297
+
1298
+ try {
1299
+ const pairingRequestId = randomUUID();
1300
+ const pairingSecret = randomBytes(32).toString("hex");
1301
+ const gatewayUrl = runtimeUrl;
1302
+
1303
+ // Call /v1/pairing/register directly (not under /v1/assistants/:id/)
1304
+ const registerUrl = `${runtimeUrl}/v1/pairing/register`;
1305
+ const registerRes = await fetch(registerUrl, {
1306
+ method: "POST",
1307
+ headers: {
1308
+ "Content-Type": "application/json",
1309
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
1310
+ },
1311
+ body: JSON.stringify({ pairingRequestId, pairingSecret, gatewayUrl }),
1312
+ });
1313
+
1314
+ if (!registerRes.ok) {
1315
+ const body = await registerRes.text().catch(() => "");
1316
+ throw new Error(`HTTP ${registerRes.status}: ${body || registerRes.statusText}`);
1317
+ }
1318
+
1319
+ const payload = JSON.stringify({
1320
+ type: "vellum-daemon",
1321
+ v: 4,
1322
+ g: gatewayUrl,
1323
+ pairingRequestId,
1324
+ pairingSecret,
1325
+ });
1326
+
1327
+ const qrString = await new Promise<string>((resolve) => {
1328
+ qrcode.generate(payload, { small: true }, (code: string) => {
1329
+ resolve(code);
1330
+ });
1331
+ });
1332
+
1333
+ h.hideSpinner();
1334
+ h.addStatus(
1335
+ `Pairing Ready\n\n` +
1336
+ `Scan this QR code with the Vellum iOS app:\n\n` +
1337
+ `${qrString}\n` +
1338
+ `This pairing request expires in 5 minutes. Run /pair again to generate a new one.`,
1339
+ );
1340
+ } catch (err) {
1341
+ h.hideSpinner();
1342
+ h.showError(`Pairing failed: ${err instanceof Error ? err.message : err}`);
1343
+ }
1344
+ return;
1345
+ }
1346
+
1283
1347
  if (trimmed === "/retire") {
1284
1348
  if (!project || !zone) {
1285
1349
  h.showError("No instance info available. Connect to a hatched instance first.");
package/src/index.ts CHANGED
@@ -5,28 +5,40 @@ import { createRequire } from "node:module";
5
5
  import { dirname, join } from "node:path";
6
6
  import { spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
+
9
+ import cliPkg from "../package.json";
10
+ import { autonomy } from "./commands/autonomy";
8
11
  import { client } from "./commands/client";
9
12
  import { config } from "./commands/config";
13
+ import { contacts } from "./commands/contacts";
10
14
  import { email } from "./commands/email";
11
15
  import { hatch } from "./commands/hatch";
16
+ import { login, logout, whoami } from "./commands/login";
12
17
  import { ps } from "./commands/ps";
13
18
  import { recover } from "./commands/recover";
14
19
  import { retire } from "./commands/retire";
20
+ import { skills } from "./commands/skills";
15
21
  import { sleep } from "./commands/sleep";
16
22
  import { ssh } from "./commands/ssh";
17
23
  import { wake } from "./commands/wake";
18
24
 
19
25
  const commands = {
26
+ autonomy,
20
27
  client,
21
28
  config,
29
+ contacts,
22
30
  email,
23
31
  hatch,
32
+ login,
33
+ logout,
24
34
  ps,
25
35
  recover,
26
36
  retire,
37
+ skills,
27
38
  sleep,
28
39
  ssh,
29
40
  wake,
41
+ whoami,
30
42
  } as const;
31
43
 
32
44
  type CommandName = keyof typeof commands;
@@ -61,20 +73,31 @@ async function main() {
61
73
  const args = process.argv.slice(2);
62
74
  const commandName = args[0];
63
75
 
76
+ if (commandName === "--version" || commandName === "-v") {
77
+ console.log(`@vellumai/cli v${cliPkg.version}`);
78
+ process.exit(0);
79
+ }
80
+
64
81
  if (!commandName || commandName === "--help" || commandName === "-h") {
65
82
  console.log("Usage: vellum <command> [options]");
66
83
  console.log("");
67
84
  console.log("Commands:");
85
+ console.log(" autonomy View and configure autonomy tiers");
68
86
  console.log(" client Connect to a hatched assistant");
69
87
  console.log(" config Manage configuration");
88
+ console.log(" contacts Manage the contact graph");
70
89
  console.log(" email Email operations (status, create inbox)");
71
90
  console.log(" hatch Create a new assistant instance");
91
+ console.log(" login Log in to the Vellum platform");
92
+ console.log(" logout Log out of the Vellum platform");
72
93
  console.log(" ps List assistants (or processes for a specific assistant)");
73
94
  console.log(" recover Restore a previously retired local assistant");
74
95
  console.log(" retire Delete an assistant instance");
96
+ console.log(" skills Browse and install skills from the Vellum catalog");
75
97
  console.log(" sleep Stop the daemon process");
76
98
  console.log(" ssh SSH into a remote assistant instance");
77
99
  console.log(" wake Start the daemon and gateway");
100
+ console.log(" whoami Show current logged-in user");
78
101
  process.exit(0);
79
102
  }
80
103
 
@@ -9,6 +9,7 @@ export interface AssistantEntry {
9
9
  bearerToken?: string;
10
10
  cloud: string;
11
11
  instanceId?: string;
12
+ namespace?: string;
12
13
  project?: string;
13
14
  region?: string;
14
15
  species?: string;
package/src/lib/gcp.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "crypto";
2
- import { existsSync, unlinkSync, writeFileSync } from "fs";
2
+ import { unlinkSync, writeFileSync } from "fs";
3
3
  import { tmpdir, userInfo } from "os";
4
4
  import { join } from "path";
5
5
 
@@ -370,13 +370,12 @@ const DESIRED_FIREWALL_RULES: FirewallRuleSpec[] = [
370
370
  },
371
371
  ];
372
372
 
373
- async function resolveInstallScriptPath(): Promise<string | null> {
374
- const sourcePath = join(import.meta.dir, "..", "adapters", "install.sh");
375
- if (existsSync(sourcePath)) {
376
- return sourcePath;
377
- }
378
- console.warn("\u26a0\ufe0f Install script not found at", sourcePath, "(expected in compiled binary)");
379
- return null;
373
+ import INSTALL_SCRIPT_CONTENT from "../adapters/install.sh" with { type: "text" };
374
+
375
+ function resolveInstallScriptPath(): string {
376
+ const tmpPath = join(tmpdir(), `vellum-install-${process.pid}.sh`);
377
+ writeFileSync(tmpPath, INSTALL_SCRIPT_CONTENT, { mode: 0o755 });
378
+ return tmpPath;
380
379
  }
381
380
 
382
381
  async function pollInstance(
@@ -459,11 +458,7 @@ async function recoverFromCurlFailure(
459
458
  sshUser: string,
460
459
  account?: string,
461
460
  ): Promise<void> {
462
- const installScriptPath = await resolveInstallScriptPath();
463
- if (!installScriptPath) {
464
- console.warn("\u26a0\ufe0f Skipping install script upload (not available in compiled binary)");
465
- return;
466
- }
461
+ const installScriptPath = resolveInstallScriptPath();
467
462
 
468
463
  const scpArgs = [
469
464
  "compute",
@@ -488,6 +483,7 @@ async function recoverFromCurlFailure(
488
483
  if (account) sshArgs.push(`--account=${account}`);
489
484
  console.log("\ud83d\udd27 Running install script on instance...");
490
485
  await exec("gcloud", sshArgs);
486
+ try { unlinkSync(installScriptPath); } catch {}
491
487
  }
492
488
 
493
489
  export async function hatchGcp(