agentskillsdk 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/add.js +27 -33
- package/src/lib/download.js +126 -57
- package/src/lib/messages/da.js +1 -3
- package/src/lib/messages/en.js +1 -3
- package/src/lib/resolve-github-path.js +5 -91
package/package.json
CHANGED
package/src/commands/add.js
CHANGED
|
@@ -4,7 +4,6 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { fetchSkill, fetchSkills } from '../lib/api.js';
|
|
5
5
|
import { detectAgents, AGENTS, AGENT_BY_SLUG } from '../lib/detect-agent.js';
|
|
6
6
|
import { downloadSkill } from '../lib/download.js';
|
|
7
|
-
import { resolveGithubSkillPath } from '../lib/resolve-github-path.js';
|
|
8
7
|
import { parseSource } from '../lib/parse-source.js';
|
|
9
8
|
import { selectPrompt, checkboxPrompt } from '../lib/prompt.js';
|
|
10
9
|
import { createOutput } from '../lib/output.js';
|
|
@@ -178,42 +177,19 @@ export async function addCommand(skillName, options = {}) {
|
|
|
178
177
|
else msgKey = 'add.source.github';
|
|
179
178
|
out.step(t(msgKey, { owner: parsed.owner, repo: parsed.repo, ref: parsed.ref, skill: requestedPath }));
|
|
180
179
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
// behavior so registry-style installs and single-skill repos still work.
|
|
188
|
-
let skillPath = requestedPath;
|
|
189
|
-
if (requestedPath) {
|
|
190
|
-
const lookup = out.spinner(t('add.spinner.resolving_path'));
|
|
191
|
-
let resolved;
|
|
192
|
-
try {
|
|
193
|
-
resolved = await resolveGithubSkillPath({
|
|
194
|
-
owner: parsed.owner,
|
|
195
|
-
repo: parsed.repo,
|
|
196
|
-
ref: parsed.ref,
|
|
197
|
-
requestedPath,
|
|
198
|
-
});
|
|
199
|
-
} catch (err) {
|
|
200
|
-
lookup.fail(t('add.error.resolve_fail'));
|
|
201
|
-
throw err;
|
|
202
|
-
}
|
|
203
|
-
if (resolved.resolved) {
|
|
204
|
-
lookup.succeed(t('add.spinner.resolved_path', { from: requestedPath, to: resolved.path }));
|
|
205
|
-
} else {
|
|
206
|
-
lookup.succeed(t('add.spinner.path_ok'));
|
|
207
|
-
}
|
|
208
|
-
skillPath = resolved.path;
|
|
209
|
-
}
|
|
210
|
-
installName = skillPath ? skillPath.split('/').pop() : parsed.repo;
|
|
180
|
+
// installName is derived from the requested skill so the on-disk folder
|
|
181
|
+
// matches what the user typed (`--skill ai-seo` → `.claude/skills/ai-seo/`),
|
|
182
|
+
// even when the actual source path inside the repo is nested
|
|
183
|
+
// (`skills/ai-seo/`). The real path is resolved inside downloadSkill from
|
|
184
|
+
// the tarball itself — no GitHub API call needed.
|
|
185
|
+
installName = requestedPath ? requestedPath.split('/').pop() : parsed.repo;
|
|
211
186
|
skill = {
|
|
212
187
|
name: installName,
|
|
213
188
|
githubOwner: parsed.owner,
|
|
214
189
|
githubRepo: parsed.repo,
|
|
215
|
-
githubPath:
|
|
190
|
+
githubPath: requestedPath || undefined,
|
|
216
191
|
ref: parsed.ref,
|
|
192
|
+
requestedPath,
|
|
217
193
|
};
|
|
218
194
|
} else {
|
|
219
195
|
installName = parsed.name;
|
|
@@ -311,9 +287,14 @@ export async function addCommand(skillName, options = {}) {
|
|
|
311
287
|
}
|
|
312
288
|
|
|
313
289
|
const spinner = out.spinner(t('add.spinner.downloading'));
|
|
290
|
+
let lastResolved;
|
|
314
291
|
try {
|
|
315
292
|
for (const { destDir } of destPlan) {
|
|
316
|
-
await downloadSkill(skill, destDir
|
|
293
|
+
const result = await downloadSkill(skill, destDir, {
|
|
294
|
+
requestedPath: skill.requestedPath,
|
|
295
|
+
owner: skill.githubOwner,
|
|
296
|
+
});
|
|
297
|
+
lastResolved = result;
|
|
317
298
|
}
|
|
318
299
|
} catch (err) {
|
|
319
300
|
spinner.fail(t('add.error.download_fail'));
|
|
@@ -321,6 +302,19 @@ export async function addCommand(skillName, options = {}) {
|
|
|
321
302
|
}
|
|
322
303
|
spinner.succeed(t('add.spinner.downloaded'));
|
|
323
304
|
|
|
305
|
+
// If the resolver picked a different path than the user asked for, surface
|
|
306
|
+
// it so they understand where the files actually came from.
|
|
307
|
+
if (isGithub && lastResolved && skill.requestedPath && lastResolved.resolvedPath !== skill.requestedPath) {
|
|
308
|
+
out.step(t('add.spinner.resolved_path', {
|
|
309
|
+
from: skill.requestedPath,
|
|
310
|
+
to: lastResolved.resolvedPath,
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
if (isGithub && lastResolved) {
|
|
314
|
+
// Update the JSON `source.path` to reflect what was actually installed.
|
|
315
|
+
sourcePayload.path = lastResolved.resolvedPath || undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
324
318
|
const agentsWithPath = agents.map(a => ({
|
|
325
319
|
slug: a.slug,
|
|
326
320
|
name: a.name,
|
package/src/lib/download.js
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
import { pipeline } from 'node:stream/promises';
|
|
2
2
|
import { createGunzip } from 'node:zlib';
|
|
3
|
-
import {
|
|
3
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import { join, dirname, resolve, sep } from 'node:path';
|
|
5
5
|
import tar from 'tar-stream';
|
|
6
6
|
import { NetworkError, FsError, NoMatchError } from './errors.js';
|
|
7
|
+
import { matchSkillDir } from './resolve-github-path.js';
|
|
7
8
|
import { t } from './messages/index.js';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
const PER_ENTRY_SIZE_CAP = 25 * 1024 * 1024; // 25 MB
|
|
11
|
+
|
|
12
|
+
function stripFirstSegment(p) {
|
|
13
|
+
const parts = p.split('/');
|
|
14
|
+
parts.shift();
|
|
15
|
+
return parts.join('/');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isSkillMd(name) {
|
|
19
|
+
return /(^|\/)SKILL\.md$/i.test(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Downloads a GitHub tarball, resolves which skill directory inside it to
|
|
23
|
+
// install, and writes the matching files to `destDir`. Path resolution is
|
|
24
|
+
// done entirely from the tarball contents — no GitHub API calls — so this
|
|
25
|
+
// works under anonymous-IP rate limits (the resolver previously hit 403 in
|
|
26
|
+
// shared cloud sandboxes).
|
|
27
|
+
//
|
|
28
|
+
// Returns { resolvedPath, skillDirs } so the caller can log "resolved A → B".
|
|
29
|
+
export async function downloadSkill(skill, destDir, opts = {}) {
|
|
30
|
+
// Backward-compat: when callers don't pass an opts object, fall back to the
|
|
31
|
+
// legacy `skill.githubPath` field. Production code in add.js passes opts
|
|
32
|
+
// explicitly; this fallback exists for unit tests that pre-date the change.
|
|
33
|
+
const requestedPath = opts.requestedPath ?? skill.githubPath;
|
|
34
|
+
const owner = opts.owner ?? skill.githubOwner;
|
|
10
35
|
const base = `https://api.github.com/repos/${skill.githubOwner}/${skill.githubRepo}/tarball`;
|
|
11
36
|
const url = skill.ref ? `${base}/${skill.ref}` : base;
|
|
12
37
|
|
|
@@ -22,17 +47,12 @@ export async function downloadSkill(skill, destDir) {
|
|
|
22
47
|
} catch {
|
|
23
48
|
throw new NetworkError(t('download.error.network'));
|
|
24
49
|
}
|
|
25
|
-
|
|
26
50
|
if (!res.ok) throw new NetworkError(t('download.error.github', { status: res.status }));
|
|
27
51
|
|
|
28
52
|
const extract = tar.extract();
|
|
29
|
-
const
|
|
30
|
-
const destDirResolved = resolve(destDir);
|
|
53
|
+
const entries = new Map(); // relativePath -> Buffer
|
|
31
54
|
let aborted = null;
|
|
32
|
-
let filesWritten = 0;
|
|
33
55
|
|
|
34
|
-
// Swallow late errors after we've already captured `aborted`; pipeline()
|
|
35
|
-
// will surface the original failure.
|
|
36
56
|
extract.on('error', () => {});
|
|
37
57
|
|
|
38
58
|
extract.on('entry', (header, stream, next) => {
|
|
@@ -42,88 +62,137 @@ export async function downloadSkill(skill, destDir) {
|
|
|
42
62
|
return;
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
// Reject symlink/link entries outright.
|
|
65
|
+
// Reject symlink/link entries outright (same posture as before).
|
|
46
66
|
if (header.type === 'symlink' || header.type === 'link') {
|
|
47
67
|
aborted = new FsError(t('download.error.unsupported_entry', { type: header.type, name: header.name }));
|
|
48
|
-
stream.on('end', () => next(
|
|
68
|
+
stream.on('end', () => next());
|
|
49
69
|
stream.resume();
|
|
50
70
|
return;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
|
-
|
|
54
|
-
// Strip the first path segment (the repo prefix).
|
|
55
|
-
const parts = header.name.split('/');
|
|
56
|
-
parts.shift();
|
|
57
|
-
const relativePath = parts.join('/');
|
|
58
|
-
|
|
59
|
-
let fileRelative;
|
|
60
|
-
if (skillPath === '') {
|
|
61
|
-
if (header.type === 'file' && relativePath) {
|
|
62
|
-
fileRelative = relativePath;
|
|
63
|
-
}
|
|
64
|
-
} else {
|
|
65
|
-
if (header.type === 'file' && relativePath.startsWith(skillPath + '/')) {
|
|
66
|
-
fileRelative = relativePath.slice(skillPath.length + 1);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!fileRelative) {
|
|
73
|
+
if (header.type !== 'file') {
|
|
71
74
|
stream.on('end', next);
|
|
72
75
|
stream.resume();
|
|
73
76
|
return;
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// Zip-slip guard: destination must stay within destDir.
|
|
80
|
-
if (destPathResolved !== destDirResolved && !destPathResolved.startsWith(destDirResolved + sep)) {
|
|
81
|
-
aborted = new FsError(t('download.error.traversal', { name: header.name }));
|
|
82
|
-
stream.on('end', () => next(aborted));
|
|
79
|
+
const relativePath = stripFirstSegment(header.name);
|
|
80
|
+
if (!relativePath) {
|
|
81
|
+
stream.on('end', next);
|
|
83
82
|
stream.resume();
|
|
84
83
|
return;
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let size = 0;
|
|
88
|
+
stream.on('data', (c) => {
|
|
89
|
+
size += c.length;
|
|
90
|
+
if (size > PER_ENTRY_SIZE_CAP && !aborted) {
|
|
91
|
+
aborted = new FsError(t('download.error.entry_too_large', {
|
|
92
|
+
name: header.name,
|
|
93
|
+
cap: PER_ENTRY_SIZE_CAP,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
if (!aborted) chunks.push(c);
|
|
95
97
|
});
|
|
96
|
-
|
|
97
|
-
if (!aborted)
|
|
98
|
-
|
|
98
|
+
stream.on('end', () => {
|
|
99
|
+
if (!aborted) entries.set(relativePath, Buffer.concat(chunks));
|
|
100
|
+
next();
|
|
99
101
|
});
|
|
100
102
|
stream.on('error', (err) => {
|
|
101
103
|
if (!aborted) aborted = err instanceof FsError ? err : new FsError(err.message);
|
|
102
104
|
});
|
|
103
|
-
stream.pipe(ws);
|
|
104
105
|
});
|
|
105
106
|
|
|
106
107
|
try {
|
|
107
|
-
await pipeline(
|
|
108
|
-
res.body,
|
|
109
|
-
createGunzip(),
|
|
110
|
-
extract,
|
|
111
|
-
);
|
|
108
|
+
await pipeline(res.body, createGunzip(), extract);
|
|
112
109
|
} catch (err) {
|
|
113
110
|
if (aborted) throw aborted;
|
|
114
111
|
throw err;
|
|
115
112
|
}
|
|
116
|
-
|
|
117
113
|
if (aborted) throw aborted;
|
|
118
114
|
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
// Discover skill directories from the buffered tarball.
|
|
116
|
+
const skillDirs = [...new Set(
|
|
117
|
+
[...entries.keys()]
|
|
118
|
+
.filter(isSkillMd)
|
|
119
|
+
.map(p => p.replace(/\/?SKILL\.md$/i, '')),
|
|
120
|
+
)];
|
|
121
|
+
|
|
122
|
+
// Choose which prefix to install.
|
|
123
|
+
let prefix;
|
|
124
|
+
if (requestedPath) {
|
|
125
|
+
if (skillDirs.includes(requestedPath)) {
|
|
126
|
+
prefix = requestedPath;
|
|
127
|
+
} else {
|
|
128
|
+
prefix = matchSkillDir(requestedPath, owner || skill.githubOwner, skillDirs);
|
|
129
|
+
}
|
|
130
|
+
if (prefix === null || prefix === undefined) {
|
|
131
|
+
throw new NoMatchError(t('resolve.error.skill_not_in_repo', {
|
|
132
|
+
skill: requestedPath,
|
|
133
|
+
owner: skill.githubOwner,
|
|
134
|
+
repo: skill.githubRepo,
|
|
135
|
+
candidates: skillDirs.length > 0
|
|
136
|
+
? skillDirs.map(d => ` - ${d || '<root>'}`).join('\n')
|
|
137
|
+
: ' (no SKILL.md files found in this repo)',
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
if (skillDirs.length === 0) {
|
|
142
|
+
throw new NoMatchError(t('resolve.error.no_skills', {
|
|
143
|
+
owner: skill.githubOwner,
|
|
144
|
+
repo: skill.githubRepo,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
if (skillDirs.length > 1) {
|
|
148
|
+
throw new NoMatchError(t('resolve.error.multiple_skills', {
|
|
149
|
+
owner: skill.githubOwner,
|
|
150
|
+
repo: skill.githubRepo,
|
|
151
|
+
candidates: skillDirs.map(d => ` - ${d || '<root>'}`).join('\n'),
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
prefix = skillDirs[0];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Write files under the resolved prefix.
|
|
158
|
+
const destDirResolved = resolve(destDir);
|
|
159
|
+
let filesWritten = 0;
|
|
160
|
+
|
|
161
|
+
for (const [relativePath, buf] of entries) {
|
|
162
|
+
let fileRelative;
|
|
163
|
+
if (prefix === '') {
|
|
164
|
+
fileRelative = relativePath;
|
|
165
|
+
} else if (relativePath === prefix) {
|
|
166
|
+
// Edge case: a file literally at the prefix path. Shouldn't happen for
|
|
167
|
+
// a SKILL.md dir but keep it safe.
|
|
168
|
+
fileRelative = relativePath.split('/').pop();
|
|
169
|
+
} else if (relativePath.startsWith(prefix + '/')) {
|
|
170
|
+
fileRelative = relativePath.slice(prefix.length + 1);
|
|
171
|
+
} else {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!fileRelative) continue;
|
|
175
|
+
|
|
176
|
+
const destPath = join(destDir, fileRelative);
|
|
177
|
+
const destPathResolved = resolve(destPath);
|
|
178
|
+
|
|
179
|
+
// Zip-slip guard: destination must stay within destDir.
|
|
180
|
+
if (destPathResolved !== destDirResolved && !destPathResolved.startsWith(destDirResolved + sep)) {
|
|
181
|
+
throw new FsError(t('download.error.traversal', { name: relativePath }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
mkdirSync(dirname(destPathResolved), { recursive: true });
|
|
185
|
+
writeFileSync(destPathResolved, buf);
|
|
186
|
+
filesWritten++;
|
|
187
|
+
}
|
|
188
|
+
|
|
122
189
|
if (filesWritten === 0) {
|
|
123
190
|
throw new NoMatchError(t('download.error.no_files_matched', {
|
|
124
|
-
path:
|
|
191
|
+
path: prefix || '<root>',
|
|
125
192
|
owner: skill.githubOwner,
|
|
126
193
|
repo: skill.githubRepo,
|
|
127
194
|
}));
|
|
128
195
|
}
|
|
196
|
+
|
|
197
|
+
return { resolvedPath: prefix, skillDirs };
|
|
129
198
|
}
|
package/src/lib/messages/da.js
CHANGED
|
@@ -26,10 +26,7 @@ export const da = {
|
|
|
26
26
|
'add.spinner.downloading': 'downloader skill-filer...',
|
|
27
27
|
'add.error.download_fail': 'download fejlede',
|
|
28
28
|
'add.spinner.downloaded': 'skill-filer downloadet',
|
|
29
|
-
'add.spinner.resolving_path': 'finder skill-sti i repo...',
|
|
30
29
|
'add.spinner.resolved_path': 'matchede "{from}" → "{to}"',
|
|
31
|
-
'add.spinner.path_ok': 'skill-sti bekræftet',
|
|
32
|
-
'add.error.resolve_fail': 'kunne ikke finde skill-stien',
|
|
33
30
|
'add.source.github': 'GitHub-kilde: {owner}/{repo}',
|
|
34
31
|
'add.source.github_with_skill': 'GitHub-kilde: {owner}/{repo} (skill: {skill})',
|
|
35
32
|
'add.source.github_with_ref': 'GitHub-kilde: {owner}/{repo}@{ref}',
|
|
@@ -67,6 +64,7 @@ export const da = {
|
|
|
67
64
|
'download.error.traversal': 'afviste usikker tarball-fil (sti-traversal): {name}',
|
|
68
65
|
'download.error.unsupported_entry': 'afviste ikke-understøttet tarball-fil ({type}): {name}',
|
|
69
66
|
'download.error.no_files_matched': 'ingen filer fundet på stien "{path}" i {owner}/{repo}',
|
|
67
|
+
'download.error.entry_too_large': 'tarball-fil overstiger størrelsesgrænse ({cap} bytes): {name}',
|
|
70
68
|
|
|
71
69
|
'resolve.error.no_skills': 'ingen SKILL.md-filer fundet i {owner}/{repo}',
|
|
72
70
|
'resolve.error.multiple_skills': '{owner}/{repo} indeholder flere skills — angiv --skill <navn>:\n{candidates}',
|
package/src/lib/messages/en.js
CHANGED
|
@@ -26,10 +26,7 @@ export const en = {
|
|
|
26
26
|
'add.spinner.downloading': 'downloading skill files...',
|
|
27
27
|
'add.error.download_fail': 'download failed',
|
|
28
28
|
'add.spinner.downloaded': 'skill files downloaded',
|
|
29
|
-
'add.spinner.resolving_path': 'resolving skill path in repo...',
|
|
30
29
|
'add.spinner.resolved_path': 'resolved "{from}" → "{to}"',
|
|
31
|
-
'add.spinner.path_ok': 'skill path verified',
|
|
32
|
-
'add.error.resolve_fail': 'could not resolve skill path',
|
|
33
30
|
'add.source.github': 'GitHub source: {owner}/{repo}',
|
|
34
31
|
'add.source.github_with_skill': 'GitHub source: {owner}/{repo} (skill: {skill})',
|
|
35
32
|
'add.source.github_with_ref': 'GitHub source: {owner}/{repo}@{ref}',
|
|
@@ -67,6 +64,7 @@ export const en = {
|
|
|
67
64
|
'download.error.traversal': 'refused unsafe tarball entry (path traversal): {name}',
|
|
68
65
|
'download.error.unsupported_entry': 'refused unsupported tarball entry type ({type}): {name}',
|
|
69
66
|
'download.error.no_files_matched': 'no files found at path "{path}" in {owner}/{repo}',
|
|
67
|
+
'download.error.entry_too_large': 'tarball entry exceeds size cap ({cap} bytes): {name}',
|
|
70
68
|
|
|
71
69
|
'resolve.error.no_skills': 'no SKILL.md files found in {owner}/{repo}',
|
|
72
70
|
'resolve.error.multiple_skills': '{owner}/{repo} contains multiple skills — pass --skill <name>:\n{candidates}',
|
|
@@ -1,57 +1,10 @@
|
|
|
1
|
-
import { NetworkError, NoMatchError } from './errors.js';
|
|
2
|
-
import { t } from './messages/index.js';
|
|
3
|
-
|
|
4
|
-
// Fetches every path in a GitHub repo tree that ends in SKILL.md.
|
|
5
|
-
// Returns the directory paths (without the trailing "/SKILL.md").
|
|
6
|
-
//
|
|
7
|
-
// `ref` is optional — when omitted GitHub resolves the default branch.
|
|
8
|
-
export async function fetchRepoSkillDirs(owner, repo, ref) {
|
|
9
|
-
const refOrDefault = ref || (await fetchDefaultBranch(owner, repo));
|
|
10
|
-
const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(refOrDefault)}?recursive=1`;
|
|
11
|
-
|
|
12
|
-
let res;
|
|
13
|
-
try {
|
|
14
|
-
res = await fetch(url, {
|
|
15
|
-
headers: {
|
|
16
|
-
'Accept': 'application/vnd.github+json',
|
|
17
|
-
'User-Agent': 'agentskillsdk-cli',
|
|
18
|
-
},
|
|
19
|
-
});
|
|
20
|
-
} catch {
|
|
21
|
-
throw new NetworkError(t('download.error.network'));
|
|
22
|
-
}
|
|
23
|
-
if (!res.ok) throw new NetworkError(t('download.error.github', { status: res.status }));
|
|
24
|
-
|
|
25
|
-
const json = await res.json();
|
|
26
|
-
const tree = Array.isArray(json.tree) ? json.tree : [];
|
|
27
|
-
return tree
|
|
28
|
-
.filter(e => e.type === 'blob' && /(^|\/)SKILL\.md$/i.test(e.path))
|
|
29
|
-
.map(e => e.path.replace(/\/?SKILL\.md$/i, ''));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function fetchDefaultBranch(owner, repo) {
|
|
33
|
-
let res;
|
|
34
|
-
try {
|
|
35
|
-
res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
|
36
|
-
headers: {
|
|
37
|
-
'Accept': 'application/vnd.github+json',
|
|
38
|
-
'User-Agent': 'agentskillsdk-cli',
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
} catch {
|
|
42
|
-
throw new NetworkError(t('download.error.network'));
|
|
43
|
-
}
|
|
44
|
-
if (!res.ok) throw new NetworkError(t('download.error.github', { status: res.status }));
|
|
45
|
-
const json = await res.json();
|
|
46
|
-
return json.default_branch || 'main';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
1
|
// Picks the best matching skill directory for `requested` from `candidates`.
|
|
50
2
|
// Strategies, in order:
|
|
51
|
-
// 1. Exact
|
|
52
|
-
// 2.
|
|
53
|
-
// 3.
|
|
54
|
-
// 4.
|
|
3
|
+
// 1. Exact last-segment match (case-insensitive)
|
|
4
|
+
// 2. Full-path match (user passed "skills/foo")
|
|
5
|
+
// 3. Owner-prefix stripped match (vercel-foo → foo)
|
|
6
|
+
// 4. Fuzzy normalized match (case + non-alphanum collapsed)
|
|
7
|
+
// 5. Single-candidate fallback when only one skill exists
|
|
55
8
|
// Returns the matched directory path, or null if no match.
|
|
56
9
|
//
|
|
57
10
|
// Ported from the agentskills website's `matchSkillToPath` so behavior stays
|
|
@@ -100,42 +53,3 @@ export function matchSkillDir(requested, owner, candidates) {
|
|
|
100
53
|
|
|
101
54
|
return null;
|
|
102
55
|
}
|
|
103
|
-
|
|
104
|
-
// High-level helper used by `add`. Resolves a user-provided `requestedPath`
|
|
105
|
-
// against the actual repo layout. Returns:
|
|
106
|
-
// - the requested path unchanged if it directly contains a SKILL.md
|
|
107
|
-
// - the matched directory otherwise
|
|
108
|
-
// - throws NoMatchError if nothing matches
|
|
109
|
-
export async function resolveGithubSkillPath({ owner, repo, ref, requestedPath }) {
|
|
110
|
-
const dirs = await fetchRepoSkillDirs(owner, repo, ref);
|
|
111
|
-
|
|
112
|
-
// No requested path: only succeed if the repo has exactly one skill.
|
|
113
|
-
if (!requestedPath) {
|
|
114
|
-
if (dirs.length === 0) {
|
|
115
|
-
throw new NoMatchError(t('resolve.error.no_skills', { owner, repo }));
|
|
116
|
-
}
|
|
117
|
-
if (dirs.length === 1) return { path: dirs[0], resolved: dirs[0] !== '' };
|
|
118
|
-
throw new NoMatchError(t('resolve.error.multiple_skills', {
|
|
119
|
-
owner, repo, candidates: dirs.map(d => ` - ${d || '<root>'}`).join('\n'),
|
|
120
|
-
}));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Direct hit: the user-supplied path already names a SKILL.md directory.
|
|
124
|
-
if (dirs.includes(requestedPath)) {
|
|
125
|
-
return { path: requestedPath, resolved: false };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const matched = matchSkillDir(requestedPath, owner, dirs);
|
|
129
|
-
if (matched === null) {
|
|
130
|
-
throw new NoMatchError(t('resolve.error.skill_not_in_repo', {
|
|
131
|
-
skill: requestedPath,
|
|
132
|
-
owner,
|
|
133
|
-
repo,
|
|
134
|
-
candidates: dirs.length > 0
|
|
135
|
-
? dirs.map(d => ` - ${d || '<root>'}`).join('\n')
|
|
136
|
-
: ' (no SKILL.md files found in this repo)',
|
|
137
|
-
}));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return { path: matched, resolved: matched !== requestedPath };
|
|
141
|
-
}
|