@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 +2 -2
- package/src/__tests__/skills-uninstall.test.ts +201 -0
- package/src/commands/skills.ts +130 -5
- package/src/lib/local.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/cli",
|
|
3
|
-
"version": "0.4.
|
|
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
|
-
"
|
|
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
|
+
});
|
package/src/commands/skills.ts
CHANGED
|
@@ -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
|
-
//
|
|
241
|
-
|
|
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
|
-
//
|
|
357
|
-
const
|
|
358
|
-
|
|
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) {
|