svamp-cli 0.1.87 → 0.1.89

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.
@@ -0,0 +1,531 @@
1
+ import os__default from 'os';
2
+ import fs__default from 'fs';
3
+ import { join, resolve, relative } from 'path';
4
+
5
+ const SKILLS_SERVER = process.env.HYPHA_SKILLS_SERVER || "https://hypha.aicell.io";
6
+ const SKILLS_WORKSPACE = process.env.HYPHA_SKILLS_WORKSPACE || "hypha-cloud";
7
+ const SKILLS_COLLECTION = process.env.HYPHA_SKILLS_COLLECTION || "marketplace";
8
+ const SKILLS_DIR = join(os__default.homedir(), ".claude", "skills");
9
+ function getArtifactBaseUrl() {
10
+ return `${SKILLS_SERVER}/${SKILLS_WORKSPACE}/artifacts`;
11
+ }
12
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 3e4) {
13
+ const controller = new AbortController();
14
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
15
+ try {
16
+ return await fetch(url, { ...options, signal: controller.signal });
17
+ } catch (err) {
18
+ if (err.name === "AbortError") throw new Error(`Request timed out after ${timeoutMs}ms: ${url}`);
19
+ throw err;
20
+ } finally {
21
+ clearTimeout(timer);
22
+ }
23
+ }
24
+ function parseFrontmatter(content) {
25
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
26
+ if (!match) return null;
27
+ const yaml = match[1];
28
+ const result = {};
29
+ let currentKey = null;
30
+ let nestedObj = null;
31
+ for (const line of yaml.split("\n")) {
32
+ if (!line.trim() || line.trim().startsWith("#")) continue;
33
+ const indentMatch = line.match(/^(\s*)/);
34
+ const indent = indentMatch ? indentMatch[1].length : 0;
35
+ if (indent > 0 && currentKey && nestedObj !== null) {
36
+ const kvMatch2 = line.trim().match(/^([^:]+):\s*(.*)$/);
37
+ if (kvMatch2) {
38
+ nestedObj[kvMatch2[1].trim()] = kvMatch2[2].trim().replace(/^["']|["']$/g, "");
39
+ }
40
+ continue;
41
+ }
42
+ if (nestedObj !== null && currentKey) {
43
+ result[currentKey] = nestedObj;
44
+ nestedObj = null;
45
+ currentKey = null;
46
+ }
47
+ const kvMatch = line.match(/^([^:]+):\s*(.*)$/);
48
+ if (!kvMatch) continue;
49
+ const key = kvMatch[1].trim();
50
+ const value = kvMatch[2].trim();
51
+ if (!value) {
52
+ currentKey = key;
53
+ nestedObj = {};
54
+ } else {
55
+ result[key] = value.replace(/^["']|["']$/g, "");
56
+ }
57
+ }
58
+ if (nestedObj !== null && currentKey) {
59
+ result[currentKey] = nestedObj;
60
+ }
61
+ if (!result.name || typeof result.name !== "string") return null;
62
+ if (!result.description || typeof result.description !== "string") return null;
63
+ return result;
64
+ }
65
+ async function searchSkills(query) {
66
+ const base = getArtifactBaseUrl();
67
+ const filters = encodeURIComponent(JSON.stringify({ type: "skill" }));
68
+ const url = `${base}/${SKILLS_COLLECTION}/children?keywords=${encodeURIComponent(query)}&filters=${filters}&limit=50`;
69
+ const resp = await fetchWithTimeout(url);
70
+ if (!resp.ok) {
71
+ if (resp.status === 404) return [];
72
+ throw new Error(`Search failed: ${resp.status} ${resp.statusText}`);
73
+ }
74
+ const data = await resp.json();
75
+ return normalizeSkillList(Array.isArray(data) ? data : data.items || []);
76
+ }
77
+ async function getSkillInfo(skillAlias) {
78
+ const base = getArtifactBaseUrl();
79
+ const url = `${base}/${skillAlias}`;
80
+ const resp = await fetchWithTimeout(url);
81
+ if (!resp.ok) {
82
+ if (resp.status === 404) return null;
83
+ throw new Error(`Get skill failed: ${resp.status} ${resp.statusText}`);
84
+ }
85
+ const data = await resp.json();
86
+ return normalizeSkill(data);
87
+ }
88
+ async function listSkillFiles(skillAlias, dir = "") {
89
+ const base = getArtifactBaseUrl();
90
+ const pathPart = dir ? `/${dir}/` : "/";
91
+ const url = `${base}/${skillAlias}/files${pathPart}`;
92
+ const resp = await fetchWithTimeout(url);
93
+ if (!resp.ok) {
94
+ throw new Error(`List files failed: ${resp.status} ${resp.statusText}`);
95
+ }
96
+ const data = await resp.json();
97
+ return Array.isArray(data) ? data : data.items || [];
98
+ }
99
+ async function downloadSkillFile(skillAlias, filePath) {
100
+ const base = getArtifactBaseUrl();
101
+ const url = `${base}/${skillAlias}/files/${filePath}`;
102
+ const resp = await fetchWithTimeout(url, { redirect: "follow" }, 6e4);
103
+ if (!resp.ok) {
104
+ throw new Error(`Download failed for ${filePath}: ${resp.status} ${resp.statusText}`);
105
+ }
106
+ return await resp.text();
107
+ }
108
+ function normalizeSkillList(items) {
109
+ return items.map(normalizeSkill).filter((s) => s !== null);
110
+ }
111
+ function normalizeSkill(item) {
112
+ if (!item) return null;
113
+ const manifest = item.manifest || {};
114
+ return {
115
+ alias: item.alias || manifest.name || item.id || "unknown",
116
+ name: manifest.name || item.alias || "unknown",
117
+ description: manifest.description || "",
118
+ author: manifest.metadata?.author || manifest.author,
119
+ version: manifest.metadata?.version || manifest.version,
120
+ tags: manifest.tags,
121
+ created_at: item.created_at,
122
+ last_modified: item.last_modified,
123
+ versions: item.versions,
124
+ git_url: item.git_url
125
+ };
126
+ }
127
+
128
+ const SVAMP_HOME = process.env.SVAMP_HOME || join(os__default.homedir(), ".svamp");
129
+ const ENV_FILE = join(SVAMP_HOME, ".env");
130
+ function loadDotEnv() {
131
+ if (!fs__default.existsSync(ENV_FILE)) return;
132
+ const lines = fs__default.readFileSync(ENV_FILE, "utf-8").split("\n");
133
+ for (const line of lines) {
134
+ const trimmed = line.trim();
135
+ if (!trimmed || trimmed.startsWith("#")) continue;
136
+ const eqIdx = trimmed.indexOf("=");
137
+ if (eqIdx === -1) continue;
138
+ const key = trimmed.slice(0, eqIdx).trim();
139
+ const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
140
+ if (!process.env[key]) {
141
+ process.env[key] = value;
142
+ }
143
+ }
144
+ }
145
+ function ensureSkillsDir() {
146
+ fs__default.mkdirSync(SKILLS_DIR, { recursive: true });
147
+ }
148
+ function formatTable(rows, widths) {
149
+ if (rows.length === 0) return "";
150
+ const cols = rows[0].length;
151
+ const w = widths || Array.from(
152
+ { length: cols },
153
+ (_, i) => Math.min(Math.max(...rows.map((r) => (r[i] || "").length), 4), 50)
154
+ );
155
+ return rows.map(
156
+ (row) => row.map((cell, i) => (cell || "").padEnd(w[i])).join(" ")
157
+ ).join("\n");
158
+ }
159
+ function isValidSkillName(name) {
160
+ return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name) && !name.includes("--") && name.length <= 64;
161
+ }
162
+ async function skillsFind(query, opts) {
163
+ let results;
164
+ try {
165
+ results = await searchSkills(query);
166
+ } catch (err) {
167
+ console.error(`Failed to search skills: ${err.message}`);
168
+ process.exit(1);
169
+ }
170
+ if (results.length === 0) {
171
+ console.log(`No skills found matching "${query}".`);
172
+ return;
173
+ }
174
+ if (opts?.json) {
175
+ console.log(JSON.stringify(results, null, 2));
176
+ return;
177
+ }
178
+ const header = ["NAME", "DESCRIPTION", "AUTHOR", "VERSION"];
179
+ const rows = results.map((s) => [
180
+ s.alias,
181
+ s.description.length > 50 ? s.description.slice(0, 47) + "..." : s.description,
182
+ s.author || "-",
183
+ s.version || "-"
184
+ ]);
185
+ console.log(formatTable([header, ...rows], [25, 50, 20, 10]));
186
+ }
187
+ async function skillsInstall(skillName, opts) {
188
+ if (!isValidSkillName(skillName)) {
189
+ console.error(`Invalid skill name "${skillName}". Must be lowercase alphanumeric with hyphens, no consecutive hyphens, max 64 chars.`);
190
+ process.exit(1);
191
+ }
192
+ const targetDir = join(SKILLS_DIR, skillName);
193
+ if (fs__default.existsSync(targetDir) && !opts?.force) {
194
+ console.error(`Skill "${skillName}" is already installed at ${targetDir}`);
195
+ console.error("Use --force to overwrite.");
196
+ process.exit(1);
197
+ }
198
+ let info;
199
+ try {
200
+ info = await getSkillInfo(skillName);
201
+ } catch (err) {
202
+ console.error(`Failed to fetch skill info: ${err.message}`);
203
+ process.exit(1);
204
+ }
205
+ if (!info) {
206
+ console.error(`Skill "${skillName}" not found in the marketplace.`);
207
+ process.exit(1);
208
+ }
209
+ console.log(`Installing skill "${info.name}"...`);
210
+ let files;
211
+ try {
212
+ files = await collectAllFiles(skillName);
213
+ } catch (err) {
214
+ console.error(`Failed to list skill files: ${err.message}`);
215
+ process.exit(1);
216
+ }
217
+ if (files.length === 0) {
218
+ console.error("Skill has no files.");
219
+ process.exit(1);
220
+ }
221
+ if (!files.includes("SKILL.md")) {
222
+ console.error("Skill is missing a SKILL.md file. The skill may be malformed.");
223
+ process.exit(1);
224
+ }
225
+ ensureSkillsDir();
226
+ if (fs__default.existsSync(targetDir) && opts?.force) {
227
+ fs__default.rmSync(targetDir, { recursive: true, force: true });
228
+ }
229
+ fs__default.mkdirSync(targetDir, { recursive: true });
230
+ let downloaded = 0;
231
+ const errors = [];
232
+ for (const filePath of files) {
233
+ try {
234
+ const content = await downloadSkillFile(skillName, filePath);
235
+ const localPath = join(targetDir, filePath);
236
+ if (!localPath.startsWith(targetDir + "/")) {
237
+ errors.push(` ${filePath}: path outside skill directory (blocked)`);
238
+ continue;
239
+ }
240
+ fs__default.mkdirSync(join(localPath, ".."), { recursive: true });
241
+ fs__default.writeFileSync(localPath, content, "utf-8");
242
+ downloaded++;
243
+ } catch (err) {
244
+ errors.push(` ${filePath}: ${err.message}`);
245
+ }
246
+ }
247
+ if (errors.length > 0) {
248
+ fs__default.rmSync(targetDir, { recursive: true, force: true });
249
+ console.error(`Installation failed \u2014 ${errors.length} file(s) could not be downloaded:`);
250
+ errors.forEach((e) => console.error(e));
251
+ console.error("Partial installation cleaned up. Run the command again to retry.");
252
+ process.exit(1);
253
+ }
254
+ const skillMdPath = join(targetDir, "SKILL.md");
255
+ if (fs__default.existsSync(skillMdPath)) {
256
+ const content = fs__default.readFileSync(skillMdPath, "utf-8");
257
+ const fm = parseFrontmatter(content);
258
+ if (!fm) {
259
+ console.error("Warning: Installed SKILL.md has invalid or missing frontmatter (name/description).");
260
+ }
261
+ }
262
+ console.log(`Installed ${downloaded} file(s) to ${targetDir}`);
263
+ if (info.git_url) {
264
+ console.log(` Git: ${info.git_url}`);
265
+ }
266
+ }
267
+ async function collectAllFiles(skillAlias, dir = "") {
268
+ const entries = await listSkillFiles(skillAlias, dir);
269
+ const result = [];
270
+ for (const entry of entries) {
271
+ const entryPath = dir ? `${dir}/${entry.name}` : entry.name;
272
+ if (entry.type === "directory") {
273
+ const sub = await collectAllFiles(skillAlias, entryPath);
274
+ result.push(...sub);
275
+ } else {
276
+ result.push(entryPath);
277
+ }
278
+ }
279
+ return result;
280
+ }
281
+ async function skillsList() {
282
+ ensureSkillsDir();
283
+ const entries = fs__default.readdirSync(SKILLS_DIR, { withFileTypes: true }).filter((e) => e.isDirectory());
284
+ if (entries.length === 0) {
285
+ console.log("No skills installed.");
286
+ console.log(`Install one with: svamp skills install <name>`);
287
+ return;
288
+ }
289
+ const header = ["NAME", "DESCRIPTION", "STATUS"];
290
+ const rows = [];
291
+ for (const entry of entries) {
292
+ const skillDir = join(SKILLS_DIR, entry.name);
293
+ const skillMd = join(skillDir, "SKILL.md");
294
+ let description = "-";
295
+ let status = "ok";
296
+ if (!fs__default.existsSync(skillMd)) {
297
+ status = "missing SKILL.md";
298
+ } else {
299
+ try {
300
+ const content = fs__default.readFileSync(skillMd, "utf-8");
301
+ const fm = parseFrontmatter(content);
302
+ if (fm) {
303
+ description = fm.description.length > 45 ? fm.description.slice(0, 42) + "..." : fm.description;
304
+ } else {
305
+ status = "invalid frontmatter";
306
+ }
307
+ } catch (err) {
308
+ status = "read error";
309
+ }
310
+ }
311
+ rows.push([entry.name, description, status]);
312
+ }
313
+ console.log(formatTable([header, ...rows], [25, 45, 20]));
314
+ }
315
+ async function skillsRemove(name) {
316
+ if (name.includes("/") || name.includes("\\") || name === ".." || name === ".") {
317
+ console.error(`Invalid skill name "${name}".`);
318
+ process.exit(1);
319
+ }
320
+ const targetDir = join(SKILLS_DIR, name);
321
+ if (!fs__default.existsSync(targetDir)) {
322
+ console.error(`Skill "${name}" is not installed.`);
323
+ process.exit(1);
324
+ }
325
+ if (!fs__default.statSync(targetDir).isDirectory()) {
326
+ console.error(`"${name}" is not a skill directory.`);
327
+ process.exit(1);
328
+ }
329
+ fs__default.rmSync(targetDir, { recursive: true, force: true });
330
+ console.log(`Removed skill "${name}" from ${targetDir}`);
331
+ }
332
+ async function skillsPublish(skillPath, opts) {
333
+ const absPath = resolve(skillPath);
334
+ if (!fs__default.existsSync(absPath)) {
335
+ console.error(`Path does not exist: ${absPath}`);
336
+ process.exit(1);
337
+ }
338
+ if (!fs__default.statSync(absPath).isDirectory()) {
339
+ console.error(`Not a directory: ${absPath}`);
340
+ process.exit(1);
341
+ }
342
+ const skillMdPath = join(absPath, "SKILL.md");
343
+ if (!fs__default.existsSync(skillMdPath)) {
344
+ console.error(`No SKILL.md found in ${absPath}`);
345
+ console.error("A valid skill must contain a SKILL.md file with name and description frontmatter.");
346
+ process.exit(1);
347
+ }
348
+ const skillMdContent = fs__default.readFileSync(skillMdPath, "utf-8");
349
+ const manifest = parseFrontmatter(skillMdContent);
350
+ if (!manifest) {
351
+ console.error('SKILL.md must have YAML frontmatter with "name" and "description" fields.');
352
+ process.exit(1);
353
+ }
354
+ if (!isValidSkillName(manifest.name)) {
355
+ console.error(`Invalid skill name "${manifest.name}".`);
356
+ console.error("Must be lowercase alphanumeric with hyphens, no consecutive hyphens, 1-64 chars.");
357
+ process.exit(1);
358
+ }
359
+ if (!manifest.description.trim()) {
360
+ console.error("SKILL.md description cannot be empty.");
361
+ process.exit(1);
362
+ }
363
+ const filesToUpload = collectLocalFiles(absPath);
364
+ if (filesToUpload.length === 0) {
365
+ console.error("No files to upload.");
366
+ process.exit(1);
367
+ }
368
+ const versionTag = opts?.version || null;
369
+ console.log(`Publishing skill "${manifest.name}"${versionTag ? ` (version: ${versionTag})` : ""}...`);
370
+ loadDotEnv();
371
+ const token = process.env.HYPHA_TOKEN;
372
+ if (!token) {
373
+ console.error('Not logged in. Run "svamp login <url>" first.');
374
+ process.exit(1);
375
+ }
376
+ const serverUrl = process.env.HYPHA_SERVER_URL || SKILLS_SERVER;
377
+ let server;
378
+ try {
379
+ const mod = await import('hypha-rpc');
380
+ const connectToServer = mod.connectToServer || mod.default?.connectToServer;
381
+ server = await connectToServer({
382
+ server_url: serverUrl,
383
+ token,
384
+ workspace: SKILLS_WORKSPACE
385
+ });
386
+ } catch (err) {
387
+ console.error(`Failed to connect to Hypha: ${err.message}`);
388
+ process.exit(1);
389
+ }
390
+ let am;
391
+ try {
392
+ am = await server.getService("public/artifact-manager");
393
+ } catch (err) {
394
+ console.error(`Failed to get artifact-manager service: ${err.message}`);
395
+ await server.disconnect();
396
+ process.exit(1);
397
+ }
398
+ let artifactId;
399
+ let isUpdate = false;
400
+ let existingArtifact = null;
401
+ try {
402
+ existingArtifact = await am.read({
403
+ artifact_id: `${SKILLS_WORKSPACE}/${manifest.name}`,
404
+ _rkwargs: true
405
+ });
406
+ } catch {
407
+ }
408
+ if (existingArtifact) {
409
+ artifactId = existingArtifact.id;
410
+ isUpdate = true;
411
+ console.log(`Updating existing skill "${manifest.name}"...`);
412
+ try {
413
+ await am.edit({
414
+ artifact_id: artifactId,
415
+ manifest: {
416
+ name: manifest.name,
417
+ description: manifest.description,
418
+ ...manifest.metadata && { metadata: manifest.metadata }
419
+ },
420
+ stage: true,
421
+ _rkwargs: true
422
+ });
423
+ } catch (err) {
424
+ console.error(`Failed to stage skill artifact for editing: ${err.message}`);
425
+ await server.disconnect();
426
+ process.exit(1);
427
+ }
428
+ } else {
429
+ try {
430
+ const created = await am.create({
431
+ alias: manifest.name,
432
+ type: "skill",
433
+ parent_id: `${SKILLS_WORKSPACE}/${SKILLS_COLLECTION}`,
434
+ manifest: {
435
+ name: manifest.name,
436
+ description: manifest.description,
437
+ ...manifest.metadata && { metadata: manifest.metadata }
438
+ },
439
+ config: {
440
+ storage: "git"
441
+ },
442
+ version: "stage",
443
+ _rkwargs: true
444
+ });
445
+ artifactId = created.id;
446
+ console.log(`Created new skill artifact with git storage.`);
447
+ } catch (err) {
448
+ console.error(`Failed to create skill artifact: ${err.message}`);
449
+ await server.disconnect();
450
+ process.exit(1);
451
+ }
452
+ }
453
+ let uploaded = 0;
454
+ const uploadErrors = [];
455
+ for (const relPath of filesToUpload) {
456
+ try {
457
+ const fullPath = join(absPath, relPath);
458
+ const content = fs__default.readFileSync(fullPath);
459
+ const putUrl = await am.put_file({
460
+ artifact_id: artifactId,
461
+ file_path: relPath,
462
+ _rkwargs: true
463
+ });
464
+ if (!putUrl || typeof putUrl !== "string") {
465
+ uploadErrors.push(`${relPath}: failed to get upload URL`);
466
+ continue;
467
+ }
468
+ const resp = await fetchWithTimeout(putUrl, {
469
+ method: "PUT",
470
+ body: content,
471
+ headers: { "Content-Type": "application/octet-stream" }
472
+ }, 12e4);
473
+ if (!resp.ok) {
474
+ uploadErrors.push(`${relPath}: upload returned ${resp.status}`);
475
+ continue;
476
+ }
477
+ uploaded++;
478
+ } catch (err) {
479
+ uploadErrors.push(`${relPath}: ${err.message}`);
480
+ }
481
+ }
482
+ if (uploadErrors.length > 0) {
483
+ console.error(`Warning: ${uploadErrors.length} file(s) failed to upload:`);
484
+ uploadErrors.forEach((e) => console.error(` ${e}`));
485
+ }
486
+ if (uploaded === 0) {
487
+ console.error("No files were uploaded. Publish aborted.");
488
+ await server.disconnect();
489
+ process.exit(1);
490
+ }
491
+ try {
492
+ const commitOpts = {
493
+ artifact_id: artifactId,
494
+ _rkwargs: true
495
+ };
496
+ if (versionTag) {
497
+ commitOpts.version = versionTag;
498
+ commitOpts.comment = `Release ${versionTag}`;
499
+ }
500
+ await am.commit(commitOpts);
501
+ } catch (err) {
502
+ console.error(`Failed to commit: ${err.message}`);
503
+ await server.disconnect();
504
+ process.exit(1);
505
+ }
506
+ console.log(`Published "${manifest.name}" (${uploaded} files)${versionTag ? ` as ${versionTag}` : ""}`);
507
+ console.log(` View: ${SKILLS_SERVER}/${SKILLS_WORKSPACE}/artifacts/${manifest.name}`);
508
+ const gitUrl = `${SKILLS_SERVER}/${SKILLS_WORKSPACE}/git/${manifest.name}`;
509
+ console.log(` Git: ${gitUrl}`);
510
+ if (isUpdate) {
511
+ console.log(` Tip: Use --version <tag> to publish a named version.`);
512
+ }
513
+ await server.disconnect();
514
+ }
515
+ function collectLocalFiles(dir, base) {
516
+ const root = base || dir;
517
+ const result = [];
518
+ const entries = fs__default.readdirSync(dir, { withFileTypes: true });
519
+ for (const entry of entries) {
520
+ const fullPath = join(dir, entry.name);
521
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
522
+ if (entry.isDirectory()) {
523
+ result.push(...collectLocalFiles(fullPath, root));
524
+ } else {
525
+ result.push(relative(root, fullPath));
526
+ }
527
+ }
528
+ return result;
529
+ }
530
+
531
+ export { skillsFind, skillsInstall, skillsList, skillsPublish, skillsRemove };