@rbbtsn0w/adg 0.1.0-alpha.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/bin/adg.ts +758 -0
  4. package/docs/agents-spec.md +132 -0
  5. package/docs/authoring.md +352 -0
  6. package/package.json +50 -0
  7. package/schemas/adg-plugin.schema.json +77 -0
  8. package/schemas/marketplace.schema.json +86 -0
  9. package/schemas/plugin-lock.schema.json +90 -0
  10. package/src/adapters/anthropic.ts +54 -0
  11. package/src/adapters/index.ts +24 -0
  12. package/src/adapters/openai.ts +37 -0
  13. package/src/adapters/reverse.ts +60 -0
  14. package/src/agents/claude.ts +124 -0
  15. package/src/agents/codex.ts +67 -0
  16. package/src/agents/index.ts +12 -0
  17. package/src/agents/registry.ts +30 -0
  18. package/src/agents/types.ts +47 -0
  19. package/src/commands/adapt.ts +36 -0
  20. package/src/commands/import.ts +69 -0
  21. package/src/commands/init.ts +146 -0
  22. package/src/commands/install.ts +411 -0
  23. package/src/commands/link.ts +61 -0
  24. package/src/commands/list.ts +28 -0
  25. package/src/commands/marketplace.ts +198 -0
  26. package/src/commands/migrate.ts +84 -0
  27. package/src/commands/multiselect-skills.ts +137 -0
  28. package/src/commands/remove.ts +136 -0
  29. package/src/commands/select-agents.ts +45 -0
  30. package/src/commands/select-components.ts +66 -0
  31. package/src/commands/select-plugins.ts +28 -0
  32. package/src/commands/select-scope.ts +21 -0
  33. package/src/commands/update.ts +85 -0
  34. package/src/commands/validate.ts +57 -0
  35. package/src/components.ts +90 -0
  36. package/src/deps.ts +64 -0
  37. package/src/fsutil.ts +38 -0
  38. package/src/hash.ts +61 -0
  39. package/src/lock.ts +57 -0
  40. package/src/manifest.ts +113 -0
  41. package/src/marketplace.ts +41 -0
  42. package/src/package.ts +74 -0
  43. package/src/paths.ts +129 -0
  44. package/src/semver.ts +67 -0
  45. package/src/skills.ts +88 -0
  46. package/src/sources.ts +159 -0
  47. package/src/types.ts +140 -0
  48. package/vendor/skills/LICENSE +29 -0
  49. package/vendor/skills/PROVENANCE.md +60 -0
  50. package/vendor/skills/ThirdPartyNoticeText.txt +117 -0
  51. package/vendor/skills/package.json +143 -0
  52. package/vendor/skills/src/add.ts +1999 -0
  53. package/vendor/skills/src/agents.ts +755 -0
  54. package/vendor/skills/src/blob.ts +567 -0
  55. package/vendor/skills/src/cli.ts +387 -0
  56. package/vendor/skills/src/constants.ts +3 -0
  57. package/vendor/skills/src/detect-agent.ts +62 -0
  58. package/vendor/skills/src/find.ts +357 -0
  59. package/vendor/skills/src/frontmatter.ts +16 -0
  60. package/vendor/skills/src/git-tree.ts +36 -0
  61. package/vendor/skills/src/git.ts +277 -0
  62. package/vendor/skills/src/install.ts +91 -0
  63. package/vendor/skills/src/installer.ts +1097 -0
  64. package/vendor/skills/src/list.ts +231 -0
  65. package/vendor/skills/src/local-lock.ts +182 -0
  66. package/vendor/skills/src/plugin-manifest.ts +183 -0
  67. package/vendor/skills/src/prompts/search-multiselect.ts +387 -0
  68. package/vendor/skills/src/providers/index.ts +14 -0
  69. package/vendor/skills/src/providers/registry.ts +51 -0
  70. package/vendor/skills/src/providers/types.ts +97 -0
  71. package/vendor/skills/src/providers/wellknown.ts +804 -0
  72. package/vendor/skills/src/remove.ts +323 -0
  73. package/vendor/skills/src/sanitize.ts +65 -0
  74. package/vendor/skills/src/self-cli.ts +20 -0
  75. package/vendor/skills/src/skill-lock.ts +329 -0
  76. package/vendor/skills/src/skills.ts +316 -0
  77. package/vendor/skills/src/source-parser.ts +438 -0
  78. package/vendor/skills/src/sync.ts +478 -0
  79. package/vendor/skills/src/telemetry.ts +186 -0
  80. package/vendor/skills/src/test-utils.ts +73 -0
  81. package/vendor/skills/src/types.ts +128 -0
  82. package/vendor/skills/src/update-source.ts +90 -0
  83. package/vendor/skills/src/update.ts +749 -0
  84. package/vendor/skills/src/use.ts +675 -0
@@ -0,0 +1,1097 @@
1
+ import {
2
+ mkdir,
3
+ cp,
4
+ access,
5
+ readdir,
6
+ symlink,
7
+ lstat,
8
+ rm,
9
+ readlink,
10
+ writeFile,
11
+ stat,
12
+ realpath,
13
+ } from 'fs/promises';
14
+ import { existsSync } from 'fs';
15
+ import { join, basename, normalize, resolve, sep, relative, dirname } from 'path';
16
+ import { homedir, platform } from 'os';
17
+ import type { Skill, AgentType, RemoteSkill } from './types.ts';
18
+ import type { WellKnownSkill } from './providers/wellknown.ts';
19
+ import { agents, detectInstalledAgents, isUniversalAgent } from './agents.ts';
20
+ import { AGENTS_DIR, SKILLS_SUBDIR } from './constants.ts';
21
+ import { parseSkillMd } from './skills.ts';
22
+
23
+ export type InstallMode = 'symlink' | 'copy';
24
+
25
+ interface InstallResult {
26
+ success: boolean;
27
+ path: string;
28
+ canonicalPath?: string;
29
+ mode: InstallMode;
30
+ symlinkFailed?: boolean;
31
+ skipped?: boolean;
32
+ error?: string;
33
+ }
34
+
35
+ /**
36
+ * Sanitizes a filename/directory name to prevent path traversal attacks
37
+ * and ensures it follows kebab-case convention
38
+ * @param name - The name to sanitize
39
+ * @returns Sanitized name safe for use in file paths
40
+ */
41
+ export function sanitizeName(name: string): string {
42
+ const sanitized = name
43
+ .toLowerCase()
44
+ // Replace any sequence of characters that are NOT lowercase letters (a-z),
45
+ // digits (0-9), dots (.), or underscores (_) with a single hyphen.
46
+ // This converts spaces, special chars, and path traversal attempts (../) into hyphens.
47
+ .replace(/[^a-z0-9._]+/g, '-')
48
+ // Remove leading/trailing dots and hyphens to prevent hidden files (.) and
49
+ // ensure clean directory names. The pattern matches:
50
+ // - ^[.\-]+ : one or more dots or hyphens at the start
51
+ // - [.\-]+$ : one or more dots or hyphens at the end
52
+ .replace(/^[.\-]+|[.\-]+$/g, '');
53
+
54
+ // Limit to 255 chars (common filesystem limit), fallback to 'unnamed-skill' if empty
55
+ return sanitized.substring(0, 255) || 'unnamed-skill';
56
+ }
57
+
58
+ /**
59
+ * Validates that a path is within an expected base directory
60
+ * @param basePath - The expected base directory
61
+ * @param targetPath - The path to validate
62
+ * @returns true if targetPath is within basePath
63
+ */
64
+ function isPathSafe(basePath: string, targetPath: string): boolean {
65
+ const normalizedBase = normalize(resolve(basePath));
66
+ const normalizedTarget = normalize(resolve(targetPath));
67
+
68
+ return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase;
69
+ }
70
+
71
+ // Dirent.isDirectory() is false for symlinks; follow and verify the target is a directory.
72
+ async function isDirEntryOrSymlinkToDir(
73
+ entry: { isDirectory(): boolean; isSymbolicLink(): boolean },
74
+ entryPath: string
75
+ ): Promise<boolean> {
76
+ if (entry.isDirectory()) return true;
77
+ if (!entry.isSymbolicLink()) return false;
78
+ try {
79
+ return (await stat(entryPath)).isDirectory();
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ export function getCanonicalSkillsDir(global: boolean, cwd?: string): string {
86
+ const baseDir = global ? homedir() : cwd || process.cwd();
87
+ return join(baseDir, AGENTS_DIR, SKILLS_SUBDIR);
88
+ }
89
+
90
+ /**
91
+ * Gets the base directory for an agent's skills, respecting universal agents.
92
+ * Universal agents always use the canonical directory, which prevents
93
+ * redundant symlinks and double-listing of skills.
94
+ */
95
+ export function getAgentBaseDir(agentType: AgentType, global: boolean, cwd?: string): string {
96
+ if (isUniversalAgent(agentType)) {
97
+ return getCanonicalSkillsDir(global, cwd);
98
+ }
99
+
100
+ const agent = agents[agentType];
101
+ const baseDir = global ? homedir() : cwd || process.cwd();
102
+
103
+ if (global) {
104
+ if (agent.globalSkillsDir === undefined) {
105
+ // This should be caught by callers checking support
106
+ return join(baseDir, agent.skillsDir);
107
+ }
108
+ return agent.globalSkillsDir;
109
+ }
110
+
111
+ return join(baseDir, agent.skillsDir);
112
+ }
113
+
114
+ function resolveSymlinkTarget(linkPath: string, linkTarget: string): string {
115
+ return resolve(dirname(linkPath), linkTarget);
116
+ }
117
+
118
+ /**
119
+ * Cleans and recreates a directory for skill installation.
120
+ *
121
+ * This ensures:
122
+ * 1. Renamed/deleted files from previous installs are removed
123
+ * 2. Symlinks (including self-referential ones causing ELOOP) are handled
124
+ * when canonical and agent paths resolve to the same location
125
+ */
126
+ async function cleanAndCreateDirectory(path: string): Promise<void> {
127
+ try {
128
+ await rm(path, { recursive: true, force: true });
129
+ } catch {
130
+ // Ignore cleanup errors - mkdir will fail if there's a real problem
131
+ }
132
+ await mkdir(path, { recursive: true });
133
+ }
134
+
135
+ /**
136
+ * Resolve a path's parent directory through symlinks, keeping the final component.
137
+ * This handles the case where a parent directory (e.g., ~/.claude/skills) is a symlink
138
+ * to another location (e.g., ~/.agents/skills). In that case, computing relative paths
139
+ * from the symlink path produces broken symlinks.
140
+ *
141
+ * Returns the real path of the parent + the original basename.
142
+ * If realpath fails (parent doesn't exist), returns the original resolved path.
143
+ */
144
+ async function resolveParentSymlinks(path: string): Promise<string> {
145
+ const resolved = resolve(path);
146
+ const dir = dirname(resolved);
147
+ const base = basename(resolved);
148
+ try {
149
+ const realDir = await realpath(dir);
150
+ return join(realDir, base);
151
+ } catch {
152
+ return resolved;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Creates a symlink, handling cross-platform differences
158
+ * Returns true if symlink was created, false if fallback to copy is needed
159
+ */
160
+ async function createSymlink(target: string, linkPath: string): Promise<boolean> {
161
+ try {
162
+ const resolvedTarget = resolve(target);
163
+ const resolvedLinkPath = resolve(linkPath);
164
+
165
+ // Use realpath to handle cases where parent directories are symlinked.
166
+ // This prevents deleting the canonical directory if the agent directory
167
+ // is a symlink to the canonical location.
168
+ const [realTarget, realLinkPath] = await Promise.all([
169
+ realpath(resolvedTarget).catch(() => resolvedTarget),
170
+ realpath(resolvedLinkPath).catch(() => resolvedLinkPath),
171
+ ]);
172
+
173
+ if (realTarget === realLinkPath) {
174
+ return true;
175
+ }
176
+
177
+ // Also check with symlinks resolved in parent directories.
178
+ // This handles cases where e.g. ~/.claude/skills is a symlink to ~/.agents/skills,
179
+ // so ~/.claude/skills/<skill> and ~/.agents/skills/<skill> are physically the same.
180
+ const realTargetWithParents = await resolveParentSymlinks(target);
181
+ const realLinkPathWithParents = await resolveParentSymlinks(linkPath);
182
+
183
+ if (realTargetWithParents === realLinkPathWithParents) {
184
+ return true;
185
+ }
186
+
187
+ try {
188
+ const stats = await lstat(linkPath);
189
+ if (stats.isSymbolicLink()) {
190
+ const existingTarget = await readlink(linkPath);
191
+ if (resolveSymlinkTarget(linkPath, existingTarget) === resolvedTarget) {
192
+ return true;
193
+ }
194
+ await rm(linkPath);
195
+ } else {
196
+ await rm(linkPath, { recursive: true });
197
+ }
198
+ } catch (err: unknown) {
199
+ // ELOOP = circular symlink, ENOENT = doesn't exist
200
+ // For ELOOP, try to remove the broken symlink
201
+ if (err && typeof err === 'object' && 'code' in err && err.code === 'ELOOP') {
202
+ try {
203
+ await rm(linkPath, { force: true });
204
+ } catch {
205
+ // If we can't remove it, symlink creation will fail and trigger copy fallback
206
+ }
207
+ }
208
+ // For ENOENT or other errors, continue to symlink creation
209
+ }
210
+
211
+ const linkDir = dirname(linkPath);
212
+ await mkdir(linkDir, { recursive: true });
213
+
214
+ // Use the real (symlink-resolved) parent directory for computing the relative path.
215
+ // This ensures the symlink target is correct even when the link's parent dir is a symlink.
216
+ const realLinkDir = await resolveParentSymlinks(linkDir);
217
+ const relativePath = relative(realLinkDir, target);
218
+ const symlinkType = platform() === 'win32' ? 'junction' : undefined;
219
+
220
+ await symlink(relativePath, linkPath, symlinkType);
221
+ return true;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ export async function installSkillForAgent(
228
+ skill: Skill,
229
+ agentType: AgentType,
230
+ options: { global?: boolean; cwd?: string; mode?: InstallMode } = {}
231
+ ): Promise<InstallResult> {
232
+ const agent = agents[agentType];
233
+ const isGlobal = options.global ?? false;
234
+ const cwd = options.cwd || process.cwd();
235
+
236
+ // Check if agent supports global installation
237
+ if (isGlobal && agent.globalSkillsDir === undefined) {
238
+ return {
239
+ success: false,
240
+ path: '',
241
+ mode: options.mode ?? 'symlink',
242
+ error: `${agent.displayName} does not support global skill installation`,
243
+ };
244
+ }
245
+
246
+ // Sanitize skill name to prevent directory traversal
247
+ const rawSkillName = skill.name || basename(skill.path);
248
+ const skillName = sanitizeName(rawSkillName);
249
+
250
+ // Canonical location: .agents/skills/<skill-name>
251
+ const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd);
252
+ const canonicalDir = join(canonicalBase, skillName);
253
+
254
+ // Agent-specific location (for symlink)
255
+ const agentBase = getAgentBaseDir(agentType, isGlobal, cwd);
256
+ const agentDir = join(agentBase, skillName);
257
+
258
+ const installMode = options.mode ?? 'symlink';
259
+
260
+ // Validate paths
261
+ if (!isPathSafe(canonicalBase, canonicalDir)) {
262
+ return {
263
+ success: false,
264
+ path: agentDir,
265
+ mode: installMode,
266
+ error: 'Invalid skill name: potential path traversal detected',
267
+ };
268
+ }
269
+
270
+ if (!isPathSafe(agentBase, agentDir)) {
271
+ return {
272
+ success: false,
273
+ path: agentDir,
274
+ mode: installMode,
275
+ error: 'Invalid skill name: potential path traversal detected',
276
+ };
277
+ }
278
+
279
+ try {
280
+ // For copy mode, skip canonical directory and copy directly to agent location
281
+ if (installMode === 'copy') {
282
+ await cleanAndCreateDirectory(agentDir);
283
+ await copyDirectory(skill.path, agentDir);
284
+
285
+ return {
286
+ success: true,
287
+ path: agentDir,
288
+ mode: 'copy',
289
+ };
290
+ }
291
+
292
+ // Symlink mode: copy to canonical location and symlink to agent location
293
+ await cleanAndCreateDirectory(canonicalDir);
294
+ await copyDirectory(skill.path, canonicalDir);
295
+
296
+ // For universal agents with global install, the skill is already in the canonical
297
+ // ~/.agents/skills directory. Skip creating a symlink to the agent-specific global dir
298
+ // (e.g. ~/.copilot/skills) to avoid duplicates.
299
+ if (isGlobal && isUniversalAgent(agentType)) {
300
+ return {
301
+ success: true,
302
+ path: canonicalDir,
303
+ canonicalPath: canonicalDir,
304
+ mode: 'symlink',
305
+ };
306
+ }
307
+
308
+ // For project-level installs, skip creating symlinks for non-universal agents
309
+ // whose config directory doesn't already exist in the project. This prevents
310
+ // creating directories like .windsurf/, .kiro/, etc. when those agents aren't
311
+ // actually used in this project. The skill is already available in .agents/skills/.
312
+ if (!isGlobal && !isUniversalAgent(agentType)) {
313
+ const agentRootDir = join(cwd, agents[agentType].skillsDir.split('/')[0]!);
314
+ if (!existsSync(agentRootDir)) {
315
+ return {
316
+ success: true,
317
+ path: canonicalDir,
318
+ canonicalPath: canonicalDir,
319
+ mode: 'symlink',
320
+ skipped: true,
321
+ };
322
+ }
323
+ }
324
+
325
+ const symlinkCreated = await createSymlink(canonicalDir, agentDir);
326
+
327
+ if (!symlinkCreated) {
328
+ // Symlink failed, fall back to copy
329
+ await cleanAndCreateDirectory(agentDir);
330
+ await copyDirectory(skill.path, agentDir);
331
+
332
+ return {
333
+ success: true,
334
+ path: agentDir,
335
+ canonicalPath: canonicalDir,
336
+ mode: 'symlink',
337
+ symlinkFailed: true,
338
+ };
339
+ }
340
+
341
+ return {
342
+ success: true,
343
+ path: agentDir,
344
+ canonicalPath: canonicalDir,
345
+ mode: 'symlink',
346
+ };
347
+ } catch (error) {
348
+ return {
349
+ success: false,
350
+ path: agentDir,
351
+ mode: installMode,
352
+ error: error instanceof Error ? error.message : 'Unknown error',
353
+ };
354
+ }
355
+ }
356
+
357
+ const EXCLUDE_FILES = new Set(['metadata.json']);
358
+ const EXCLUDE_DIRS = new Set(['.git', '__pycache__', '__pypackages__']);
359
+
360
+ const isExcluded = (name: string, isDirectory: boolean = false): boolean => {
361
+ if (EXCLUDE_FILES.has(name)) return true;
362
+ if (isDirectory && EXCLUDE_DIRS.has(name)) return true;
363
+ return false;
364
+ };
365
+
366
+ async function copyDirectory(src: string, dest: string): Promise<void> {
367
+ await mkdir(dest, { recursive: true });
368
+
369
+ const entries = await readdir(src, { withFileTypes: true });
370
+
371
+ // Copy files and directories in parallel
372
+ await Promise.all(
373
+ entries
374
+ .filter((entry) => !isExcluded(entry.name, entry.isDirectory()))
375
+ .map(async (entry) => {
376
+ const srcPath = join(src, entry.name);
377
+ const destPath = join(dest, entry.name);
378
+
379
+ if (entry.isDirectory()) {
380
+ await copyDirectory(srcPath, destPath);
381
+ } else {
382
+ try {
383
+ await cp(srcPath, destPath, {
384
+ // If the file is a symlink to elsewhere in a remote skill, it may not
385
+ // resolve correctly once it has been copied to the local location.
386
+ // `dereference: true` tells Node to copy the file instead of copying
387
+ // the symlink. `recursive: true` handles symlinks pointing to directories.
388
+ dereference: true,
389
+ recursive: true,
390
+ });
391
+ } catch (err: unknown) {
392
+ // Skip broken symlinks (e.g., pointing to absolute paths on another machine)
393
+ // instead of aborting the entire install.
394
+ if (
395
+ err instanceof Error &&
396
+ 'code' in err &&
397
+ (err as NodeJS.ErrnoException).code === 'ENOENT' &&
398
+ entry.isSymbolicLink()
399
+ ) {
400
+ console.warn(`Skipping broken symlink: ${srcPath}`);
401
+ } else {
402
+ throw err;
403
+ }
404
+ }
405
+ }
406
+ })
407
+ );
408
+ }
409
+
410
+ export async function isSkillInstalled(
411
+ skillName: string,
412
+ agentType: AgentType,
413
+ options: { global?: boolean; cwd?: string } = {}
414
+ ): Promise<boolean> {
415
+ const agent = agents[agentType];
416
+ const sanitized = sanitizeName(skillName);
417
+
418
+ // Agent doesn't support global installation
419
+ if (options.global && agent.globalSkillsDir === undefined) {
420
+ return false;
421
+ }
422
+
423
+ const targetBase = options.global
424
+ ? agent.globalSkillsDir!
425
+ : join(options.cwd || process.cwd(), agent.skillsDir);
426
+
427
+ const skillDir = join(targetBase, sanitized);
428
+
429
+ if (!isPathSafe(targetBase, skillDir)) {
430
+ return false;
431
+ }
432
+
433
+ try {
434
+ await access(skillDir);
435
+ return true;
436
+ } catch {
437
+ return false;
438
+ }
439
+ }
440
+
441
+ export function getInstallPath(
442
+ skillName: string,
443
+ agentType: AgentType,
444
+ options: { global?: boolean; cwd?: string } = {}
445
+ ): string {
446
+ const agent = agents[agentType];
447
+ const cwd = options.cwd || process.cwd();
448
+ const sanitized = sanitizeName(skillName);
449
+
450
+ const targetBase = getAgentBaseDir(agentType, options.global ?? false, options.cwd);
451
+ const installPath = join(targetBase, sanitized);
452
+
453
+ if (!isPathSafe(targetBase, installPath)) {
454
+ throw new Error('Invalid skill name: potential path traversal detected');
455
+ }
456
+
457
+ return installPath;
458
+ }
459
+
460
+ /**
461
+ * Gets the canonical .agents/skills/<skill> path
462
+ */
463
+ export function getCanonicalPath(
464
+ skillName: string,
465
+ options: { global?: boolean; cwd?: string } = {}
466
+ ): string {
467
+ const sanitized = sanitizeName(skillName);
468
+ const canonicalBase = getCanonicalSkillsDir(options.global ?? false, options.cwd);
469
+ const canonicalPath = join(canonicalBase, sanitized);
470
+
471
+ if (!isPathSafe(canonicalBase, canonicalPath)) {
472
+ throw new Error('Invalid skill name: potential path traversal detected');
473
+ }
474
+
475
+ return canonicalPath;
476
+ }
477
+
478
+ /**
479
+ * Install a remote skill from any host provider.
480
+ * The skill directory name is derived from the installName field.
481
+ * Supports symlink mode (writes to canonical location and symlinks to agent dirs)
482
+ * or copy mode (writes directly to each agent dir).
483
+ */
484
+ export async function installRemoteSkillForAgent(
485
+ skill: RemoteSkill,
486
+ agentType: AgentType,
487
+ options: { global?: boolean; cwd?: string; mode?: InstallMode } = {}
488
+ ): Promise<InstallResult> {
489
+ const agent = agents[agentType];
490
+ const isGlobal = options.global ?? false;
491
+ const cwd = options.cwd || process.cwd();
492
+ const installMode = options.mode ?? 'symlink';
493
+
494
+ // Check if agent supports global installation
495
+ if (isGlobal && agent.globalSkillsDir === undefined) {
496
+ return {
497
+ success: false,
498
+ path: '',
499
+ mode: installMode,
500
+ error: `${agent.displayName} does not support global skill installation`,
501
+ };
502
+ }
503
+
504
+ // Use installName as the skill directory name
505
+ const skillName = sanitizeName(skill.installName);
506
+
507
+ // Canonical location: .agents/skills/<skill-name>
508
+ const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd);
509
+ const canonicalDir = join(canonicalBase, skillName);
510
+
511
+ // Agent-specific location (for symlink)
512
+ const agentBase = getAgentBaseDir(agentType, isGlobal, cwd);
513
+ const agentDir = join(agentBase, skillName);
514
+
515
+ // Validate paths
516
+ if (!isPathSafe(canonicalBase, canonicalDir)) {
517
+ return {
518
+ success: false,
519
+ path: agentDir,
520
+ mode: installMode,
521
+ error: 'Invalid skill name: potential path traversal detected',
522
+ };
523
+ }
524
+
525
+ if (!isPathSafe(agentBase, agentDir)) {
526
+ return {
527
+ success: false,
528
+ path: agentDir,
529
+ mode: installMode,
530
+ error: 'Invalid skill name: potential path traversal detected',
531
+ };
532
+ }
533
+
534
+ try {
535
+ // For copy mode, write directly to agent location
536
+ if (installMode === 'copy') {
537
+ await cleanAndCreateDirectory(agentDir);
538
+ const skillMdPath = join(agentDir, 'SKILL.md');
539
+ await writeFile(skillMdPath, skill.content, 'utf-8');
540
+
541
+ return {
542
+ success: true,
543
+ path: agentDir,
544
+ mode: 'copy',
545
+ };
546
+ }
547
+
548
+ // Symlink mode: write to canonical location and symlink to agent location
549
+ await cleanAndCreateDirectory(canonicalDir);
550
+ const skillMdPath = join(canonicalDir, 'SKILL.md');
551
+ await writeFile(skillMdPath, skill.content, 'utf-8');
552
+
553
+ // For universal agents with global install, skip creating agent-specific symlink
554
+ if (isGlobal && isUniversalAgent(agentType)) {
555
+ return {
556
+ success: true,
557
+ path: canonicalDir,
558
+ canonicalPath: canonicalDir,
559
+ mode: 'symlink',
560
+ };
561
+ }
562
+
563
+ const symlinkCreated = await createSymlink(canonicalDir, agentDir);
564
+
565
+ if (!symlinkCreated) {
566
+ // Symlink failed, fall back to copy
567
+ await cleanAndCreateDirectory(agentDir);
568
+ const agentSkillMdPath = join(agentDir, 'SKILL.md');
569
+ await writeFile(agentSkillMdPath, skill.content, 'utf-8');
570
+
571
+ return {
572
+ success: true,
573
+ path: agentDir,
574
+ canonicalPath: canonicalDir,
575
+ mode: 'symlink',
576
+ symlinkFailed: true,
577
+ };
578
+ }
579
+
580
+ return {
581
+ success: true,
582
+ path: agentDir,
583
+ canonicalPath: canonicalDir,
584
+ mode: 'symlink',
585
+ };
586
+ } catch (error) {
587
+ return {
588
+ success: false,
589
+ path: agentDir,
590
+ mode: installMode,
591
+ error: error instanceof Error ? error.message : 'Unknown error',
592
+ };
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Install a well-known skill with multiple files.
598
+ * The skill directory name is derived from the installName field.
599
+ * All files from the skill's files map are written to the installation directory.
600
+ * Supports symlink mode (writes to canonical location and symlinks to agent dirs)
601
+ * or copy mode (writes directly to each agent dir).
602
+ */
603
+ export async function installWellKnownSkillForAgent(
604
+ skill: WellKnownSkill,
605
+ agentType: AgentType,
606
+ options: { global?: boolean; cwd?: string; mode?: InstallMode } = {}
607
+ ): Promise<InstallResult> {
608
+ const agent = agents[agentType];
609
+ const isGlobal = options.global ?? false;
610
+ const cwd = options.cwd || process.cwd();
611
+ const installMode = options.mode ?? 'symlink';
612
+
613
+ // Check if agent supports global installation
614
+ if (isGlobal && agent.globalSkillsDir === undefined) {
615
+ return {
616
+ success: false,
617
+ path: '',
618
+ mode: installMode,
619
+ error: `${agent.displayName} does not support global skill installation`,
620
+ };
621
+ }
622
+
623
+ // Use installName as the skill directory name
624
+ const skillName = sanitizeName(skill.installName);
625
+
626
+ // Canonical location: .agents/skills/<skill-name>
627
+ const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd);
628
+ const canonicalDir = join(canonicalBase, skillName);
629
+
630
+ // Agent-specific location (for symlink)
631
+ const agentBase = getAgentBaseDir(agentType, isGlobal, cwd);
632
+ const agentDir = join(agentBase, skillName);
633
+
634
+ // Validate paths
635
+ if (!isPathSafe(canonicalBase, canonicalDir)) {
636
+ return {
637
+ success: false,
638
+ path: agentDir,
639
+ mode: installMode,
640
+ error: 'Invalid skill name: potential path traversal detected',
641
+ };
642
+ }
643
+
644
+ if (!isPathSafe(agentBase, agentDir)) {
645
+ return {
646
+ success: false,
647
+ path: agentDir,
648
+ mode: installMode,
649
+ error: 'Invalid skill name: potential path traversal detected',
650
+ };
651
+ }
652
+
653
+ /**
654
+ * Write all skill files to a directory (assumes directory already exists)
655
+ */
656
+ async function writeSkillFiles(targetDir: string): Promise<void> {
657
+ for (const [filePath, content] of skill.files) {
658
+ // Validate file path doesn't escape the target directory
659
+ const fullPath = join(targetDir, filePath);
660
+ if (!isPathSafe(targetDir, fullPath)) {
661
+ continue; // Skip files that would escape the directory
662
+ }
663
+
664
+ // Create parent directories if needed
665
+ const parentDir = dirname(fullPath);
666
+ if (parentDir !== targetDir) {
667
+ await mkdir(parentDir, { recursive: true });
668
+ }
669
+
670
+ await writeFile(fullPath, content);
671
+ }
672
+ }
673
+
674
+ try {
675
+ // For copy mode, write directly to agent location
676
+ if (installMode === 'copy') {
677
+ await cleanAndCreateDirectory(agentDir);
678
+ await writeSkillFiles(agentDir);
679
+
680
+ return {
681
+ success: true,
682
+ path: agentDir,
683
+ mode: 'copy',
684
+ };
685
+ }
686
+
687
+ // Symlink mode: write to canonical location and symlink to agent location
688
+ await cleanAndCreateDirectory(canonicalDir);
689
+ await writeSkillFiles(canonicalDir);
690
+
691
+ // For universal agents with global install, skip creating agent-specific symlink
692
+ if (isGlobal && isUniversalAgent(agentType)) {
693
+ return {
694
+ success: true,
695
+ path: canonicalDir,
696
+ canonicalPath: canonicalDir,
697
+ mode: 'symlink',
698
+ };
699
+ }
700
+
701
+ const symlinkCreated = await createSymlink(canonicalDir, agentDir);
702
+
703
+ if (!symlinkCreated) {
704
+ // Symlink failed, fall back to copy
705
+ await cleanAndCreateDirectory(agentDir);
706
+ await writeSkillFiles(agentDir);
707
+
708
+ return {
709
+ success: true,
710
+ path: agentDir,
711
+ canonicalPath: canonicalDir,
712
+ mode: 'symlink',
713
+ symlinkFailed: true,
714
+ };
715
+ }
716
+
717
+ return {
718
+ success: true,
719
+ path: agentDir,
720
+ canonicalPath: canonicalDir,
721
+ mode: 'symlink',
722
+ };
723
+ } catch (error) {
724
+ return {
725
+ success: false,
726
+ path: agentDir,
727
+ mode: installMode,
728
+ error: error instanceof Error ? error.message : 'Unknown error',
729
+ };
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Install a blob-downloaded skill (fetched from skills.sh download API).
735
+ * Similar to installWellKnownSkillForAgent but takes the snapshot file format
736
+ * (array of { path, contents }) instead of a Map.
737
+ */
738
+ export async function installBlobSkillForAgent(
739
+ skill: { installName: string; files: Array<{ path: string; contents: string }> },
740
+ agentType: AgentType,
741
+ options: { global?: boolean; cwd?: string; mode?: InstallMode } = {}
742
+ ): Promise<InstallResult> {
743
+ const agent = agents[agentType];
744
+ const isGlobal = options.global ?? false;
745
+ const cwd = options.cwd || process.cwd();
746
+ const installMode = options.mode ?? 'symlink';
747
+
748
+ if (isGlobal && agent.globalSkillsDir === undefined) {
749
+ return {
750
+ success: false,
751
+ path: '',
752
+ mode: installMode,
753
+ error: `${agent.displayName} does not support global skill installation`,
754
+ };
755
+ }
756
+
757
+ const skillName = sanitizeName(skill.installName);
758
+ const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd);
759
+ const canonicalDir = join(canonicalBase, skillName);
760
+ const agentBase = getAgentBaseDir(agentType, isGlobal, cwd);
761
+ const agentDir = join(agentBase, skillName);
762
+
763
+ if (!isPathSafe(canonicalBase, canonicalDir)) {
764
+ return {
765
+ success: false,
766
+ path: agentDir,
767
+ mode: installMode,
768
+ error: 'Invalid skill name: potential path traversal detected',
769
+ };
770
+ }
771
+
772
+ if (!isPathSafe(agentBase, agentDir)) {
773
+ return {
774
+ success: false,
775
+ path: agentDir,
776
+ mode: installMode,
777
+ error: 'Invalid skill name: potential path traversal detected',
778
+ };
779
+ }
780
+
781
+ async function writeSkillFiles(targetDir: string): Promise<void> {
782
+ for (const file of skill.files) {
783
+ const fullPath = join(targetDir, file.path);
784
+ if (!isPathSafe(targetDir, fullPath)) continue;
785
+
786
+ const parentDir = dirname(fullPath);
787
+ if (parentDir !== targetDir) {
788
+ await mkdir(parentDir, { recursive: true });
789
+ }
790
+
791
+ await writeFile(fullPath, file.contents, 'utf-8');
792
+ }
793
+ }
794
+
795
+ try {
796
+ if (installMode === 'copy') {
797
+ await cleanAndCreateDirectory(agentDir);
798
+ await writeSkillFiles(agentDir);
799
+ return { success: true, path: agentDir, mode: 'copy' };
800
+ }
801
+
802
+ // Symlink mode
803
+ await cleanAndCreateDirectory(canonicalDir);
804
+ await writeSkillFiles(canonicalDir);
805
+
806
+ if (isGlobal && isUniversalAgent(agentType)) {
807
+ return {
808
+ success: true,
809
+ path: canonicalDir,
810
+ canonicalPath: canonicalDir,
811
+ mode: 'symlink',
812
+ };
813
+ }
814
+
815
+ // For project-level installs, skip creating symlinks for non-universal agents
816
+ // whose config directory doesn't already exist in the project.
817
+ if (!isGlobal && !isUniversalAgent(agentType)) {
818
+ const agentRootDir = join(cwd, agents[agentType].skillsDir.split('/')[0]!);
819
+ if (!existsSync(agentRootDir)) {
820
+ return {
821
+ success: true,
822
+ path: canonicalDir,
823
+ canonicalPath: canonicalDir,
824
+ mode: 'symlink',
825
+ skipped: true,
826
+ };
827
+ }
828
+ }
829
+
830
+ const symlinkCreated = await createSymlink(canonicalDir, agentDir);
831
+
832
+ if (!symlinkCreated) {
833
+ await cleanAndCreateDirectory(agentDir);
834
+ await writeSkillFiles(agentDir);
835
+ return {
836
+ success: true,
837
+ path: agentDir,
838
+ canonicalPath: canonicalDir,
839
+ mode: 'symlink',
840
+ symlinkFailed: true,
841
+ };
842
+ }
843
+
844
+ return {
845
+ success: true,
846
+ path: agentDir,
847
+ canonicalPath: canonicalDir,
848
+ mode: 'symlink',
849
+ };
850
+ } catch (error) {
851
+ return {
852
+ success: false,
853
+ path: agentDir,
854
+ mode: installMode,
855
+ error: error instanceof Error ? error.message : 'Unknown error',
856
+ };
857
+ }
858
+ }
859
+
860
+ export interface InstalledSkill {
861
+ name: string;
862
+ description: string;
863
+ path: string;
864
+ canonicalPath: string;
865
+ scope: 'project' | 'global';
866
+ agents: AgentType[];
867
+ }
868
+
869
+ /**
870
+ * Lists all installed skills from canonical locations
871
+ * @param options - Options for listing skills
872
+ * @returns Array of installed skills with metadata
873
+ */
874
+ export async function listInstalledSkills(
875
+ options: {
876
+ global?: boolean;
877
+ cwd?: string;
878
+ agentFilter?: AgentType[];
879
+ } = {}
880
+ ): Promise<InstalledSkill[]> {
881
+ const cwd = options.cwd || process.cwd();
882
+ // Use a Map to deduplicate skills by scope:name
883
+ const skillsMap: Map<string, InstalledSkill> = new Map();
884
+ const scopes: Array<{ global: boolean; path: string; agentType?: AgentType }> = [];
885
+
886
+ // Detect which agents are actually installed
887
+ const detectedAgents = await detectInstalledAgents();
888
+ const agentFilter = options.agentFilter;
889
+ const agentsToCheck = agentFilter
890
+ ? detectedAgents.filter((a) => agentFilter.includes(a))
891
+ : detectedAgents;
892
+
893
+ // Determine which scopes to scan
894
+ const scopeTypes: Array<{ global: boolean }> = [];
895
+ if (options.global === undefined) {
896
+ scopeTypes.push({ global: false }, { global: true });
897
+ } else {
898
+ scopeTypes.push({ global: options.global });
899
+ }
900
+
901
+ // Build list of directories to scan: canonical + each installed agent's directory
902
+ //
903
+ // Scanning workflow:
904
+ //
905
+ // detectInstalledAgents()
906
+ // │
907
+ // ▼
908
+ // for each scope (project / global)
909
+ // │
910
+ // ├──▶ scan canonical dir ──▶ .agents/skills, ~/.agents/skills
911
+ // │
912
+ // ├──▶ scan each installed agent's dir ──▶ .cursor/skills, .claude/skills, ...
913
+ // │
914
+ // ▼
915
+ // deduplicate by skill name
916
+ //
917
+ // Trade-off: More readdir() calls, but most non-existent dirs fail fast.
918
+ // Skills in agent-specific dirs skip the expensive "check all agents" loop.
919
+ //
920
+ for (const { global: isGlobal } of scopeTypes) {
921
+ // Add canonical directory
922
+ scopes.push({ global: isGlobal, path: getCanonicalSkillsDir(isGlobal, cwd) });
923
+
924
+ // Add each installed agent's skills directory
925
+ for (const agentType of agentsToCheck) {
926
+ const agent = agents[agentType];
927
+ if (isGlobal && agent.globalSkillsDir === undefined) {
928
+ continue;
929
+ }
930
+ const agentDir = isGlobal ? agent.globalSkillsDir! : join(cwd, agent.skillsDir);
931
+ // Avoid duplicate paths
932
+ if (!scopes.some((s) => s.path === agentDir && s.global === isGlobal)) {
933
+ scopes.push({ global: isGlobal, path: agentDir, agentType });
934
+ }
935
+ }
936
+
937
+ // Also scan skill directories for agents NOT in agentsToCheck, in case
938
+ // skills were installed with `--agent <name>` but the agent is no longer
939
+ // detected (e.g. ~/.openclaw was removed). Only add dirs that actually
940
+ // exist on disk to avoid unnecessary readdir errors.
941
+ const allAgentTypes = Object.keys(agents) as AgentType[];
942
+ for (const agentType of allAgentTypes) {
943
+ if (agentsToCheck.includes(agentType)) continue;
944
+ const agent = agents[agentType];
945
+ if (isGlobal && agent.globalSkillsDir === undefined) continue;
946
+ const agentDir = isGlobal ? agent.globalSkillsDir! : join(cwd, agent.skillsDir);
947
+ if (scopes.some((s) => s.path === agentDir && s.global === isGlobal)) continue;
948
+ if (existsSync(agentDir)) {
949
+ scopes.push({ global: isGlobal, path: agentDir, agentType });
950
+ }
951
+ }
952
+ }
953
+
954
+ for (const scope of scopes) {
955
+ try {
956
+ const entries = await readdir(scope.path, { withFileTypes: true });
957
+
958
+ for (const entry of entries) {
959
+ const skillDir = join(scope.path, entry.name);
960
+ if (!(await isDirEntryOrSymlinkToDir(entry, skillDir))) continue;
961
+
962
+ const skillMdPath = join(skillDir, 'SKILL.md');
963
+
964
+ // Check if SKILL.md exists
965
+ try {
966
+ await stat(skillMdPath);
967
+ } catch {
968
+ // SKILL.md doesn't exist, skip this directory
969
+ continue;
970
+ }
971
+
972
+ // Parse the skill
973
+ const skill = await parseSkillMd(skillMdPath);
974
+ if (!skill) {
975
+ continue;
976
+ }
977
+
978
+ const scopeKey = scope.global ? 'global' : 'project';
979
+ const skillKey = `${scopeKey}:${skill.name}`;
980
+
981
+ // If scanning an agent-specific directory, attribute directly to that agent
982
+ if (scope.agentType) {
983
+ if (skillsMap.has(skillKey)) {
984
+ const existing = skillsMap.get(skillKey)!;
985
+ if (!existing.agents.includes(scope.agentType)) {
986
+ existing.agents.push(scope.agentType);
987
+ }
988
+ } else {
989
+ skillsMap.set(skillKey, {
990
+ name: skill.name,
991
+ description: skill.description,
992
+ path: skillDir,
993
+ canonicalPath: skillDir,
994
+ scope: scopeKey,
995
+ agents: [scope.agentType],
996
+ });
997
+ }
998
+ continue;
999
+ }
1000
+
1001
+ // For canonical directory, check which agents have this skill
1002
+ const sanitizedSkillName = sanitizeName(skill.name);
1003
+ const installedAgents: AgentType[] = [];
1004
+
1005
+ for (const agentType of agentsToCheck) {
1006
+ const agent = agents[agentType];
1007
+
1008
+ if (scope.global && agent.globalSkillsDir === undefined) {
1009
+ continue;
1010
+ }
1011
+
1012
+ const agentBase = getAgentBaseDir(agentType, scope.global, cwd);
1013
+ let found = false;
1014
+
1015
+ // Try exact directory name matches
1016
+ const possibleNames = Array.from(
1017
+ new Set([
1018
+ entry.name,
1019
+ sanitizedSkillName,
1020
+ skill.name
1021
+ .toLowerCase()
1022
+ .replace(/\s+/g, '-')
1023
+ .replace(/[\/\\:\0]/g, ''),
1024
+ ])
1025
+ );
1026
+
1027
+ for (const possibleName of possibleNames) {
1028
+ const agentSkillDir = join(agentBase, possibleName);
1029
+ if (!isPathSafe(agentBase, agentSkillDir)) continue;
1030
+
1031
+ try {
1032
+ await access(agentSkillDir);
1033
+ found = true;
1034
+ break;
1035
+ } catch {
1036
+ // Try next name
1037
+ }
1038
+ }
1039
+
1040
+ // Fallback: scan all directories and check SKILL.md files
1041
+ // Handles cases where directory names don't match (e.g., "git-review" vs "Git Review Before Commit")
1042
+ if (!found) {
1043
+ try {
1044
+ const agentEntries = await readdir(agentBase, { withFileTypes: true });
1045
+ for (const agentEntry of agentEntries) {
1046
+ const candidateDir = join(agentBase, agentEntry.name);
1047
+ if (!(await isDirEntryOrSymlinkToDir(agentEntry, candidateDir))) continue;
1048
+ if (!isPathSafe(agentBase, candidateDir)) continue;
1049
+
1050
+ try {
1051
+ const candidateSkillMd = join(candidateDir, 'SKILL.md');
1052
+ await stat(candidateSkillMd);
1053
+ const candidateSkill = await parseSkillMd(candidateSkillMd);
1054
+ if (candidateSkill && candidateSkill.name === skill.name) {
1055
+ found = true;
1056
+ break;
1057
+ }
1058
+ } catch {
1059
+ // Not a valid skill directory
1060
+ }
1061
+ }
1062
+ } catch {
1063
+ // Agent base directory doesn't exist
1064
+ }
1065
+ }
1066
+
1067
+ if (found) {
1068
+ installedAgents.push(agentType);
1069
+ }
1070
+ }
1071
+
1072
+ if (skillsMap.has(skillKey)) {
1073
+ // Merge agents
1074
+ const existing = skillsMap.get(skillKey)!;
1075
+ for (const agent of installedAgents) {
1076
+ if (!existing.agents.includes(agent)) {
1077
+ existing.agents.push(agent);
1078
+ }
1079
+ }
1080
+ } else {
1081
+ skillsMap.set(skillKey, {
1082
+ name: skill.name,
1083
+ description: skill.description,
1084
+ path: skillDir,
1085
+ canonicalPath: skillDir,
1086
+ scope: scopeKey,
1087
+ agents: installedAgents,
1088
+ });
1089
+ }
1090
+ }
1091
+ } catch {
1092
+ // Directory doesn't exist, skip
1093
+ }
1094
+ }
1095
+
1096
+ return Array.from(skillsMap.values());
1097
+ }