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