@vellumai/cli 0.4.42 → 0.4.43
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 +1 -1
- package/src/__tests__/assistant-config.test.ts +33 -2
- package/src/__tests__/multi-local.test.ts +13 -22
- package/src/__tests__/sleep.test.ts +172 -0
- package/src/commands/client.ts +72 -10
- package/src/commands/hatch.ts +61 -15
- package/src/commands/ps.ts +25 -8
- package/src/commands/recover.ts +17 -8
- package/src/commands/retire.ts +14 -23
- package/src/commands/sleep.ts +88 -16
- package/src/commands/wake.ts +9 -7
- package/src/components/DefaultMainScreen.tsx +3 -83
- package/src/index.ts +0 -3
- package/src/lib/assistant-config.ts +17 -62
- package/src/lib/aws.ts +30 -1
- package/src/lib/docker.ts +319 -0
- package/src/lib/gcp.ts +53 -1
- package/src/lib/http-client.ts +114 -0
- package/src/lib/local.ts +96 -148
- package/src/lib/step-runner.ts +9 -1
- package/src/__tests__/skills-uninstall.test.ts +0 -203
- package/src/commands/skills.ts +0 -514
|
@@ -1,203 +0,0 @@
|
|
|
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/skills.ts
DELETED
|
@@ -1,514 +0,0 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
cpSync,
|
|
5
|
-
existsSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
renameSync,
|
|
9
|
-
rmSync,
|
|
10
|
-
writeFileSync,
|
|
11
|
-
} from "node:fs";
|
|
12
|
-
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
14
|
-
import { gunzipSync } from "node:zlib";
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Path helpers
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
function getRootDir(): string {
|
|
21
|
-
return join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function getSkillsDir(): string {
|
|
25
|
-
return join(getRootDir(), "workspace", "skills");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function getSkillsIndexPath(): string {
|
|
29
|
-
return join(getSkillsDir(), "SKILLS.md");
|
|
30
|
-
}
|
|
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
|
-
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
// Platform API client
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
|
|
68
|
-
function getConfigPlatformUrl(): string | undefined {
|
|
69
|
-
try {
|
|
70
|
-
const configPath = join(getRootDir(), "workspace", "config.json");
|
|
71
|
-
if (!existsSync(configPath)) return undefined;
|
|
72
|
-
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
73
|
-
string,
|
|
74
|
-
unknown
|
|
75
|
-
>;
|
|
76
|
-
const platform = raw.platform as Record<string, unknown> | undefined;
|
|
77
|
-
const baseUrl = platform?.baseUrl;
|
|
78
|
-
if (typeof baseUrl === "string" && baseUrl.trim()) return baseUrl.trim();
|
|
79
|
-
} catch {
|
|
80
|
-
// ignore
|
|
81
|
-
}
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function getPlatformUrl(): string {
|
|
86
|
-
return (
|
|
87
|
-
process.env.VELLUM_ASSISTANT_PLATFORM_URL ??
|
|
88
|
-
getConfigPlatformUrl() ??
|
|
89
|
-
"https://platform.vellum.ai"
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function getPlatformToken(): string | null {
|
|
94
|
-
try {
|
|
95
|
-
return readFileSync(join(getRootDir(), "platform-token"), "utf-8").trim();
|
|
96
|
-
} catch {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function buildHeaders(): Record<string, string> {
|
|
102
|
-
const headers: Record<string, string> = {};
|
|
103
|
-
const token = getPlatformToken();
|
|
104
|
-
if (token) {
|
|
105
|
-
headers["X-Session-Token"] = token;
|
|
106
|
-
}
|
|
107
|
-
return headers;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
// Types
|
|
112
|
-
// ---------------------------------------------------------------------------
|
|
113
|
-
|
|
114
|
-
interface CatalogSkill {
|
|
115
|
-
id: string;
|
|
116
|
-
name: string;
|
|
117
|
-
description: string;
|
|
118
|
-
emoji?: string;
|
|
119
|
-
includes?: string[];
|
|
120
|
-
version?: string;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface CatalogManifest {
|
|
124
|
-
version: number;
|
|
125
|
-
skills: CatalogSkill[];
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
|
-
// Catalog operations
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
async function fetchCatalog(): Promise<CatalogSkill[]> {
|
|
133
|
-
const url = `${getPlatformUrl()}/v1/skills/`;
|
|
134
|
-
const response = await fetch(url, {
|
|
135
|
-
headers: buildHeaders(),
|
|
136
|
-
signal: AbortSignal.timeout(10000),
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
if (!response.ok) {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`Platform API error ${response.status}: ${response.statusText}`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const manifest = (await response.json()) as CatalogManifest;
|
|
146
|
-
if (!Array.isArray(manifest.skills)) {
|
|
147
|
-
throw new Error("Platform catalog has invalid skills array");
|
|
148
|
-
}
|
|
149
|
-
return manifest.skills;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Extract all files from a tar archive (uncompressed) into a directory.
|
|
154
|
-
* Returns true if a SKILL.md was found in the archive.
|
|
155
|
-
*/
|
|
156
|
-
function extractTarToDir(tarBuffer: Buffer, destDir: string): boolean {
|
|
157
|
-
let foundSkillMd = false;
|
|
158
|
-
let offset = 0;
|
|
159
|
-
while (offset + 512 <= tarBuffer.length) {
|
|
160
|
-
const header = tarBuffer.subarray(offset, offset + 512);
|
|
161
|
-
|
|
162
|
-
// End-of-archive (two consecutive zero blocks)
|
|
163
|
-
if (header.every((b) => b === 0)) break;
|
|
164
|
-
|
|
165
|
-
// Filename (bytes 0-99, null-terminated)
|
|
166
|
-
const nameEnd = header.indexOf(0, 0);
|
|
167
|
-
const name = header
|
|
168
|
-
.subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100))
|
|
169
|
-
.toString("utf-8");
|
|
170
|
-
|
|
171
|
-
// File type (byte 156): '5' = directory, '0' or '\0' = regular file
|
|
172
|
-
const typeFlag = header[156];
|
|
173
|
-
|
|
174
|
-
// File size (bytes 124-135, octal)
|
|
175
|
-
const sizeStr = header.subarray(124, 136).toString("utf-8").trim();
|
|
176
|
-
const size = parseInt(sizeStr, 8) || 0;
|
|
177
|
-
|
|
178
|
-
offset += 512; // past header
|
|
179
|
-
|
|
180
|
-
// Skip directories and empty names
|
|
181
|
-
if (name && typeFlag !== 53 /* '5' */) {
|
|
182
|
-
// Prevent path traversal
|
|
183
|
-
const normalizedName = name.replace(/^\.\//, "");
|
|
184
|
-
if (!normalizedName.startsWith("..") && !normalizedName.includes("/..")) {
|
|
185
|
-
const destPath = join(destDir, normalizedName);
|
|
186
|
-
mkdirSync(dirname(destPath), { recursive: true });
|
|
187
|
-
writeFileSync(destPath, tarBuffer.subarray(offset, offset + size));
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
normalizedName === "SKILL.md" ||
|
|
191
|
-
normalizedName.endsWith("/SKILL.md")
|
|
192
|
-
) {
|
|
193
|
-
foundSkillMd = true;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Skip to next header (data padded to 512 bytes)
|
|
199
|
-
offset += Math.ceil(size / 512) * 512;
|
|
200
|
-
}
|
|
201
|
-
return foundSkillMd;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async function fetchAndExtractSkill(
|
|
205
|
-
skillId: string,
|
|
206
|
-
destDir: string,
|
|
207
|
-
): Promise<void> {
|
|
208
|
-
const url = `${getPlatformUrl()}/v1/skills/${encodeURIComponent(skillId)}/`;
|
|
209
|
-
const response = await fetch(url, {
|
|
210
|
-
headers: buildHeaders(),
|
|
211
|
-
signal: AbortSignal.timeout(15000),
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
if (!response.ok) {
|
|
215
|
-
throw new Error(
|
|
216
|
-
`Failed to fetch skill "${skillId}": HTTP ${response.status}`,
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const gzipBuffer = Buffer.from(await response.arrayBuffer());
|
|
221
|
-
const tarBuffer = gunzipSync(gzipBuffer);
|
|
222
|
-
const foundSkillMd = extractTarToDir(tarBuffer, destDir);
|
|
223
|
-
|
|
224
|
-
if (!foundSkillMd) {
|
|
225
|
-
throw new Error(`SKILL.md not found in archive for "${skillId}"`);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ---------------------------------------------------------------------------
|
|
230
|
-
// Managed skill installation
|
|
231
|
-
// ---------------------------------------------------------------------------
|
|
232
|
-
|
|
233
|
-
function atomicWriteFile(filePath: string, content: string): void {
|
|
234
|
-
const dir = dirname(filePath);
|
|
235
|
-
mkdirSync(dir, { recursive: true });
|
|
236
|
-
const tmpPath = join(dir, `.tmp-${randomUUID()}`);
|
|
237
|
-
writeFileSync(tmpPath, content, "utf-8");
|
|
238
|
-
renameSync(tmpPath, filePath);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function upsertSkillsIndex(id: string): void {
|
|
242
|
-
const indexPath = getSkillsIndexPath();
|
|
243
|
-
let lines: string[] = [];
|
|
244
|
-
if (existsSync(indexPath)) {
|
|
245
|
-
lines = readFileSync(indexPath, "utf-8").split("\n");
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
249
|
-
const pattern = new RegExp(`^[-*]\\s+(?:\`)?${escaped}(?:\`)?\\s*$`);
|
|
250
|
-
if (lines.some((line) => pattern.test(line))) return;
|
|
251
|
-
|
|
252
|
-
const nonEmpty = lines.filter((l) => l.trim());
|
|
253
|
-
nonEmpty.push(`- ${id}`);
|
|
254
|
-
const content = nonEmpty.join("\n");
|
|
255
|
-
atomicWriteFile(indexPath, content.endsWith("\n") ? content : content + "\n");
|
|
256
|
-
}
|
|
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
|
-
|
|
285
|
-
async function installSkillLocally(
|
|
286
|
-
skillId: string,
|
|
287
|
-
catalogEntry: CatalogSkill,
|
|
288
|
-
overwrite: boolean,
|
|
289
|
-
): Promise<void> {
|
|
290
|
-
const skillDir = join(getSkillsDir(), skillId);
|
|
291
|
-
const skillFilePath = join(skillDir, "SKILL.md");
|
|
292
|
-
|
|
293
|
-
if (existsSync(skillFilePath) && !overwrite) {
|
|
294
|
-
throw new Error(
|
|
295
|
-
`Skill "${skillId}" is already installed. Use --overwrite to replace it.`,
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
mkdirSync(skillDir, { recursive: true });
|
|
300
|
-
|
|
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
|
-
}
|
|
312
|
-
|
|
313
|
-
// Write version metadata
|
|
314
|
-
if (catalogEntry.version) {
|
|
315
|
-
const meta = {
|
|
316
|
-
version: catalogEntry.version,
|
|
317
|
-
installedAt: new Date().toISOString(),
|
|
318
|
-
};
|
|
319
|
-
atomicWriteFile(
|
|
320
|
-
join(skillDir, "version.json"),
|
|
321
|
-
JSON.stringify(meta, null, 2) + "\n",
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Install npm dependencies if the skill has a package.json
|
|
326
|
-
if (existsSync(join(skillDir, "package.json"))) {
|
|
327
|
-
const bunPath = `${process.env.HOME || "/root"}/.bun/bin`;
|
|
328
|
-
execSync("bun install", {
|
|
329
|
-
cwd: skillDir,
|
|
330
|
-
stdio: "inherit",
|
|
331
|
-
env: { ...process.env, PATH: `${bunPath}:${process.env.PATH}` },
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Register in SKILLS.md only after all steps succeed
|
|
336
|
-
upsertSkillsIndex(skillId);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ---------------------------------------------------------------------------
|
|
340
|
-
// Helpers
|
|
341
|
-
// ---------------------------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
function hasFlag(args: string[], flag: string): boolean {
|
|
344
|
-
return args.includes(flag);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// ---------------------------------------------------------------------------
|
|
348
|
-
// Usage
|
|
349
|
-
// ---------------------------------------------------------------------------
|
|
350
|
-
|
|
351
|
-
function printUsage(): void {
|
|
352
|
-
console.log("Usage: vellum skills <subcommand> [options]");
|
|
353
|
-
console.log("");
|
|
354
|
-
console.log("Subcommands:");
|
|
355
|
-
console.log(
|
|
356
|
-
" list List available catalog skills",
|
|
357
|
-
);
|
|
358
|
-
console.log(
|
|
359
|
-
" install <skill-id> [--overwrite] Install a skill from the catalog",
|
|
360
|
-
);
|
|
361
|
-
console.log(
|
|
362
|
-
" uninstall <skill-id> Uninstall a previously installed skill",
|
|
363
|
-
);
|
|
364
|
-
console.log("");
|
|
365
|
-
console.log("Options:");
|
|
366
|
-
console.log(" --json Machine-readable JSON output");
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// ---------------------------------------------------------------------------
|
|
370
|
-
// Command entry point
|
|
371
|
-
// ---------------------------------------------------------------------------
|
|
372
|
-
|
|
373
|
-
export async function skills(): Promise<void> {
|
|
374
|
-
const args = process.argv.slice(3);
|
|
375
|
-
const subcommand = args[0];
|
|
376
|
-
const json = hasFlag(args, "--json");
|
|
377
|
-
|
|
378
|
-
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
379
|
-
printUsage();
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
switch (subcommand) {
|
|
384
|
-
case "list": {
|
|
385
|
-
try {
|
|
386
|
-
const catalog = await fetchCatalog();
|
|
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
|
-
|
|
400
|
-
if (json) {
|
|
401
|
-
console.log(JSON.stringify({ ok: true, skills: catalog }));
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (catalog.length === 0) {
|
|
406
|
-
console.log("No skills available in the catalog.");
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
console.log(`Available skills (${catalog.length}):\n`);
|
|
411
|
-
for (const s of catalog) {
|
|
412
|
-
const emoji = s.emoji ? `${s.emoji} ` : "";
|
|
413
|
-
const deps = s.includes?.length
|
|
414
|
-
? ` (requires: ${s.includes.join(", ")})`
|
|
415
|
-
: "";
|
|
416
|
-
console.log(` ${emoji}${s.id}`);
|
|
417
|
-
console.log(` ${s.name} — ${s.description}${deps}`);
|
|
418
|
-
}
|
|
419
|
-
} catch (err) {
|
|
420
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
421
|
-
if (json) {
|
|
422
|
-
console.log(JSON.stringify({ ok: false, error: msg }));
|
|
423
|
-
} else {
|
|
424
|
-
console.error(`Error: ${msg}`);
|
|
425
|
-
}
|
|
426
|
-
process.exitCode = 1;
|
|
427
|
-
}
|
|
428
|
-
break;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
case "install": {
|
|
432
|
-
const skillId = args.find((a) => !a.startsWith("--") && a !== "install");
|
|
433
|
-
if (!skillId) {
|
|
434
|
-
console.error("Usage: vellum skills install <skill-id>");
|
|
435
|
-
process.exit(1);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
const overwrite = hasFlag(args, "--overwrite");
|
|
439
|
-
|
|
440
|
-
try {
|
|
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
|
-
|
|
455
|
-
if (!entry) {
|
|
456
|
-
throw new Error(`Skill "${skillId}" not found in the Vellum catalog`);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Fetch, extract, and install
|
|
460
|
-
await installSkillLocally(skillId, entry, overwrite);
|
|
461
|
-
|
|
462
|
-
if (json) {
|
|
463
|
-
console.log(JSON.stringify({ ok: true, skillId }));
|
|
464
|
-
} else {
|
|
465
|
-
console.log(`Installed skill "${skillId}".`);
|
|
466
|
-
}
|
|
467
|
-
} catch (err) {
|
|
468
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
469
|
-
if (json) {
|
|
470
|
-
console.log(JSON.stringify({ ok: false, error: msg }));
|
|
471
|
-
} else {
|
|
472
|
-
console.error(`Error: ${msg}`);
|
|
473
|
-
}
|
|
474
|
-
process.exitCode = 1;
|
|
475
|
-
}
|
|
476
|
-
break;
|
|
477
|
-
}
|
|
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
|
-
|
|
508
|
-
default: {
|
|
509
|
-
console.error(`Unknown skills subcommand: ${subcommand}`);
|
|
510
|
-
printUsage();
|
|
511
|
-
process.exit(1);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
}
|