@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.
Files changed (110) hide show
  1. package/dist/bin/adg.js +703 -0
  2. package/dist/src/adapters/anthropic.js +54 -0
  3. package/dist/src/adapters/index.js +10 -0
  4. package/dist/src/adapters/openai.js +30 -0
  5. package/dist/src/adapters/reverse.js +53 -0
  6. package/dist/src/agents/claude.js +118 -0
  7. package/dist/src/agents/codex.js +61 -0
  8. package/{src/agents/index.ts → dist/src/agents/index.js} +6 -8
  9. package/dist/src/agents/registry.js +24 -0
  10. package/dist/src/agents/types.js +1 -0
  11. package/dist/src/commands/adapt.js +26 -0
  12. package/dist/src/commands/import.js +51 -0
  13. package/dist/src/commands/init.js +104 -0
  14. package/dist/src/commands/install.js +257 -0
  15. package/dist/src/commands/link.js +34 -0
  16. package/dist/src/commands/list.js +19 -0
  17. package/dist/src/commands/marketplace.js +124 -0
  18. package/dist/src/commands/migrate.js +60 -0
  19. package/dist/src/commands/multiselect-skills.js +103 -0
  20. package/dist/src/commands/remove.js +102 -0
  21. package/dist/src/commands/select-agents.js +40 -0
  22. package/dist/src/commands/select-components.js +61 -0
  23. package/dist/src/commands/select-plugins.js +25 -0
  24. package/dist/src/commands/select-scope.js +20 -0
  25. package/dist/src/commands/update.js +50 -0
  26. package/dist/src/commands/validate.js +50 -0
  27. package/dist/src/components.js +90 -0
  28. package/dist/src/deps.js +46 -0
  29. package/dist/src/fsutil.js +32 -0
  30. package/dist/src/hash.js +51 -0
  31. package/dist/src/lock.js +51 -0
  32. package/dist/src/manifest.js +110 -0
  33. package/dist/src/marketplace.js +39 -0
  34. package/{src/package.ts → dist/src/package.js} +37 -42
  35. package/{src/paths.ts → dist/src/paths.js} +54 -60
  36. package/dist/src/semver.js +55 -0
  37. package/dist/src/skills.js +79 -0
  38. package/dist/src/sources.js +122 -0
  39. package/dist/src/types.js +19 -0
  40. package/dist/vendor/skills/package.json +143 -0
  41. package/dist/vendor/skills/src/add.js +1663 -0
  42. package/dist/vendor/skills/src/agents.js +729 -0
  43. package/dist/vendor/skills/src/blob.js +436 -0
  44. package/dist/vendor/skills/src/cli.js +340 -0
  45. package/dist/vendor/skills/src/constants.js +3 -0
  46. package/dist/vendor/skills/src/detect-agent.js +56 -0
  47. package/dist/vendor/skills/src/find.js +294 -0
  48. package/dist/vendor/skills/src/frontmatter.js +13 -0
  49. package/dist/vendor/skills/src/git-tree.js +32 -0
  50. package/dist/vendor/skills/src/git.js +235 -0
  51. package/dist/vendor/skills/src/install.js +75 -0
  52. package/dist/vendor/skills/src/installer.js +924 -0
  53. package/dist/vendor/skills/src/list.js +201 -0
  54. package/dist/vendor/skills/src/local-lock.js +109 -0
  55. package/dist/vendor/skills/src/plugin-manifest.js +152 -0
  56. package/dist/vendor/skills/src/prompts/search-multiselect.js +312 -0
  57. package/dist/vendor/skills/src/providers/index.js +4 -0
  58. package/dist/vendor/skills/src/providers/registry.js +42 -0
  59. package/dist/vendor/skills/src/providers/types.js +1 -0
  60. package/dist/vendor/skills/src/providers/wellknown.js +625 -0
  61. package/dist/vendor/skills/src/remove.js +263 -0
  62. package/dist/vendor/skills/src/sanitize.js +57 -0
  63. package/dist/vendor/skills/src/self-cli.js +15 -0
  64. package/dist/vendor/skills/src/skill-lock.js +237 -0
  65. package/dist/vendor/skills/src/skills.js +264 -0
  66. package/dist/vendor/skills/src/source-parser.js +367 -0
  67. package/dist/vendor/skills/src/sync.js +404 -0
  68. package/dist/vendor/skills/src/telemetry.js +101 -0
  69. package/dist/vendor/skills/src/test-utils.js +59 -0
  70. package/dist/vendor/skills/src/types.js +1 -0
  71. package/dist/vendor/skills/src/update-source.js +76 -0
  72. package/dist/vendor/skills/src/update.js +590 -0
  73. package/dist/vendor/skills/src/use.js +505 -0
  74. package/package.json +15 -7
  75. package/bin/adg.ts +0 -758
  76. package/src/adapters/anthropic.ts +0 -54
  77. package/src/adapters/index.ts +0 -24
  78. package/src/adapters/openai.ts +0 -37
  79. package/src/adapters/reverse.ts +0 -60
  80. package/src/agents/claude.ts +0 -124
  81. package/src/agents/codex.ts +0 -67
  82. package/src/agents/registry.ts +0 -30
  83. package/src/agents/types.ts +0 -47
  84. package/src/commands/adapt.ts +0 -36
  85. package/src/commands/import.ts +0 -69
  86. package/src/commands/init.ts +0 -146
  87. package/src/commands/install.ts +0 -411
  88. package/src/commands/link.ts +0 -61
  89. package/src/commands/list.ts +0 -28
  90. package/src/commands/marketplace.ts +0 -198
  91. package/src/commands/migrate.ts +0 -84
  92. package/src/commands/multiselect-skills.ts +0 -137
  93. package/src/commands/remove.ts +0 -136
  94. package/src/commands/select-agents.ts +0 -45
  95. package/src/commands/select-components.ts +0 -66
  96. package/src/commands/select-plugins.ts +0 -28
  97. package/src/commands/select-scope.ts +0 -21
  98. package/src/commands/update.ts +0 -85
  99. package/src/commands/validate.ts +0 -57
  100. package/src/components.ts +0 -90
  101. package/src/deps.ts +0 -64
  102. package/src/fsutil.ts +0 -38
  103. package/src/hash.ts +0 -61
  104. package/src/lock.ts +0 -57
  105. package/src/manifest.ts +0 -113
  106. package/src/marketplace.ts +0 -41
  107. package/src/semver.ts +0 -67
  108. package/src/skills.ts +0 -88
  109. package/src/sources.ts +0 -159
  110. 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
+ }