@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,264 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'fs/promises';
|
|
2
|
+
import { join, basename, dirname, resolve, normalize, sep, relative } from 'path';
|
|
3
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
4
|
+
import { sanitizeMetadata } from "./sanitize.js";
|
|
5
|
+
import { getPluginSkillPaths, getPluginGroupings } from "./plugin-manifest.js";
|
|
6
|
+
import { readLocalLock } from "./local-lock.js";
|
|
7
|
+
const SKIP_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__'];
|
|
8
|
+
const AGENT_PROJECT_SKILL_DIRS = [
|
|
9
|
+
'.agents/skills',
|
|
10
|
+
'.claude/skills',
|
|
11
|
+
'.cline/skills',
|
|
12
|
+
'.codebuddy/skills',
|
|
13
|
+
'.codex/skills',
|
|
14
|
+
'.commandcode/skills',
|
|
15
|
+
'.continue/skills',
|
|
16
|
+
'.github/skills',
|
|
17
|
+
'.goose/skills',
|
|
18
|
+
'.iflow/skills',
|
|
19
|
+
'.junie/skills',
|
|
20
|
+
'.kilocode/skills',
|
|
21
|
+
'.kiro/skills',
|
|
22
|
+
'.mux/skills',
|
|
23
|
+
'.neovate/skills',
|
|
24
|
+
'.opencode/skills',
|
|
25
|
+
'.openhands/skills',
|
|
26
|
+
'.pi/skills',
|
|
27
|
+
'.qoder/skills',
|
|
28
|
+
'.roo/skills',
|
|
29
|
+
'.trae/skills',
|
|
30
|
+
'.windsurf/skills',
|
|
31
|
+
'.zencoder/skills',
|
|
32
|
+
];
|
|
33
|
+
function normalizeSkillName(name) {
|
|
34
|
+
return name.toLowerCase().replace(/[\s_]+/g, '-');
|
|
35
|
+
}
|
|
36
|
+
function normalizeRelativePath(path) {
|
|
37
|
+
return path.split(sep).join('/').replace(/\/+/g, '/');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Check if internal skills should be installed.
|
|
41
|
+
* Internal skills are hidden by default unless INSTALL_INTERNAL_SKILLS=1 is set.
|
|
42
|
+
*/
|
|
43
|
+
export function shouldInstallInternalSkills() {
|
|
44
|
+
const envValue = process.env.INSTALL_INTERNAL_SKILLS;
|
|
45
|
+
return envValue === '1' || envValue === 'true';
|
|
46
|
+
}
|
|
47
|
+
async function hasSkillMd(dir) {
|
|
48
|
+
try {
|
|
49
|
+
const skillPath = join(dir, 'SKILL.md');
|
|
50
|
+
const stats = await stat(skillPath);
|
|
51
|
+
return stats.isFile();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function parseSkillMd(skillMdPath, options) {
|
|
58
|
+
try {
|
|
59
|
+
const content = await readFile(skillMdPath, 'utf-8');
|
|
60
|
+
const { data } = parseFrontmatter(content);
|
|
61
|
+
if (!data.name || !data.description) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
// Ensure name and description are strings (YAML can parse numbers, booleans, etc.)
|
|
65
|
+
if (typeof data.name !== 'string' || typeof data.description !== 'string') {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
// ADG patch: narrow `metadata` from the loosely-typed frontmatter (`unknown`)
|
|
69
|
+
// before reading it, so the file typechecks under the root tsconfig without
|
|
70
|
+
// changing behavior. See vendor/skills/PROVENANCE.md.
|
|
71
|
+
const metadata = data.metadata && typeof data.metadata === 'object'
|
|
72
|
+
? data.metadata
|
|
73
|
+
: undefined;
|
|
74
|
+
// Skip internal skills unless:
|
|
75
|
+
// 1. INSTALL_INTERNAL_SKILLS=1 is set, OR
|
|
76
|
+
// 2. includeInternal option is true (e.g., when user explicitly requests a skill)
|
|
77
|
+
const isInternal = metadata?.internal === true;
|
|
78
|
+
if (isInternal && !shouldInstallInternalSkills() && !options?.includeInternal) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
name: sanitizeMetadata(data.name),
|
|
83
|
+
description: sanitizeMetadata(data.description),
|
|
84
|
+
path: dirname(skillMdPath),
|
|
85
|
+
rawContent: content,
|
|
86
|
+
metadata,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function findSkillDirs(dir, depth = 0, maxDepth = 5) {
|
|
94
|
+
if (depth > maxDepth)
|
|
95
|
+
return [];
|
|
96
|
+
try {
|
|
97
|
+
const [hasSkill, entries] = await Promise.all([
|
|
98
|
+
hasSkillMd(dir),
|
|
99
|
+
readdir(dir, { withFileTypes: true }).catch(() => []),
|
|
100
|
+
]);
|
|
101
|
+
const currentDir = hasSkill ? [dir] : [];
|
|
102
|
+
// Search subdirectories in parallel
|
|
103
|
+
const subDirResults = await Promise.all(entries
|
|
104
|
+
.filter((entry) => entry.isDirectory() && !SKIP_DIRS.includes(entry.name))
|
|
105
|
+
.map((entry) => findSkillDirs(join(dir, entry.name), depth + 1, maxDepth)));
|
|
106
|
+
return [...currentDir, ...subDirResults.flat()];
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Validates that a resolved subpath stays within the base directory.
|
|
114
|
+
* Prevents path traversal attacks where subpath contains ".." segments
|
|
115
|
+
* that would escape the cloned repository directory.
|
|
116
|
+
*/
|
|
117
|
+
export function isSubpathSafe(basePath, subpath) {
|
|
118
|
+
const normalizedBase = normalize(resolve(basePath));
|
|
119
|
+
const normalizedTarget = normalize(resolve(join(basePath, subpath)));
|
|
120
|
+
return normalizedTarget.startsWith(normalizedBase + sep) || normalizedTarget === normalizedBase;
|
|
121
|
+
}
|
|
122
|
+
export async function discoverSkills(basePath, subpath, options) {
|
|
123
|
+
const skills = [];
|
|
124
|
+
const seenNames = new Set();
|
|
125
|
+
const localLock = await readLocalLock(basePath);
|
|
126
|
+
const lockedSkillNames = new Set(Object.keys(localLock.skills).map(normalizeSkillName));
|
|
127
|
+
// Validate subpath doesn't escape basePath (prevent path traversal)
|
|
128
|
+
if (subpath && !isSubpathSafe(basePath, subpath)) {
|
|
129
|
+
throw new Error(`Invalid subpath: "${subpath}" resolves outside the repository directory. Subpath must not contain ".." segments that escape the base path.`);
|
|
130
|
+
}
|
|
131
|
+
const searchPath = subpath ? join(basePath, subpath) : basePath;
|
|
132
|
+
// Get plugin groupings to map skills to their parent plugin
|
|
133
|
+
// We search for plugin definitions from the base search path
|
|
134
|
+
const pluginGroupings = await getPluginGroupings(searchPath);
|
|
135
|
+
// Helper to assign plugin name if available
|
|
136
|
+
const enhanceSkill = (skill) => {
|
|
137
|
+
const resolvedPath = resolve(skill.path);
|
|
138
|
+
if (pluginGroupings.has(resolvedPath)) {
|
|
139
|
+
skill.pluginName = pluginGroupings.get(resolvedPath);
|
|
140
|
+
}
|
|
141
|
+
return skill;
|
|
142
|
+
};
|
|
143
|
+
const isInstalledProjectSkill = (skill) => {
|
|
144
|
+
if (lockedSkillNames.size === 0)
|
|
145
|
+
return false;
|
|
146
|
+
const relativeDir = normalizeRelativePath(relative(basePath, skill.path));
|
|
147
|
+
const isAgentSkillPath = AGENT_PROJECT_SKILL_DIRS.some((dir) => relativeDir === dir || relativeDir.startsWith(`${dir}/`));
|
|
148
|
+
if (!isAgentSkillPath)
|
|
149
|
+
return false;
|
|
150
|
+
const skillName = normalizeSkillName(skill.name);
|
|
151
|
+
const directoryName = normalizeSkillName(basename(skill.path));
|
|
152
|
+
return lockedSkillNames.has(skillName) || lockedSkillNames.has(directoryName);
|
|
153
|
+
};
|
|
154
|
+
// If pointing directly at a skill, add it (and return early unless fullDepth is set).
|
|
155
|
+
// If the root SKILL.md is an installed project skill tracked by skills-lock.json,
|
|
156
|
+
// ignore it and continue scanning in case the repo also contains source skills.
|
|
157
|
+
if (await hasSkillMd(searchPath)) {
|
|
158
|
+
let skill = await parseSkillMd(join(searchPath, 'SKILL.md'), options);
|
|
159
|
+
if (skill) {
|
|
160
|
+
if (!isInstalledProjectSkill(skill)) {
|
|
161
|
+
skill = enhanceSkill(skill);
|
|
162
|
+
skills.push(skill);
|
|
163
|
+
seenNames.add(skill.name);
|
|
164
|
+
// Only return early if fullDepth is not set
|
|
165
|
+
if (!options?.fullDepth) {
|
|
166
|
+
return skills;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Search common skill locations first
|
|
172
|
+
const prioritySearchDirs = [
|
|
173
|
+
searchPath,
|
|
174
|
+
join(searchPath, 'skills'),
|
|
175
|
+
join(searchPath, 'skills/.curated'),
|
|
176
|
+
join(searchPath, 'skills/.experimental'),
|
|
177
|
+
join(searchPath, 'skills/.system'),
|
|
178
|
+
...AGENT_PROJECT_SKILL_DIRS.map((dir) => join(searchPath, dir)),
|
|
179
|
+
];
|
|
180
|
+
// Known skill container dirs are walked one extra level deep so layouts
|
|
181
|
+
// like `skills/<category>/<skill>/SKILL.md` are discovered without
|
|
182
|
+
// requiring `--full-depth`. The repo root (first entry) keeps its
|
|
183
|
+
// existing depth-1 behavior to avoid surfacing unrelated `SKILL.md`
|
|
184
|
+
// files (e.g. `examples/foo/SKILL.md`), and plugin-manifest-declared
|
|
185
|
+
// dirs (appended below) stay at depth-1 to honor the manifest spec.
|
|
186
|
+
const deepContainerDirs = new Set(prioritySearchDirs.slice(1));
|
|
187
|
+
// Add skill paths declared in plugin manifests
|
|
188
|
+
prioritySearchDirs.push(...(await getPluginSkillPaths(searchPath)));
|
|
189
|
+
const tryAddSkillAt = async (skillDir) => {
|
|
190
|
+
if (!(await hasSkillMd(skillDir)))
|
|
191
|
+
return false;
|
|
192
|
+
let skill = await parseSkillMd(join(skillDir, 'SKILL.md'), options);
|
|
193
|
+
if (!skill || seenNames.has(skill.name))
|
|
194
|
+
return true;
|
|
195
|
+
if (isInstalledProjectSkill(skill))
|
|
196
|
+
return true;
|
|
197
|
+
skill = enhanceSkill(skill);
|
|
198
|
+
skills.push(skill);
|
|
199
|
+
seenNames.add(skill.name);
|
|
200
|
+
return true;
|
|
201
|
+
};
|
|
202
|
+
for (const dir of prioritySearchDirs) {
|
|
203
|
+
const walkDeep = deepContainerDirs.has(dir);
|
|
204
|
+
try {
|
|
205
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
if (!entry.isDirectory())
|
|
208
|
+
continue;
|
|
209
|
+
const childDir = join(dir, entry.name);
|
|
210
|
+
const foundAtChild = await tryAddSkillAt(childDir);
|
|
211
|
+
// Don't descend past a discovered SKILL.md (matches the existing
|
|
212
|
+
// flat-layout semantics) and don't go deeper inside non-container
|
|
213
|
+
// priority dirs.
|
|
214
|
+
if (foundAtChild || !walkDeep)
|
|
215
|
+
continue;
|
|
216
|
+
if (SKIP_DIRS.includes(entry.name))
|
|
217
|
+
continue;
|
|
218
|
+
// Walk one extra level for catalog layouts.
|
|
219
|
+
try {
|
|
220
|
+
const grandEntries = await readdir(childDir, { withFileTypes: true });
|
|
221
|
+
for (const grand of grandEntries) {
|
|
222
|
+
if (!grand.isDirectory() || SKIP_DIRS.includes(grand.name))
|
|
223
|
+
continue;
|
|
224
|
+
await tryAddSkillAt(join(childDir, grand.name));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Child dir unreadable; skip silently.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Directory doesn't exist
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Fall back to recursive search if nothing found, or if fullDepth is set
|
|
237
|
+
if (skills.length === 0 || options?.fullDepth) {
|
|
238
|
+
const allSkillDirs = await findSkillDirs(searchPath);
|
|
239
|
+
for (const skillDir of allSkillDirs) {
|
|
240
|
+
let skill = await parseSkillMd(join(skillDir, 'SKILL.md'), options);
|
|
241
|
+
if (skill && !seenNames.has(skill.name) && !isInstalledProjectSkill(skill)) {
|
|
242
|
+
skill = enhanceSkill(skill);
|
|
243
|
+
skills.push(skill);
|
|
244
|
+
seenNames.add(skill.name);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return skills;
|
|
249
|
+
}
|
|
250
|
+
export function getSkillDisplayName(skill) {
|
|
251
|
+
return skill.name || basename(skill.path);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Filter skills based on user input (case-insensitive direct matching).
|
|
255
|
+
* Multi-word skill names must be quoted on the command line.
|
|
256
|
+
*/
|
|
257
|
+
export function filterSkills(skills, inputNames) {
|
|
258
|
+
const normalizedInputs = inputNames.map((n) => n.toLowerCase());
|
|
259
|
+
return skills.filter((skill) => {
|
|
260
|
+
const name = skill.name.toLowerCase();
|
|
261
|
+
const displayName = getSkillDisplayName(skill).toLowerCase();
|
|
262
|
+
return normalizedInputs.some((input) => input === name || input === displayName);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'path';
|
|
2
|
+
/**
|
|
3
|
+
* Extract owner/repo (or group/subgroup/repo for GitLab) from a parsed source
|
|
4
|
+
* for lockfile tracking and telemetry.
|
|
5
|
+
* Returns null for local paths or unparseable sources.
|
|
6
|
+
* Supports any Git host with an owner/repo URL structure, including GitLab subgroups.
|
|
7
|
+
*/
|
|
8
|
+
export function getOwnerRepo(parsed) {
|
|
9
|
+
if (parsed.type === 'local') {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
// Handle Git SSH URLs (e.g., git@gitlab.com:owner/repo.git, git@github.com:owner/repo.git)
|
|
13
|
+
const sshMatch = parsed.url.match(/^git@[^:]+:(.+)$/);
|
|
14
|
+
if (sshMatch) {
|
|
15
|
+
let path = sshMatch[1];
|
|
16
|
+
path = path.replace(/\.git$/, '');
|
|
17
|
+
// Must have at least owner/repo (one slash)
|
|
18
|
+
if (path.includes('/')) {
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
// Handle SSH URLs with a scheme (e.g., ssh://git@host:7999/owner/repo.git)
|
|
24
|
+
if (parsed.url.startsWith('ssh://')) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(parsed.url);
|
|
27
|
+
let path = url.pathname.slice(1);
|
|
28
|
+
path = path.replace(/\.git$/, '');
|
|
29
|
+
if (path.includes('/')) {
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Handle HTTP(S) URLs
|
|
39
|
+
if (!parsed.url.startsWith('http://') && !parsed.url.startsWith('https://')) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(parsed.url);
|
|
44
|
+
// Get pathname, remove leading slash and trailing .git
|
|
45
|
+
let path = url.pathname.slice(1);
|
|
46
|
+
path = path.replace(/\.git$/, '');
|
|
47
|
+
// Must have at least owner/repo (one slash)
|
|
48
|
+
if (path.includes('/')) {
|
|
49
|
+
return path;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Invalid URL
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Extract owner and repo from an owner/repo string.
|
|
59
|
+
* Returns null if the format is invalid.
|
|
60
|
+
*/
|
|
61
|
+
export function parseOwnerRepo(ownerRepo) {
|
|
62
|
+
const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/);
|
|
63
|
+
if (match) {
|
|
64
|
+
return { owner: match[1], repo: match[2] };
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if a GitHub repository is private.
|
|
70
|
+
* Returns true if private, false if public, null if unable to determine.
|
|
71
|
+
* Only works for GitHub repositories (GitLab not supported).
|
|
72
|
+
*/
|
|
73
|
+
export async function isRepoPrivate(owner, repo) {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
|
|
76
|
+
// If repo doesn't exist or we don't have access, assume private to be safe
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
return null; // Unable to determine
|
|
79
|
+
}
|
|
80
|
+
const data = (await res.json());
|
|
81
|
+
return data.private === true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// On error, return null to indicate we couldn't determine
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Sanitizes a subpath to prevent path traversal attacks.
|
|
90
|
+
* Rejects subpaths containing ".." segments that could escape the repository root.
|
|
91
|
+
* Returns the sanitized subpath, or throws if the subpath is unsafe.
|
|
92
|
+
*/
|
|
93
|
+
export function sanitizeSubpath(subpath) {
|
|
94
|
+
// Normalize to forward slashes for consistent handling
|
|
95
|
+
const normalized = subpath.replace(/\\/g, '/');
|
|
96
|
+
// Check each segment for ".."
|
|
97
|
+
const segments = normalized.split('/');
|
|
98
|
+
for (const segment of segments) {
|
|
99
|
+
if (segment === '..') {
|
|
100
|
+
throw new Error(`Unsafe subpath: "${subpath}" contains path traversal segments. ` +
|
|
101
|
+
`Subpaths must not contain ".." components.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return subpath;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Check if a string represents a local file system path
|
|
108
|
+
*/
|
|
109
|
+
function isLocalPath(input) {
|
|
110
|
+
return (isAbsolute(input) ||
|
|
111
|
+
input.startsWith('./') ||
|
|
112
|
+
input.startsWith('../') ||
|
|
113
|
+
input === '.' ||
|
|
114
|
+
input === '..' ||
|
|
115
|
+
// Windows absolute paths like C:\ or D:\
|
|
116
|
+
/^[a-zA-Z]:[/\\]/.test(input));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Parse a source string into a structured format
|
|
120
|
+
* Supports: local paths, GitHub URLs, GitLab URLs, GitHub shorthand, well-known URLs, and direct git URLs
|
|
121
|
+
*/
|
|
122
|
+
// Source aliases: map common shorthand to canonical source
|
|
123
|
+
const SOURCE_ALIASES = {
|
|
124
|
+
'coinbase/agentWallet': 'coinbase/agentic-wallet-skills',
|
|
125
|
+
};
|
|
126
|
+
function decodeFragmentValue(value) {
|
|
127
|
+
try {
|
|
128
|
+
return decodeURIComponent(value);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function looksLikeGitSource(input) {
|
|
135
|
+
if (input.startsWith('github:') || input.startsWith('gitlab:') || input.startsWith('git@')) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
if (/^ssh:\/\/.+\.git(?:$|[/?])/i.test(input)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
if (input.startsWith('http://') || input.startsWith('https://')) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = new URL(input);
|
|
144
|
+
const pathname = parsed.pathname;
|
|
145
|
+
// Only treat GitHub fragments as refs for repo/tree URLs.
|
|
146
|
+
if (parsed.hostname === 'github.com') {
|
|
147
|
+
return /^\/[^/]+\/[^/]+(?:\.git)?(?:\/tree\/[^/]+(?:\/.*)?)?\/?$/.test(pathname);
|
|
148
|
+
}
|
|
149
|
+
// Only treat gitlab.com fragments as refs for repo/tree URLs.
|
|
150
|
+
if (parsed.hostname === 'gitlab.com') {
|
|
151
|
+
return /^\/.+?\/[^/]+(?:\.git)?(?:\/-\/tree\/[^/]+(?:\/.*)?)?\/?$/.test(pathname);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Fall through to generic checks below.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (/^https?:\/\/.+\.git(?:$|[/?])/i.test(input)) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return (!input.includes(':') &&
|
|
162
|
+
!input.startsWith('.') &&
|
|
163
|
+
!input.startsWith('/') &&
|
|
164
|
+
/^([^/]+)\/([^/]+)(?:\/(.+)|@(.+))?$/.test(input));
|
|
165
|
+
}
|
|
166
|
+
function parseFragmentRef(input) {
|
|
167
|
+
const hashIndex = input.indexOf('#');
|
|
168
|
+
if (hashIndex < 0) {
|
|
169
|
+
return { inputWithoutFragment: input };
|
|
170
|
+
}
|
|
171
|
+
const inputWithoutFragment = input.slice(0, hashIndex);
|
|
172
|
+
const fragment = input.slice(hashIndex + 1);
|
|
173
|
+
// Treat URL fragments as git refs only for git-like sources.
|
|
174
|
+
// This avoids changing behavior for generic well-known URLs.
|
|
175
|
+
if (!fragment || !looksLikeGitSource(inputWithoutFragment)) {
|
|
176
|
+
return { inputWithoutFragment: input };
|
|
177
|
+
}
|
|
178
|
+
const atIndex = fragment.indexOf('@');
|
|
179
|
+
if (atIndex === -1) {
|
|
180
|
+
return {
|
|
181
|
+
inputWithoutFragment,
|
|
182
|
+
ref: decodeFragmentValue(fragment),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const ref = fragment.slice(0, atIndex);
|
|
186
|
+
const skillFilter = fragment.slice(atIndex + 1);
|
|
187
|
+
return {
|
|
188
|
+
inputWithoutFragment,
|
|
189
|
+
ref: ref ? decodeFragmentValue(ref) : undefined,
|
|
190
|
+
skillFilter: skillFilter ? decodeFragmentValue(skillFilter) : undefined,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function appendFragmentRef(input, ref, skillFilter) {
|
|
194
|
+
if (!ref) {
|
|
195
|
+
return input;
|
|
196
|
+
}
|
|
197
|
+
return `${input}#${ref}${skillFilter ? `@${skillFilter}` : ''}`;
|
|
198
|
+
}
|
|
199
|
+
export function parseSource(input) {
|
|
200
|
+
// Local path: absolute, relative, or current directory
|
|
201
|
+
if (isLocalPath(input)) {
|
|
202
|
+
const resolvedPath = resolve(input);
|
|
203
|
+
// Return local type even if path doesn't exist - we'll handle validation in main flow
|
|
204
|
+
return {
|
|
205
|
+
type: 'local',
|
|
206
|
+
url: resolvedPath, // Store resolved path in url for consistency
|
|
207
|
+
localPath: resolvedPath,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const { inputWithoutFragment, ref: fragmentRef, skillFilter: fragmentSkillFilter, } = parseFragmentRef(input);
|
|
211
|
+
input = inputWithoutFragment;
|
|
212
|
+
// Resolve source aliases before parsing
|
|
213
|
+
const alias = SOURCE_ALIASES[input];
|
|
214
|
+
if (alias) {
|
|
215
|
+
input = alias;
|
|
216
|
+
}
|
|
217
|
+
// Prefix shorthand: github:owner/repo -> owner/repo (handled by existing shorthand logic)
|
|
218
|
+
// Also supports github:owner/repo/subpath and github:owner/repo@skill
|
|
219
|
+
const githubPrefixMatch = input.match(/^github:(.+)$/);
|
|
220
|
+
if (githubPrefixMatch) {
|
|
221
|
+
return parseSource(appendFragmentRef(githubPrefixMatch[1], fragmentRef, fragmentSkillFilter));
|
|
222
|
+
}
|
|
223
|
+
// Prefix shorthand: gitlab:owner/repo -> https://gitlab.com/owner/repo
|
|
224
|
+
const gitlabPrefixMatch = input.match(/^gitlab:(.+)$/);
|
|
225
|
+
if (gitlabPrefixMatch) {
|
|
226
|
+
return parseSource(appendFragmentRef(`https://gitlab.com/${gitlabPrefixMatch[1]}`, fragmentRef, fragmentSkillFilter));
|
|
227
|
+
}
|
|
228
|
+
// GitHub URL with path: https://github.com/owner/repo/tree/branch/path/to/skill
|
|
229
|
+
const githubTreeWithPathMatch = input.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/);
|
|
230
|
+
if (githubTreeWithPathMatch) {
|
|
231
|
+
const [, owner, repo, ref, subpath] = githubTreeWithPathMatch;
|
|
232
|
+
return {
|
|
233
|
+
type: 'github',
|
|
234
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
235
|
+
ref: ref || fragmentRef,
|
|
236
|
+
subpath: subpath ? sanitizeSubpath(subpath) : subpath,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// GitHub URL with branch only: https://github.com/owner/repo/tree/branch
|
|
240
|
+
const githubTreeMatch = input.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)$/);
|
|
241
|
+
if (githubTreeMatch) {
|
|
242
|
+
const [, owner, repo, ref] = githubTreeMatch;
|
|
243
|
+
return {
|
|
244
|
+
type: 'github',
|
|
245
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
246
|
+
ref: ref || fragmentRef,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// GitHub URL: https://github.com/owner/repo
|
|
250
|
+
const githubRepoMatch = input.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
251
|
+
if (githubRepoMatch) {
|
|
252
|
+
const [, owner, repo] = githubRepoMatch;
|
|
253
|
+
const cleanRepo = repo.replace(/\.git$/, '');
|
|
254
|
+
return {
|
|
255
|
+
type: 'github',
|
|
256
|
+
url: `https://github.com/${owner}/${cleanRepo}.git`,
|
|
257
|
+
...(fragmentRef ? { ref: fragmentRef } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// GitLab URL with path (any GitLab instance): https://gitlab.com/owner/repo/-/tree/branch/path
|
|
261
|
+
// Key identifier is the "/-/tree/" path pattern unique to GitLab.
|
|
262
|
+
// Supports subgroups by using a non-greedy match for the repository path.
|
|
263
|
+
const gitlabTreeWithPathMatch = input.match(/^(https?):\/\/([^/]+)\/(.+?)\/-\/tree\/([^/]+)\/(.+)/);
|
|
264
|
+
if (gitlabTreeWithPathMatch) {
|
|
265
|
+
const [, protocol, hostname, repoPath, ref, subpath] = gitlabTreeWithPathMatch;
|
|
266
|
+
if (hostname !== 'github.com' && repoPath) {
|
|
267
|
+
return {
|
|
268
|
+
type: 'gitlab',
|
|
269
|
+
url: `${protocol}://${hostname}/${repoPath.replace(/\.git$/, '')}.git`,
|
|
270
|
+
ref: ref || fragmentRef,
|
|
271
|
+
subpath: subpath ? sanitizeSubpath(subpath) : subpath,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// GitLab URL with branch only (any GitLab instance): https://gitlab.com/owner/repo/-/tree/branch
|
|
276
|
+
const gitlabTreeMatch = input.match(/^(https?):\/\/([^/]+)\/(.+?)\/-\/tree\/([^/]+)$/);
|
|
277
|
+
if (gitlabTreeMatch) {
|
|
278
|
+
const [, protocol, hostname, repoPath, ref] = gitlabTreeMatch;
|
|
279
|
+
if (hostname !== 'github.com' && repoPath) {
|
|
280
|
+
return {
|
|
281
|
+
type: 'gitlab',
|
|
282
|
+
url: `${protocol}://${hostname}/${repoPath.replace(/\.git$/, '')}.git`,
|
|
283
|
+
ref: ref || fragmentRef,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// GitLab.com URL: https://gitlab.com/owner/repo or https://gitlab.com/group/subgroup/repo
|
|
288
|
+
// Only for the official gitlab.com domain for user convenience.
|
|
289
|
+
// Supports nested subgroups (e.g., gitlab.com/group/subgroup1/subgroup2/repo).
|
|
290
|
+
const gitlabRepoMatch = input.match(/gitlab\.com\/(.+?)(?:\.git)?\/?$/);
|
|
291
|
+
if (gitlabRepoMatch) {
|
|
292
|
+
const repoPath = gitlabRepoMatch[1];
|
|
293
|
+
// Must have at least owner/repo (one slash)
|
|
294
|
+
if (repoPath.includes('/')) {
|
|
295
|
+
return {
|
|
296
|
+
type: 'gitlab',
|
|
297
|
+
url: `https://gitlab.com/${repoPath}.git`,
|
|
298
|
+
...(fragmentRef ? { ref: fragmentRef } : {}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// GitHub shorthand: owner/repo, owner/repo/path/to/skill, or owner/repo@skill-name
|
|
303
|
+
// Exclude paths that start with . or / to avoid matching local paths
|
|
304
|
+
// First check for @skill syntax: owner/repo@skill-name
|
|
305
|
+
const atSkillMatch = input.match(/^([^/]+)\/([^/@]+)@(.+)$/);
|
|
306
|
+
if (atSkillMatch && !input.includes(':') && !input.startsWith('.') && !input.startsWith('/')) {
|
|
307
|
+
const [, owner, repo, skillFilter] = atSkillMatch;
|
|
308
|
+
return {
|
|
309
|
+
type: 'github',
|
|
310
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
311
|
+
...(fragmentRef ? { ref: fragmentRef } : {}),
|
|
312
|
+
skillFilter: fragmentSkillFilter || skillFilter,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const shorthandMatch = input.match(/^([^/]+)\/([^/]+)(?:\/(.+?))?\/?$/);
|
|
316
|
+
if (shorthandMatch && !input.includes(':') && !input.startsWith('.') && !input.startsWith('/')) {
|
|
317
|
+
const [, owner, repo, subpath] = shorthandMatch;
|
|
318
|
+
return {
|
|
319
|
+
type: 'github',
|
|
320
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
321
|
+
...(fragmentRef ? { ref: fragmentRef } : {}),
|
|
322
|
+
subpath: subpath ? sanitizeSubpath(subpath) : subpath,
|
|
323
|
+
...(fragmentSkillFilter ? { skillFilter: fragmentSkillFilter } : {}),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// Well-known skills: arbitrary HTTP(S) URLs that aren't GitHub/GitLab
|
|
327
|
+
// This is the final fallback for URLs - we'll check for /.well-known/agent-skills/index.json
|
|
328
|
+
// then fall back to /.well-known/skills/index.json
|
|
329
|
+
if (isWellKnownUrl(input)) {
|
|
330
|
+
return {
|
|
331
|
+
type: 'well-known',
|
|
332
|
+
url: input,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Fallback: treat as direct git URL
|
|
336
|
+
return {
|
|
337
|
+
type: 'git',
|
|
338
|
+
url: input,
|
|
339
|
+
...(fragmentRef ? { ref: fragmentRef } : {}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Check if a URL could be a well-known skills endpoint.
|
|
344
|
+
* Must be HTTP(S) and not a known git host (GitHub, GitLab).
|
|
345
|
+
* Also excludes URLs that look like git repos (.git suffix).
|
|
346
|
+
*/
|
|
347
|
+
function isWellKnownUrl(input) {
|
|
348
|
+
if (!input.startsWith('http://') && !input.startsWith('https://')) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const parsed = new URL(input);
|
|
353
|
+
// Exclude known git hosts that have their own handling
|
|
354
|
+
const excludedHosts = ['github.com', 'gitlab.com', 'raw.githubusercontent.com'];
|
|
355
|
+
if (excludedHosts.includes(parsed.hostname)) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
// Don't match URLs that look like git repos (should be handled by git type)
|
|
359
|
+
if (input.endsWith('.git')) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|