@vellumai/cli 0.4.36 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.36",
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/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");
@@ -840,6 +841,7 @@ export async function startGateway(
840
841
  // `vellum sleep` to SIGKILL the gateway when the CLI timeout is shorter
841
842
  // than the drain window. Respect an explicit env override.
842
843
  GATEWAY_SHUTDOWN_DRAIN_MS: process.env.GATEWAY_SHUTDOWN_DRAIN_MS || "0",
844
+ ...(watch ? { VELLUM_DEV: "1" } : {}),
843
845
  };
844
846
 
845
847
  if (process.env.GATEWAY_UNMAPPED_POLICY) {