@vellumai/cli 0.4.36 → 0.4.40
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__/multi-local.test.ts +275 -0
- package/src/__tests__/skills-uninstall.test.ts +203 -0
- package/src/commands/client.ts +23 -7
- package/src/commands/hatch.ts +38 -42
- package/src/commands/ps.ts +32 -12
- package/src/commands/retire.ts +48 -12
- package/src/commands/skills.ts +130 -5
- package/src/commands/sleep.ts +25 -6
- package/src/commands/use.ts +44 -0
- package/src/commands/wake.ts +25 -16
- package/src/index.ts +5 -49
- package/src/lib/assistant-config.ts +226 -3
- package/src/lib/constants.ts +6 -0
- package/src/lib/local.ts +189 -49
- package/src/lib/status-emoji.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.40",
|
|
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,275 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Create a temp directory that acts as a fake home, so allocateLocalResources()
|
|
7
|
+
// and defaultLocalResources() never touch the real ~/.vellum directory.
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
|
|
9
|
+
process.env.BASE_DATA_DIR = testDir;
|
|
10
|
+
|
|
11
|
+
// Mock homedir() to return testDir — this isolates allocateLocalResources()
|
|
12
|
+
// which uses homedir() directly for instance directory creation.
|
|
13
|
+
const realOs = await import("node:os");
|
|
14
|
+
mock.module("node:os", () => ({
|
|
15
|
+
...realOs,
|
|
16
|
+
homedir: () => testDir,
|
|
17
|
+
}));
|
|
18
|
+
// Also mock the bare "os" specifier since assistant-config.ts uses `from "os"`
|
|
19
|
+
mock.module("os", () => ({
|
|
20
|
+
...realOs,
|
|
21
|
+
homedir: () => testDir,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Mock probePort so we control which ports appear in-use without touching the network
|
|
25
|
+
const probePortMock = mock<(port: number, host?: string) => Promise<boolean>>(
|
|
26
|
+
() => Promise.resolve(false),
|
|
27
|
+
);
|
|
28
|
+
mock.module("../lib/port-probe.js", () => ({
|
|
29
|
+
probePort: probePortMock,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
allocateLocalResources,
|
|
34
|
+
defaultLocalResources,
|
|
35
|
+
resolveTargetAssistant,
|
|
36
|
+
setActiveAssistant,
|
|
37
|
+
getActiveAssistant,
|
|
38
|
+
removeAssistantEntry,
|
|
39
|
+
saveAssistantEntry,
|
|
40
|
+
type AssistantEntry,
|
|
41
|
+
} from "../lib/assistant-config.js";
|
|
42
|
+
import { DEFAULT_DAEMON_PORT } from "../lib/constants.js";
|
|
43
|
+
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
46
|
+
delete process.env.BASE_DATA_DIR;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function writeLockfile(data: unknown): void {
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(testDir, ".vellum.lock.json"),
|
|
52
|
+
JSON.stringify(data, null, 2),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readLockfileRaw(): Record<string, unknown> {
|
|
57
|
+
return JSON.parse(
|
|
58
|
+
readFileSync(join(testDir, ".vellum.lock.json"), "utf-8"),
|
|
59
|
+
) as Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const makeEntry = (
|
|
63
|
+
id: string,
|
|
64
|
+
cloud = "local",
|
|
65
|
+
extra?: Partial<AssistantEntry>,
|
|
66
|
+
): AssistantEntry => ({
|
|
67
|
+
assistantId: id,
|
|
68
|
+
runtimeUrl: `http://localhost:${DEFAULT_DAEMON_PORT}`,
|
|
69
|
+
cloud,
|
|
70
|
+
...extra,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function resetLockfile(): void {
|
|
74
|
+
try {
|
|
75
|
+
rmSync(join(testDir, ".vellum.lock.json"));
|
|
76
|
+
} catch {
|
|
77
|
+
// file may not exist
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
rmSync(join(testDir, ".vellum.lockfile.json"));
|
|
81
|
+
} catch {
|
|
82
|
+
// file may not exist
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("multi-local", () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
resetLockfile();
|
|
89
|
+
probePortMock.mockReset();
|
|
90
|
+
probePortMock.mockImplementation(() => Promise.resolve(false));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("allocateLocalResources() produces non-conflicting ports", () => {
|
|
94
|
+
test("two instances get distinct ports and dirs when first instance ports are occupied", async () => {
|
|
95
|
+
// After the first allocation grabs its ports, simulate those ports
|
|
96
|
+
// being in-use so the second allocation must pick different ones.
|
|
97
|
+
const a = await allocateLocalResources("instance-a");
|
|
98
|
+
const occupiedPorts = new Set([
|
|
99
|
+
a.daemonPort,
|
|
100
|
+
a.gatewayPort,
|
|
101
|
+
a.qdrantPort,
|
|
102
|
+
]);
|
|
103
|
+
probePortMock.mockImplementation((port: number) =>
|
|
104
|
+
Promise.resolve(occupiedPorts.has(port)),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const b = await allocateLocalResources("instance-b");
|
|
108
|
+
|
|
109
|
+
// All six ports must be unique across both instances
|
|
110
|
+
const allPorts = [
|
|
111
|
+
a.daemonPort,
|
|
112
|
+
a.gatewayPort,
|
|
113
|
+
a.qdrantPort,
|
|
114
|
+
b.daemonPort,
|
|
115
|
+
b.gatewayPort,
|
|
116
|
+
b.qdrantPort,
|
|
117
|
+
];
|
|
118
|
+
expect(new Set(allPorts).size).toBe(6);
|
|
119
|
+
|
|
120
|
+
// Instance dirs must be distinct
|
|
121
|
+
expect(a.instanceDir).not.toBe(b.instanceDir);
|
|
122
|
+
expect(a.instanceDir).toContain("instance-a");
|
|
123
|
+
expect(b.instanceDir).toContain("instance-b");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("skips ports that probePort reports as in-use", async () => {
|
|
127
|
+
// Simulate the default ports being occupied
|
|
128
|
+
const portsInUse = new Set([
|
|
129
|
+
DEFAULT_DAEMON_PORT,
|
|
130
|
+
DEFAULT_DAEMON_PORT + 1,
|
|
131
|
+
]);
|
|
132
|
+
probePortMock.mockImplementation((port: number) =>
|
|
133
|
+
Promise.resolve(portsInUse.has(port)),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const res = await allocateLocalResources("probe-test");
|
|
137
|
+
expect(res.daemonPort).toBeGreaterThan(DEFAULT_DAEMON_PORT + 1);
|
|
138
|
+
expect(portsInUse.has(res.daemonPort)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("defaultLocalResources() returns legacy paths", () => {
|
|
143
|
+
test("instanceDir is homedir", () => {
|
|
144
|
+
const res = defaultLocalResources();
|
|
145
|
+
expect(res.instanceDir).toBe(testDir);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("daemonPort is DEFAULT_DAEMON_PORT", () => {
|
|
149
|
+
const res = defaultLocalResources();
|
|
150
|
+
expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("resolveTargetAssistant() priority chain", () => {
|
|
155
|
+
test("explicit name returns that entry", () => {
|
|
156
|
+
writeLockfile({
|
|
157
|
+
assistants: [makeEntry("alpha"), makeEntry("beta")],
|
|
158
|
+
});
|
|
159
|
+
const result = resolveTargetAssistant("beta");
|
|
160
|
+
expect(result.assistantId).toBe("beta");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("active assistant set returns the active entry", () => {
|
|
164
|
+
writeLockfile({
|
|
165
|
+
assistants: [makeEntry("alpha"), makeEntry("beta")],
|
|
166
|
+
activeAssistant: "alpha",
|
|
167
|
+
});
|
|
168
|
+
const result = resolveTargetAssistant();
|
|
169
|
+
expect(result.assistantId).toBe("alpha");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("sole local assistant returns it", () => {
|
|
173
|
+
writeLockfile({
|
|
174
|
+
assistants: [makeEntry("only-one")],
|
|
175
|
+
});
|
|
176
|
+
const result = resolveTargetAssistant();
|
|
177
|
+
expect(result.assistantId).toBe("only-one");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("multiple local assistants and no active throws with guidance", () => {
|
|
181
|
+
writeLockfile({
|
|
182
|
+
assistants: [makeEntry("x"), makeEntry("y")],
|
|
183
|
+
});
|
|
184
|
+
// resolveTargetAssistant calls process.exit(1) on ambiguity
|
|
185
|
+
const mockExit = mock(() => {
|
|
186
|
+
throw new Error("process.exit called");
|
|
187
|
+
});
|
|
188
|
+
const origExit = process.exit;
|
|
189
|
+
process.exit = mockExit as unknown as typeof process.exit;
|
|
190
|
+
try {
|
|
191
|
+
expect(() => resolveTargetAssistant()).toThrow("process.exit called");
|
|
192
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
193
|
+
} finally {
|
|
194
|
+
process.exit = origExit;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("no local assistants throws", () => {
|
|
199
|
+
writeLockfile({ assistants: [] });
|
|
200
|
+
const mockExit = mock(() => {
|
|
201
|
+
throw new Error("process.exit called");
|
|
202
|
+
});
|
|
203
|
+
const origExit = process.exit;
|
|
204
|
+
process.exit = mockExit as unknown as typeof process.exit;
|
|
205
|
+
try {
|
|
206
|
+
expect(() => resolveTargetAssistant()).toThrow("process.exit called");
|
|
207
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
208
|
+
} finally {
|
|
209
|
+
process.exit = origExit;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("setActiveAssistant() / getActiveAssistant() round-trip", () => {
|
|
215
|
+
test("set active, read it back", () => {
|
|
216
|
+
writeLockfile({ assistants: [makeEntry("my-assistant")] });
|
|
217
|
+
setActiveAssistant("my-assistant");
|
|
218
|
+
expect(getActiveAssistant()).toBe("my-assistant");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("lockfile is updated on disk", () => {
|
|
222
|
+
writeLockfile({ assistants: [makeEntry("disk-check")] });
|
|
223
|
+
setActiveAssistant("disk-check");
|
|
224
|
+
const raw = readLockfileRaw();
|
|
225
|
+
expect(raw.activeAssistant).toBe("disk-check");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("removeAssistantEntry() clears matching activeAssistant", () => {
|
|
230
|
+
test("set active to foo, remove foo, verify active is null", () => {
|
|
231
|
+
writeLockfile({
|
|
232
|
+
assistants: [makeEntry("foo"), makeEntry("bar")],
|
|
233
|
+
activeAssistant: "foo",
|
|
234
|
+
});
|
|
235
|
+
removeAssistantEntry("foo");
|
|
236
|
+
expect(getActiveAssistant()).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("set active to foo, remove bar, verify active is still foo", () => {
|
|
240
|
+
writeLockfile({
|
|
241
|
+
assistants: [makeEntry("foo"), makeEntry("bar")],
|
|
242
|
+
activeAssistant: "foo",
|
|
243
|
+
});
|
|
244
|
+
removeAssistantEntry("bar");
|
|
245
|
+
expect(getActiveAssistant()).toBe("foo");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("remote non-regression", () => {
|
|
250
|
+
test("resolveTargetAssistant works with remote entries", () => {
|
|
251
|
+
writeLockfile({
|
|
252
|
+
assistants: [
|
|
253
|
+
makeEntry("my-remote", "gcp", {
|
|
254
|
+
runtimeUrl: "http://10.0.0.1:7821",
|
|
255
|
+
}),
|
|
256
|
+
],
|
|
257
|
+
activeAssistant: "my-remote",
|
|
258
|
+
});
|
|
259
|
+
const result = resolveTargetAssistant();
|
|
260
|
+
expect(result.assistantId).toBe("my-remote");
|
|
261
|
+
expect(result.cloud).toBe("gcp");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("remote entries don't get resources applied", () => {
|
|
265
|
+
const remoteEntry = makeEntry("cloud-box", "aws", {
|
|
266
|
+
runtimeUrl: "http://10.0.0.2:7821",
|
|
267
|
+
});
|
|
268
|
+
writeLockfile({ assistants: [remoteEntry] });
|
|
269
|
+
// Save and reload to verify resources are not injected
|
|
270
|
+
saveAssistantEntry(remoteEntry);
|
|
271
|
+
const result = resolveTargetAssistant("cloud-box");
|
|
272
|
+
expect(result.resources).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
// Bun treats `process.exitCode = undefined` as a no-op, so explicitly
|
|
53
|
+
// reset to 0 when the original value was not set.
|
|
54
|
+
process.exitCode = originalExitCode ?? 0;
|
|
55
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("vellum skills uninstall", () => {
|
|
59
|
+
test("removes skill directory and SKILLS.md entry", async () => {
|
|
60
|
+
/**
|
|
61
|
+
* Tests the happy path for uninstalling a skill.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
// GIVEN a skill is installed locally
|
|
65
|
+
installFakeSkill("weather");
|
|
66
|
+
writeSkillsIndex("- weather\n- google-oauth-setup\n");
|
|
67
|
+
|
|
68
|
+
// WHEN we run `vellum skills uninstall weather`
|
|
69
|
+
process.argv = ["bun", "run", "skills", "uninstall", "weather"];
|
|
70
|
+
await skills();
|
|
71
|
+
|
|
72
|
+
// THEN the skill directory should be removed
|
|
73
|
+
expect(existsSync(join(getSkillsDir(), "weather"))).toBe(false);
|
|
74
|
+
|
|
75
|
+
// AND the SKILLS.md entry should be removed
|
|
76
|
+
const index = readFileSync(getSkillsIndexPath(), "utf-8");
|
|
77
|
+
expect(index).not.toContain("weather");
|
|
78
|
+
|
|
79
|
+
// AND other skills should remain in the index
|
|
80
|
+
expect(index).toContain("google-oauth-setup");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("outputs JSON on success when --json flag is passed", async () => {
|
|
84
|
+
/**
|
|
85
|
+
* Tests that --json flag produces machine-readable output.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
// GIVEN a skill is installed locally
|
|
89
|
+
installFakeSkill("weather");
|
|
90
|
+
writeSkillsIndex("- weather\n");
|
|
91
|
+
|
|
92
|
+
// WHEN we run `vellum skills uninstall weather --json`
|
|
93
|
+
process.argv = ["bun", "run", "skills", "uninstall", "weather", "--json"];
|
|
94
|
+
const logs: string[] = [];
|
|
95
|
+
const origLog = console.log;
|
|
96
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
97
|
+
try {
|
|
98
|
+
await skills();
|
|
99
|
+
} finally {
|
|
100
|
+
console.log = origLog;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// THEN JSON output should indicate success
|
|
104
|
+
const output = JSON.parse(logs[0]);
|
|
105
|
+
expect(output).toEqual({ ok: true, skillId: "weather" });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("errors when skill is not installed", async () => {
|
|
109
|
+
/**
|
|
110
|
+
* Tests that uninstalling a non-existent skill produces an error.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
// GIVEN no skills are installed
|
|
114
|
+
// WHEN we run `vellum skills uninstall nonexistent`
|
|
115
|
+
process.argv = ["bun", "run", "skills", "uninstall", "nonexistent"];
|
|
116
|
+
const errors: string[] = [];
|
|
117
|
+
const origError = console.error;
|
|
118
|
+
console.error = (...args: unknown[]) => errors.push(args.join(" "));
|
|
119
|
+
try {
|
|
120
|
+
await skills();
|
|
121
|
+
} finally {
|
|
122
|
+
console.error = origError;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// THEN an error message should be displayed
|
|
126
|
+
expect(errors[0]).toContain('Skill "nonexistent" is not installed.');
|
|
127
|
+
expect(process.exitCode).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("errors with JSON output when skill is not installed and --json is passed", async () => {
|
|
131
|
+
/**
|
|
132
|
+
* Tests that --json flag produces machine-readable error output.
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
// GIVEN no skills are installed
|
|
136
|
+
// WHEN we run `vellum skills uninstall nonexistent --json`
|
|
137
|
+
process.argv = [
|
|
138
|
+
"bun",
|
|
139
|
+
"run",
|
|
140
|
+
"skills",
|
|
141
|
+
"uninstall",
|
|
142
|
+
"nonexistent",
|
|
143
|
+
"--json",
|
|
144
|
+
];
|
|
145
|
+
const logs: string[] = [];
|
|
146
|
+
const origLog = console.log;
|
|
147
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
148
|
+
try {
|
|
149
|
+
await skills();
|
|
150
|
+
} finally {
|
|
151
|
+
console.log = origLog;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// THEN JSON output should indicate failure
|
|
155
|
+
const output = JSON.parse(logs[0]);
|
|
156
|
+
expect(output.ok).toBe(false);
|
|
157
|
+
expect(output.error).toContain('Skill "nonexistent" is not installed.');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("works when SKILLS.md does not exist", async () => {
|
|
161
|
+
/**
|
|
162
|
+
* Tests that uninstall works even if the SKILLS.md index file is missing.
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
// GIVEN a skill directory exists but no SKILLS.md
|
|
166
|
+
installFakeSkill("weather");
|
|
167
|
+
|
|
168
|
+
// WHEN we run `vellum skills uninstall weather`
|
|
169
|
+
process.argv = ["bun", "run", "skills", "uninstall", "weather"];
|
|
170
|
+
await skills();
|
|
171
|
+
|
|
172
|
+
// THEN the skill directory should be removed
|
|
173
|
+
expect(existsSync(join(getSkillsDir(), "weather"))).toBe(false);
|
|
174
|
+
|
|
175
|
+
// AND no SKILLS.md should have been created
|
|
176
|
+
expect(existsSync(getSkillsIndexPath())).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("removes skill with nested files", async () => {
|
|
180
|
+
/**
|
|
181
|
+
* Tests that uninstall recursively removes skills with nested directories.
|
|
182
|
+
*/
|
|
183
|
+
|
|
184
|
+
// GIVEN a skill with nested files is installed
|
|
185
|
+
const skillDir = join(getSkillsDir(), "weather");
|
|
186
|
+
mkdirSync(join(skillDir, "scripts", "lib"), { recursive: true });
|
|
187
|
+
writeFileSync(join(skillDir, "SKILL.md"), "# weather\n");
|
|
188
|
+
writeFileSync(join(skillDir, "scripts", "fetch.sh"), "#!/bin/bash\n");
|
|
189
|
+
writeFileSync(join(skillDir, "scripts", "lib", "utils.sh"), "# utils\n");
|
|
190
|
+
writeSkillsIndex("- weather\n");
|
|
191
|
+
|
|
192
|
+
// WHEN we run `vellum skills uninstall weather`
|
|
193
|
+
process.argv = ["bun", "run", "skills", "uninstall", "weather"];
|
|
194
|
+
await skills();
|
|
195
|
+
|
|
196
|
+
// THEN the entire skill directory tree should be removed
|
|
197
|
+
expect(existsSync(skillDir)).toBe(false);
|
|
198
|
+
|
|
199
|
+
// AND the SKILLS.md entry should be removed
|
|
200
|
+
const index = readFileSync(getSkillsIndexPath(), "utf-8");
|
|
201
|
+
expect(index).not.toContain("weather");
|
|
202
|
+
});
|
|
203
|
+
});
|
package/src/commands/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
findAssistantByName,
|
|
3
|
+
getActiveAssistant,
|
|
3
4
|
loadLatestAssistant,
|
|
4
5
|
} from "../lib/assistant-config";
|
|
5
6
|
import { GATEWAY_PORT, type Species } from "../lib/constants";
|
|
@@ -45,16 +46,31 @@ function parseArgs(): ParsedArgs {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
let entry: ReturnType<typeof findAssistantByName>;
|
|
50
|
+
if (positionalName) {
|
|
51
|
+
entry = findAssistantByName(positionalName);
|
|
52
|
+
if (!entry) {
|
|
53
|
+
console.error(
|
|
54
|
+
`No assistant instance found with name '${positionalName}'.`,
|
|
55
|
+
);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
} else if (process.env.RUNTIME_URL) {
|
|
59
|
+
// Explicit env var — skip assistant resolution, will use env values below
|
|
60
|
+
entry = loadLatestAssistant();
|
|
61
|
+
} else {
|
|
62
|
+
// Respect active assistant when set, otherwise fall back to latest
|
|
63
|
+
// for backward compatibility with remote-only setups.
|
|
64
|
+
const active = getActiveAssistant();
|
|
65
|
+
const activeEntry = active ? findAssistantByName(active) : null;
|
|
66
|
+
entry = activeEntry ?? loadLatestAssistant();
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
let runtimeUrl =
|
|
57
|
-
process.env.RUNTIME_URL ||
|
|
70
|
+
process.env.RUNTIME_URL ||
|
|
71
|
+
entry?.localUrl ||
|
|
72
|
+
entry?.runtimeUrl ||
|
|
73
|
+
FALLBACK_RUNTIME_URL;
|
|
58
74
|
let assistantId =
|
|
59
75
|
process.env.ASSISTANT_ID || entry?.assistantId || FALLBACK_ASSISTANT_ID;
|
|
60
76
|
const bearerToken =
|