@vellumai/cli 0.4.35 → 0.4.37

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/AGENTS.md ADDED
@@ -0,0 +1,47 @@
1
+ # CLI Package — Agent Instructions
2
+
3
+ ## Purpose
4
+
5
+ The `cli/` package (`@vellumai/cli`) manages the **lifecycle of Vellum assistant instances** — creating, starting, stopping, connecting to, and deleting them. Commands here operate on or across instances and typically require specifying which assistant to target.
6
+
7
+ This contrasts with `assistant/src/cli/`, where commands are scoped to a **single running assistant** and operate on its local state (config, memory, contacts, etc.).
8
+
9
+ ## When a command belongs here vs `assistant/src/cli/`
10
+
11
+ | `cli/` (this package) | `assistant/src/cli/` |
12
+ | ----------------------------------------------- | --------------------------------------------------- |
13
+ | Operates on or across assistant instances | Operates within a single assistant's workspace |
14
+ | Manages lifecycle (create, start, stop, delete) | Manages instance-local state (config, memory, etc.) |
15
+ | Requires specifying which assistant to target | Implicitly scoped to the running assistant |
16
+ | Works without an assistant process running | May require or start the daemon |
17
+
18
+ Examples: `hatch`, `wake`, `sleep`, `retire`, `ps`, `ssh` belong here. `config`, `contacts`, `memory` belong in `assistant/src/cli/`.
19
+
20
+ ## Assistant targeting convention
21
+
22
+ Commands that act on a specific assistant should accept an assistant name or ID as an argument. When none is specified, default to the most recently created local assistant. Use `loadAllAssistants()` and `findAssistantByName()` from `lib/assistant-config` for resolution.
23
+
24
+ ## Conventions
25
+
26
+ - Commands are standalone exported functions in `src/commands/`.
27
+ - Each command manually parses `process.argv.slice(3)` (no framework — keep it lightweight).
28
+ - Register new commands in the `commands` object in `src/index.ts` and add a help line.
29
+ - User-facing output uses `console.log`/`console.error` directly (no shared logger).
30
+
31
+ ## Help Text Standards
32
+
33
+ Every command must have high-quality `--help` output optimized for AI/LLM
34
+ consumption. Help text is a primary interface — both humans and AI agents read
35
+ it to understand what a command does and how to use it.
36
+
37
+ ### Requirements
38
+
39
+ 1. **Each command**: Include a concise one-liner description in the help output,
40
+ followed by an explanation of arguments/options with their formats and
41
+ constraints.
42
+
43
+ 2. **Include examples**: Show 2-3 concrete invocations with realistic values.
44
+
45
+ 3. **Write for machines**: Be precise about formats, constraints, and side effects.
46
+ AI agents parse help text to decide which command to run and how. Avoid vague
47
+ language — say exactly what the command does and where state is stored.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,7 +11,7 @@
11
11
  "./src/commands/*": "./src/commands/*.ts"
12
12
  },
13
13
  "bin": {
14
- "vellum": "./src/index.ts"
14
+ "assistant": "./src/index.ts"
15
15
  },
16
16
  "scripts": {
17
17
  "format": "prettier --write .",
@@ -0,0 +1,201 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { skills } from "../commands/skills.js";
13
+
14
+ let tempDir: string;
15
+ let originalArgv: string[];
16
+ let originalBaseDataDir: string | undefined;
17
+ let originalExitCode: number | string | null | undefined;
18
+
19
+ function getSkillsDir(): string {
20
+ return join(tempDir, ".vellum", "workspace", "skills");
21
+ }
22
+
23
+ function getSkillsIndexPath(): string {
24
+ return join(getSkillsDir(), "SKILLS.md");
25
+ }
26
+
27
+ function installFakeSkill(skillId: string): void {
28
+ const skillDir = join(getSkillsDir(), skillId);
29
+ mkdirSync(skillDir, { recursive: true });
30
+ writeFileSync(join(skillDir, "SKILL.md"), `# ${skillId}\nA test skill.\n`);
31
+ }
32
+
33
+ function writeSkillsIndex(content: string): void {
34
+ mkdirSync(getSkillsDir(), { recursive: true });
35
+ writeFileSync(getSkillsIndexPath(), content);
36
+ }
37
+
38
+ beforeEach(() => {
39
+ tempDir = join(tmpdir(), `skills-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
40
+ mkdirSync(join(tempDir, ".vellum", "workspace", "skills"), {
41
+ recursive: true,
42
+ });
43
+ originalArgv = process.argv;
44
+ originalBaseDataDir = process.env.BASE_DATA_DIR;
45
+ originalExitCode = process.exitCode;
46
+ process.env.BASE_DATA_DIR = tempDir;
47
+ });
48
+
49
+ afterEach(() => {
50
+ process.argv = originalArgv;
51
+ process.env.BASE_DATA_DIR = originalBaseDataDir;
52
+ process.exitCode = originalExitCode;
53
+ rmSync(tempDir, { recursive: true, force: true });
54
+ });
55
+
56
+ describe("vellum skills uninstall", () => {
57
+ test("removes skill directory and SKILLS.md entry", async () => {
58
+ /**
59
+ * Tests the happy path for uninstalling a skill.
60
+ */
61
+
62
+ // GIVEN a skill is installed locally
63
+ installFakeSkill("weather");
64
+ writeSkillsIndex("- weather\n- google-oauth-setup\n");
65
+
66
+ // WHEN we run `vellum skills uninstall weather`
67
+ process.argv = ["bun", "run", "skills", "uninstall", "weather"];
68
+ await skills();
69
+
70
+ // THEN the skill directory should be removed
71
+ expect(existsSync(join(getSkillsDir(), "weather"))).toBe(false);
72
+
73
+ // AND the SKILLS.md entry should be removed
74
+ const index = readFileSync(getSkillsIndexPath(), "utf-8");
75
+ expect(index).not.toContain("weather");
76
+
77
+ // AND other skills should remain in the index
78
+ expect(index).toContain("google-oauth-setup");
79
+ });
80
+
81
+ test("outputs JSON on success when --json flag is passed", async () => {
82
+ /**
83
+ * Tests that --json flag produces machine-readable output.
84
+ */
85
+
86
+ // GIVEN a skill is installed locally
87
+ installFakeSkill("weather");
88
+ writeSkillsIndex("- weather\n");
89
+
90
+ // WHEN we run `vellum skills uninstall weather --json`
91
+ process.argv = ["bun", "run", "skills", "uninstall", "weather", "--json"];
92
+ const logs: string[] = [];
93
+ const origLog = console.log;
94
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
95
+ try {
96
+ await skills();
97
+ } finally {
98
+ console.log = origLog;
99
+ }
100
+
101
+ // THEN JSON output should indicate success
102
+ const output = JSON.parse(logs[0]);
103
+ expect(output).toEqual({ ok: true, skillId: "weather" });
104
+ });
105
+
106
+ test("errors when skill is not installed", async () => {
107
+ /**
108
+ * Tests that uninstalling a non-existent skill produces an error.
109
+ */
110
+
111
+ // GIVEN no skills are installed
112
+ // WHEN we run `vellum skills uninstall nonexistent`
113
+ process.argv = ["bun", "run", "skills", "uninstall", "nonexistent"];
114
+ const errors: string[] = [];
115
+ const origError = console.error;
116
+ console.error = (...args: unknown[]) => errors.push(args.join(" "));
117
+ try {
118
+ await skills();
119
+ } finally {
120
+ console.error = origError;
121
+ }
122
+
123
+ // THEN an error message should be displayed
124
+ expect(errors[0]).toContain('Skill "nonexistent" is not installed.');
125
+ expect(process.exitCode).toBe(1);
126
+ });
127
+
128
+ test("errors with JSON output when skill is not installed and --json is passed", async () => {
129
+ /**
130
+ * Tests that --json flag produces machine-readable error output.
131
+ */
132
+
133
+ // GIVEN no skills are installed
134
+ // WHEN we run `vellum skills uninstall nonexistent --json`
135
+ process.argv = [
136
+ "bun",
137
+ "run",
138
+ "skills",
139
+ "uninstall",
140
+ "nonexistent",
141
+ "--json",
142
+ ];
143
+ const logs: string[] = [];
144
+ const origLog = console.log;
145
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
146
+ try {
147
+ await skills();
148
+ } finally {
149
+ console.log = origLog;
150
+ }
151
+
152
+ // THEN JSON output should indicate failure
153
+ const output = JSON.parse(logs[0]);
154
+ expect(output.ok).toBe(false);
155
+ expect(output.error).toContain('Skill "nonexistent" is not installed.');
156
+ });
157
+
158
+ test("works when SKILLS.md does not exist", async () => {
159
+ /**
160
+ * Tests that uninstall works even if the SKILLS.md index file is missing.
161
+ */
162
+
163
+ // GIVEN a skill directory exists but no SKILLS.md
164
+ installFakeSkill("weather");
165
+
166
+ // WHEN we run `vellum skills uninstall weather`
167
+ process.argv = ["bun", "run", "skills", "uninstall", "weather"];
168
+ await skills();
169
+
170
+ // THEN the skill directory should be removed
171
+ expect(existsSync(join(getSkillsDir(), "weather"))).toBe(false);
172
+
173
+ // AND no SKILLS.md should have been created
174
+ expect(existsSync(getSkillsIndexPath())).toBe(false);
175
+ });
176
+
177
+ test("removes skill with nested files", async () => {
178
+ /**
179
+ * Tests that uninstall recursively removes skills with nested directories.
180
+ */
181
+
182
+ // GIVEN a skill with nested files is installed
183
+ const skillDir = join(getSkillsDir(), "weather");
184
+ mkdirSync(join(skillDir, "scripts", "lib"), { recursive: true });
185
+ writeFileSync(join(skillDir, "SKILL.md"), "# weather\n");
186
+ writeFileSync(join(skillDir, "scripts", "fetch.sh"), "#!/bin/bash\n");
187
+ writeFileSync(join(skillDir, "scripts", "lib", "utils.sh"), "# utils\n");
188
+ writeSkillsIndex("- weather\n");
189
+
190
+ // WHEN we run `vellum skills uninstall weather`
191
+ process.argv = ["bun", "run", "skills", "uninstall", "weather"];
192
+ await skills();
193
+
194
+ // THEN the entire skill directory tree should be removed
195
+ expect(existsSync(skillDir)).toBe(false);
196
+
197
+ // AND the SKILLS.md entry should be removed
198
+ const index = readFileSync(getSkillsIndexPath(), "utf-8");
199
+ expect(index).not.toContain("weather");
200
+ });
201
+ });
@@ -1,10 +1,12 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import {
4
+ cpSync,
4
5
  existsSync,
5
6
  mkdirSync,
6
7
  readFileSync,
7
8
  renameSync,
9
+ rmSync,
8
10
  writeFileSync,
9
11
  } from "node:fs";
10
12
  import { homedir } from "node:os";
@@ -27,6 +29,38 @@ function getSkillsIndexPath(): string {
27
29
  return join(getSkillsDir(), "SKILLS.md");
28
30
  }
29
31
 
32
+ /**
33
+ * Resolve the repo-level skills/ directory when running in dev mode.
34
+ * Returns the path if VELLUM_DEV is set and the directory exists, or undefined.
35
+ */
36
+ function getRepoSkillsDir(): string | undefined {
37
+ if (!process.env.VELLUM_DEV) return undefined;
38
+
39
+ // cli/src/commands/skills.ts -> ../../../skills/
40
+ const candidate = join(import.meta.dir, "..", "..", "..", "skills");
41
+ if (existsSync(join(candidate, "catalog.json"))) {
42
+ return candidate;
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * Read skills from the repo-local catalog.json.
49
+ */
50
+ function readLocalCatalog(repoSkillsDir: string): CatalogSkill[] {
51
+ try {
52
+ const raw = readFileSync(
53
+ join(repoSkillsDir, "catalog.json"),
54
+ "utf-8",
55
+ );
56
+ const manifest = JSON.parse(raw) as CatalogManifest;
57
+ if (!Array.isArray(manifest.skills)) return [];
58
+ return manifest.skills;
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
30
64
  // ---------------------------------------------------------------------------
31
65
  // Platform API client
32
66
  // ---------------------------------------------------------------------------
@@ -221,6 +255,33 @@ function upsertSkillsIndex(id: string): void {
221
255
  atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
222
256
  }
223
257
 
258
+ function removeSkillsIndexEntry(id: string): void {
259
+ const indexPath = getSkillsIndexPath();
260
+ if (!existsSync(indexPath)) return;
261
+
262
+ const lines = readFileSync(indexPath, "utf-8").split("\n");
263
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
264
+ const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
265
+ const filtered = lines.filter((line) => !pattern.test(line));
266
+
267
+ // If nothing changed, skip the write
268
+ if (filtered.length === lines.length) return;
269
+
270
+ const content = filtered.join("\n");
271
+ atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
272
+ }
273
+
274
+ function uninstallSkillLocally(skillId: string): void {
275
+ const skillDir = join(getSkillsDir(), skillId);
276
+
277
+ if (!existsSync(skillDir)) {
278
+ throw new Error(`Skill "${skillId}" is not installed.`);
279
+ }
280
+
281
+ rmSync(skillDir, { recursive: true, force: true });
282
+ removeSkillsIndexEntry(skillId);
283
+ }
284
+
224
285
  async function installSkillLocally(
225
286
  skillId: string,
226
287
  catalogEntry: CatalogSkill,
@@ -237,8 +298,17 @@ async function installSkillLocally(
237
298
 
238
299
  mkdirSync(skillDir, { recursive: true });
239
300
 
240
- // Extract all files from the archive into the skill directory
241
- await fetchAndExtractSkill(skillId, skillDir);
301
+ // In dev mode, install from the local repo skills directory if available
302
+ const repoSkillsDir = getRepoSkillsDir();
303
+ const repoSkillSource = repoSkillsDir
304
+ ? join(repoSkillsDir, skillId)
305
+ : undefined;
306
+
307
+ if (repoSkillSource && existsSync(join(repoSkillSource, "SKILL.md"))) {
308
+ cpSync(repoSkillSource, skillDir, { recursive: true });
309
+ } else {
310
+ await fetchAndExtractSkill(skillId, skillDir);
311
+ }
242
312
 
243
313
  // Write version metadata
244
314
  if (catalogEntry.version) {
@@ -288,6 +358,9 @@ function printUsage(): void {
288
358
  console.log(
289
359
  " install <skill-id> [--overwrite] Install a skill from the catalog",
290
360
  );
361
+ console.log(
362
+ " uninstall <skill-id> Uninstall a previously installed skill",
363
+ );
291
364
  console.log("");
292
365
  console.log("Options:");
293
366
  console.log(" --json Machine-readable JSON output");
@@ -312,6 +385,18 @@ export async function skills(): Promise<void> {
312
385
  try {
313
386
  const catalog = await fetchCatalog();
314
387
 
388
+ // In dev mode, merge in skills from the repo-local skills/ directory
389
+ const repoSkillsDir = getRepoSkillsDir();
390
+ if (repoSkillsDir) {
391
+ const localSkills = readLocalCatalog(repoSkillsDir);
392
+ const remoteIds = new Set(catalog.map((s) => s.id));
393
+ for (const local of localSkills) {
394
+ if (!remoteIds.has(local.id)) {
395
+ catalog.push(local);
396
+ }
397
+ }
398
+ }
399
+
315
400
  if (json) {
316
401
  console.log(JSON.stringify({ ok: true, skills: catalog }));
317
402
  return;
@@ -353,9 +438,20 @@ export async function skills(): Promise<void> {
353
438
  const overwrite = hasFlag(args, "--overwrite");
354
439
 
355
440
  try {
356
- // Verify skill exists in catalog
357
- const catalog = await fetchCatalog();
358
- const entry = catalog.find((s) => s.id === skillId);
441
+ // In dev mode, also check the repo-local skills/ directory
442
+ const repoSkillsDir = getRepoSkillsDir();
443
+ let localSkills: CatalogSkill[] = [];
444
+ if (repoSkillsDir) {
445
+ localSkills = readLocalCatalog(repoSkillsDir);
446
+ }
447
+
448
+ // Check local catalog first, then fall back to remote
449
+ let entry = localSkills.find((s) => s.id === skillId);
450
+ if (!entry) {
451
+ const catalog = await fetchCatalog();
452
+ entry = catalog.find((s) => s.id === skillId);
453
+ }
454
+
359
455
  if (!entry) {
360
456
  throw new Error(`Skill "${skillId}" not found in the Vellum catalog`);
361
457
  }
@@ -380,6 +476,35 @@ export async function skills(): Promise<void> {
380
476
  break;
381
477
  }
382
478
 
479
+ case "uninstall": {
480
+ const skillId = args.find(
481
+ (a) => !a.startsWith("--") && a !== "uninstall",
482
+ );
483
+ if (!skillId) {
484
+ console.error("Usage: vellum skills uninstall <skill-id>");
485
+ process.exit(1);
486
+ }
487
+
488
+ try {
489
+ uninstallSkillLocally(skillId);
490
+
491
+ if (json) {
492
+ console.log(JSON.stringify({ ok: true, skillId }));
493
+ } else {
494
+ console.log(`Uninstalled skill "${skillId}".`);
495
+ }
496
+ } catch (err) {
497
+ const msg = err instanceof Error ? err.message : String(err);
498
+ if (json) {
499
+ console.log(JSON.stringify({ ok: false, error: msg }));
500
+ } else {
501
+ console.error(`Error: ${msg}`);
502
+ }
503
+ process.exitCode = 1;
504
+ }
505
+ break;
506
+ }
507
+
383
508
  default: {
384
509
  console.error(`Unknown skills subcommand: ${subcommand}`);
385
510
  printUsage();
package/src/index.ts CHANGED
@@ -7,11 +7,7 @@ import { spawn } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  import cliPkg from "../package.json";
10
- import { autonomy } from "./commands/autonomy";
11
10
  import { client } from "./commands/client";
12
- import { config } from "./commands/config";
13
- import { contacts } from "./commands/contacts";
14
- import { email } from "./commands/email";
15
11
  import { hatch } from "./commands/hatch";
16
12
  import { login, logout, whoami } from "./commands/login";
17
13
  import { pair } from "./commands/pair";
@@ -25,11 +21,7 @@ import { tunnel } from "./commands/tunnel";
25
21
  import { wake } from "./commands/wake";
26
22
 
27
23
  const commands = {
28
- autonomy,
29
24
  client,
30
- config,
31
- contacts,
32
- email,
33
25
  hatch,
34
26
  login,
35
27
  logout,
@@ -88,8 +80,8 @@ async function main() {
88
80
  console.log(" autonomy View and configure autonomy tiers");
89
81
  console.log(" client Connect to a hatched assistant");
90
82
  console.log(" config Manage configuration");
91
- console.log(" contacts Manage the contact graph");
92
- console.log(" email Email operations (status, create inbox)");
83
+ console.log(" contacts Manage assistant contacts");
84
+ console.log(" email Email operations (provider-agnostic)");
93
85
  console.log(" hatch Create a new assistant instance");
94
86
  console.log(" login Log in to the Vellum platform");
95
87
  console.log(" logout Log out of the Vellum platform");
package/src/lib/local.ts CHANGED
@@ -342,6 +342,7 @@ async function startDaemonWatchFromSource(
342
342
  const env: Record<string, string | undefined> = {
343
343
  ...process.env,
344
344
  RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
345
+ VELLUM_DEV: "1",
345
346
  };
346
347
 
347
348
  const daemonLogFd = openLogFile("hatch.log");
@@ -701,6 +702,7 @@ export async function startLocalDaemon(watch: boolean = false): Promise<void> {
701
702
  "VELLUM_DAEMON_TCP_PORT",
702
703
  "VELLUM_DAEMON_TCP_HOST",
703
704
  "VELLUM_DAEMON_SOCKET",
705
+ "VELLUM_KEYCHAIN_BROKER_SOCKET",
704
706
  "VELLUM_DEBUG",
705
707
  "SENTRY_DSN",
706
708
  "TMPDIR",
@@ -839,6 +841,7 @@ export async function startGateway(
839
841
  // `vellum sleep` to SIGKILL the gateway when the CLI timeout is shorter
840
842
  // than the drain window. Respect an explicit env override.
841
843
  GATEWAY_SHUTDOWN_DRAIN_MS: process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "0",
844
+ ...(watch ? { VELLUM_DEV: "1" } : {}),
842
845
  };
843
846
 
844
847
  if (process.env.GATEWAY_UNMAPPED_POLICY) {
package/src/lib/ngrok.ts CHANGED
@@ -1,8 +1,31 @@
1
1
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
2
5
 
3
- import { loadRawConfig, saveRawConfig } from "./config";
4
6
  import { GATEWAY_PORT } from "./constants";
5
7
 
8
+ function getConfigPath(): string {
9
+ const root = join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
10
+ return join(root, "workspace", "config.json");
11
+ }
12
+
13
+ function loadRawConfig(): Record<string, unknown> {
14
+ const configPath = getConfigPath();
15
+ if (!existsSync(configPath)) return {};
16
+ return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
17
+ string,
18
+ unknown
19
+ >;
20
+ }
21
+
22
+ function saveRawConfig(config: Record<string, unknown>): void {
23
+ const configPath = getConfigPath();
24
+ const dir = dirname(configPath);
25
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
26
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
27
+ }
28
+
6
29
  const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
7
30
  const NGROK_POLL_INTERVAL_MS = 500;
8
31
  const NGROK_POLL_TIMEOUT_MS = 15_000;