@poncho-ai/cli 0.37.0 → 0.38.1

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/src/skills.ts ADDED
@@ -0,0 +1,372 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, cp, mkdir, readdir, rm, stat } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { basename, dirname, normalize, relative, resolve } from "node:path";
5
+ import { createRequire } from "node:module";
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ export const runPnpmInstall = async (workingDir: string): Promise<void> =>
10
+ await new Promise<void>((resolveInstall, rejectInstall) => {
11
+ const child = spawn("pnpm", ["install"], {
12
+ cwd: workingDir,
13
+ stdio: "inherit",
14
+ env: process.env,
15
+ });
16
+ child.on("exit", (code) => {
17
+ if (code === 0) {
18
+ resolveInstall();
19
+ return;
20
+ }
21
+ rejectInstall(new Error(`pnpm install failed with exit code ${code ?? -1}`));
22
+ });
23
+ });
24
+
25
+ export const runInstallCommand = async (
26
+ workingDir: string,
27
+ packageNameOrPath: string,
28
+ ): Promise<void> =>
29
+ await new Promise<void>((resolveInstall, rejectInstall) => {
30
+ const child = spawn("pnpm", ["add", packageNameOrPath], {
31
+ cwd: workingDir,
32
+ stdio: "inherit",
33
+ env: process.env,
34
+ });
35
+ child.on("exit", (code) => {
36
+ if (code === 0) {
37
+ resolveInstall();
38
+ return;
39
+ }
40
+ rejectInstall(new Error(`pnpm add failed with exit code ${code ?? -1}`));
41
+ });
42
+ });
43
+
44
+ /**
45
+ * Resolve the installed npm package name from a package specifier.
46
+ * Handles local paths, scoped packages, and GitHub shorthand (e.g.
47
+ * "vercel-labs/agent-skills" installs as "agent-skills").
48
+ */
49
+ export const resolveInstalledPackageName = (packageNameOrPath: string): string | null => {
50
+ if (packageNameOrPath.startsWith(".") || packageNameOrPath.startsWith("/")) {
51
+ return null; // local path — handled separately
52
+ }
53
+ // Scoped package: @scope/name
54
+ if (packageNameOrPath.startsWith("@")) {
55
+ return packageNameOrPath;
56
+ }
57
+ // GitHub shorthand: owner/repo — npm installs as the repo name
58
+ if (packageNameOrPath.includes("/")) {
59
+ return packageNameOrPath.split("/").pop() ?? packageNameOrPath;
60
+ }
61
+ return packageNameOrPath;
62
+ };
63
+
64
+ /**
65
+ * Locate the root directory of an installed skill package.
66
+ * Handles local paths, normal npm packages, and GitHub repos (which may
67
+ * lack a root package.json).
68
+ */
69
+ export const resolveSkillRoot = (
70
+ workingDir: string,
71
+ packageNameOrPath: string,
72
+ ): string => {
73
+ // Local path
74
+ if (packageNameOrPath.startsWith(".") || packageNameOrPath.startsWith("/")) {
75
+ return resolve(workingDir, packageNameOrPath);
76
+ }
77
+
78
+ const moduleName =
79
+ resolveInstalledPackageName(packageNameOrPath) ?? packageNameOrPath;
80
+
81
+ // Try require.resolve first (works for packages with a package.json)
82
+ try {
83
+ const packageJsonPath = require.resolve(`${moduleName}/package.json`, {
84
+ paths: [workingDir],
85
+ });
86
+ return resolve(packageJsonPath, "..");
87
+ } catch {
88
+ // Fall back to looking in node_modules directly (GitHub repos may lack
89
+ // a root package.json)
90
+ const candidate = resolve(workingDir, "node_modules", moduleName);
91
+ if (existsSync(candidate)) {
92
+ return candidate;
93
+ }
94
+ throw new Error(
95
+ `Could not locate installed package "${moduleName}" in ${workingDir}`,
96
+ );
97
+ }
98
+ };
99
+
100
+ export const normalizeSkillSourceName = (value: string): string => {
101
+ const normalized = value
102
+ .trim()
103
+ .replace(/\\/g, "/")
104
+ .replace(/^@/, "")
105
+ .replace(/[\/\s]+/g, "-")
106
+ .replace(/[^a-zA-Z0-9._-]/g, "-")
107
+ .replace(/-+/g, "-")
108
+ .replace(/^-|-$/g, "");
109
+ return normalized.length > 0 ? normalized : "skills";
110
+ };
111
+
112
+ export const collectSkillManifests = async (dir: string, depth = 2): Promise<string[]> => {
113
+ const manifests: string[] = [];
114
+ const localManifest = resolve(dir, "SKILL.md");
115
+ try {
116
+ await access(localManifest);
117
+ manifests.push(localManifest);
118
+ } catch {
119
+ // Not found at this level — look one level deeper (e.g. skills/<name>/SKILL.md)
120
+ }
121
+
122
+ if (depth <= 0) return manifests;
123
+
124
+ try {
125
+ const entries = await readdir(dir, { withFileTypes: true });
126
+ for (const entry of entries) {
127
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
128
+
129
+ let isDir = entry.isDirectory();
130
+ // Dirent reports symlinks separately; resolve target type via stat()
131
+ if (!isDir && entry.isSymbolicLink()) {
132
+ try {
133
+ const s = await stat(resolve(dir, entry.name));
134
+ isDir = s.isDirectory();
135
+ } catch {
136
+ continue; // broken symlink — skip
137
+ }
138
+ }
139
+
140
+ if (isDir) {
141
+ manifests.push(...(await collectSkillManifests(resolve(dir, entry.name), depth - 1)));
142
+ }
143
+ }
144
+ } catch {
145
+ // ignore read errors
146
+ }
147
+
148
+ return manifests;
149
+ };
150
+
151
+ export const validateSkillPackage = async (
152
+ workingDir: string,
153
+ packageNameOrPath: string,
154
+ ): Promise<{ skillRoot: string; manifests: string[] }> => {
155
+ const skillRoot = resolveSkillRoot(workingDir, packageNameOrPath);
156
+ const manifests = await collectSkillManifests(skillRoot);
157
+ if (manifests.length === 0) {
158
+ throw new Error(`Skill validation failed: no SKILL.md found in ${skillRoot}`);
159
+ }
160
+ return { skillRoot, manifests };
161
+ };
162
+
163
+ export const selectSkillManifests = async (
164
+ skillRoot: string,
165
+ manifests: string[],
166
+ relativeSkillPath?: string,
167
+ ): Promise<string[]> => {
168
+ if (!relativeSkillPath) return manifests;
169
+
170
+ const normalized = normalize(relativeSkillPath);
171
+ if (normalized.startsWith("..") || normalized.startsWith("/")) {
172
+ throw new Error(`Invalid skill path "${relativeSkillPath}": path must be within package root.`);
173
+ }
174
+
175
+ const candidate = resolve(skillRoot, normalized);
176
+ const relativeToRoot = relative(skillRoot, candidate).split("\\").join("/");
177
+ if (relativeToRoot.startsWith("..") || relativeToRoot.startsWith("/")) {
178
+ throw new Error(`Invalid skill path "${relativeSkillPath}": path escapes package root.`);
179
+ }
180
+
181
+ const candidateAsFile = candidate.toLowerCase().endsWith("skill.md")
182
+ ? candidate
183
+ : resolve(candidate, "SKILL.md");
184
+ if (!existsSync(candidateAsFile)) {
185
+ throw new Error(
186
+ `Skill path "${relativeSkillPath}" does not point to a directory (or file) containing SKILL.md.`,
187
+ );
188
+ }
189
+
190
+ const selected = manifests.filter((manifest) => resolve(manifest) === resolve(candidateAsFile));
191
+ if (selected.length === 0) {
192
+ throw new Error(`Skill path "${relativeSkillPath}" was not discovered as a valid skill manifest.`);
193
+ }
194
+ return selected;
195
+ };
196
+
197
+ export const copySkillsIntoProject = async (
198
+ workingDir: string,
199
+ manifests: string[],
200
+ sourceName: string,
201
+ ): Promise<string[]> => {
202
+ const skillsDir = resolve(workingDir, "skills", normalizeSkillSourceName(sourceName));
203
+ await mkdir(skillsDir, { recursive: true });
204
+
205
+ const destinations = new Map<string, string>();
206
+ for (const manifest of manifests) {
207
+ const sourceSkillDir = dirname(manifest);
208
+ const skillFolderName = basename(sourceSkillDir);
209
+ if (destinations.has(skillFolderName)) {
210
+ throw new Error(
211
+ `Skill copy failed: multiple skill directories map to "skills/${skillFolderName}" (${destinations.get(skillFolderName)} and ${sourceSkillDir}).`,
212
+ );
213
+ }
214
+ destinations.set(skillFolderName, sourceSkillDir);
215
+ }
216
+
217
+ const copied: string[] = [];
218
+ for (const [skillFolderName, sourceSkillDir] of destinations.entries()) {
219
+ const destinationSkillDir = resolve(skillsDir, skillFolderName);
220
+ if (existsSync(destinationSkillDir)) {
221
+ throw new Error(
222
+ `Skill copy failed: destination already exists at ${destinationSkillDir}. Remove or rename it and try again.`,
223
+ );
224
+ }
225
+ await cp(sourceSkillDir, destinationSkillDir, {
226
+ recursive: true,
227
+ dereference: true,
228
+ force: false,
229
+ errorOnExist: true,
230
+ });
231
+ copied.push(relative(workingDir, destinationSkillDir).split("\\").join("/"));
232
+ }
233
+
234
+ return copied.sort();
235
+ };
236
+
237
+ export const copySkillsFromPackage = async (
238
+ workingDir: string,
239
+ packageNameOrPath: string,
240
+ options?: { path?: string },
241
+ ): Promise<string[]> => {
242
+ const { skillRoot, manifests } = await validateSkillPackage(workingDir, packageNameOrPath);
243
+ const selected = await selectSkillManifests(skillRoot, manifests, options?.path);
244
+ const sourceName = resolveInstalledPackageName(packageNameOrPath) ?? basename(skillRoot);
245
+ return await copySkillsIntoProject(workingDir, selected, sourceName);
246
+ };
247
+
248
+ export const addSkill = async (
249
+ workingDir: string,
250
+ packageNameOrPath: string,
251
+ options?: { path?: string },
252
+ ): Promise<void> => {
253
+ await runInstallCommand(workingDir, packageNameOrPath);
254
+ const copiedSkills = await copySkillsFromPackage(workingDir, packageNameOrPath, options);
255
+ process.stdout.write(
256
+ `Added ${copiedSkills.length} skill${copiedSkills.length === 1 ? "" : "s"} from ${packageNameOrPath}:\n`,
257
+ );
258
+ for (const copied of copiedSkills) {
259
+ process.stdout.write(`- ${copied}\n`);
260
+ }
261
+ };
262
+
263
+ const getSkillFolderNames = (manifests: string[]): string[] => {
264
+ const names = new Set<string>();
265
+ for (const manifest of manifests) {
266
+ names.add(basename(dirname(manifest)));
267
+ }
268
+ return Array.from(names).sort();
269
+ };
270
+
271
+ export const removeSkillsFromPackage = async (
272
+ workingDir: string,
273
+ packageNameOrPath: string,
274
+ options?: { path?: string },
275
+ ): Promise<{ removed: string[]; missing: string[] }> => {
276
+ const { skillRoot, manifests } = await validateSkillPackage(workingDir, packageNameOrPath);
277
+ const selected = await selectSkillManifests(skillRoot, manifests, options?.path);
278
+ const skillsDir = resolve(workingDir, "skills");
279
+ const sourceName = normalizeSkillSourceName(
280
+ resolveInstalledPackageName(packageNameOrPath) ?? basename(skillRoot),
281
+ );
282
+ const sourceSkillsDir = resolve(skillsDir, sourceName);
283
+ const skillNames = getSkillFolderNames(selected);
284
+
285
+ const removed: string[] = [];
286
+ const missing: string[] = [];
287
+
288
+ if (!options?.path && existsSync(sourceSkillsDir)) {
289
+ await rm(sourceSkillsDir, { recursive: true, force: false });
290
+ removed.push(`skills/${sourceName}`);
291
+ return { removed, missing };
292
+ }
293
+
294
+ for (const skillName of skillNames) {
295
+ const destinationSkillDir = resolve(sourceSkillsDir, skillName);
296
+ const normalized = relative(skillsDir, destinationSkillDir).split("\\").join("/");
297
+ if (normalized.startsWith("..") || normalized.startsWith("/")) {
298
+ throw new Error(`Refusing to remove path outside skills directory: ${destinationSkillDir}`);
299
+ }
300
+
301
+ if (!existsSync(destinationSkillDir)) {
302
+ missing.push(`skills/${sourceName}/${skillName}`);
303
+ continue;
304
+ }
305
+
306
+ await rm(destinationSkillDir, { recursive: true, force: false });
307
+ removed.push(`skills/${sourceName}/${skillName}`);
308
+ }
309
+
310
+ return { removed, missing };
311
+ };
312
+
313
+ export const removeSkillPackage = async (
314
+ workingDir: string,
315
+ packageNameOrPath: string,
316
+ options?: { path?: string },
317
+ ): Promise<void> => {
318
+ const result = await removeSkillsFromPackage(workingDir, packageNameOrPath, options);
319
+ process.stdout.write(
320
+ `Removed ${result.removed.length} skill${result.removed.length === 1 ? "" : "s"} from ${packageNameOrPath}:\n`,
321
+ );
322
+ for (const removed of result.removed) {
323
+ process.stdout.write(`- ${removed}\n`);
324
+ }
325
+ if (result.missing.length > 0) {
326
+ process.stdout.write(
327
+ `Skipped ${result.missing.length} missing skill${result.missing.length === 1 ? "" : "s"}:\n`,
328
+ );
329
+ for (const missing of result.missing) {
330
+ process.stdout.write(`- ${missing}\n`);
331
+ }
332
+ }
333
+ };
334
+
335
+ export const listInstalledSkills = async (
336
+ workingDir: string,
337
+ sourceName?: string,
338
+ ): Promise<string[]> => {
339
+ const skillsRoot = resolve(workingDir, "skills");
340
+ const resolvedSourceName = sourceName
341
+ ? resolveInstalledPackageName(sourceName) ?? sourceName
342
+ : undefined;
343
+ const targetRoot = sourceName
344
+ ? resolve(skillsRoot, normalizeSkillSourceName(resolvedSourceName ?? sourceName))
345
+ : skillsRoot;
346
+ if (!existsSync(targetRoot)) {
347
+ return [];
348
+ }
349
+ const manifests = await collectSkillManifests(targetRoot, sourceName ? 1 : 2);
350
+ return manifests
351
+ .map((manifest) => relative(workingDir, dirname(manifest)).split("\\").join("/"))
352
+ .sort();
353
+ };
354
+
355
+ export const listSkills = async (workingDir: string, sourceName?: string): Promise<void> => {
356
+ const skills = await listInstalledSkills(workingDir, sourceName);
357
+ if (skills.length === 0) {
358
+ process.stdout.write("No installed skills found.\n");
359
+ return;
360
+ }
361
+ const resolvedSourceName = sourceName
362
+ ? resolveInstalledPackageName(sourceName) ?? sourceName
363
+ : undefined;
364
+ process.stdout.write(
365
+ sourceName
366
+ ? `Installed skills for ${normalizeSkillSourceName(resolvedSourceName ?? sourceName)}:\n`
367
+ : "Installed skills:\n",
368
+ );
369
+ for (const skill of skills) {
370
+ process.stdout.write(`- ${skill}\n`);
371
+ }
372
+ };