agentskillsdk 0.6.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentskillsdk",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Install agent skills from agentskills.dk",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- // Resolve the requested path against the actual repo layout. The website's
182
- // registry runs `matchSkillToPath` during indexing for the same reason —
183
- // users type `--skill foo` but the file lives at `skills/foo/SKILL.md`.
184
- //
185
- // Only resolve when the caller asked for a specific skill for bare
186
- // `owner/repo` we preserve the existing "install whatever's in the tarball"
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: skillPath || undefined,
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,
@@ -1,12 +1,37 @@
1
1
  import { pipeline } from 'node:stream/promises';
2
2
  import { createGunzip } from 'node:zlib';
3
- import { createWriteStream, mkdirSync } from 'node:fs';
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
- export async function downloadSkill(skill, destDir) {
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 skillPath = skill.githubPath || '';
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,133 @@ export async function downloadSkill(skill, destDir) {
42
62
  return;
43
63
  }
44
64
 
45
- // Reject symlink/link entries outright.
46
- if (header.type === 'symlink' || header.type === 'link') {
47
- aborted = new FsError(t('download.error.unsupported_entry', { type: header.type, name: header.name }));
48
- stream.on('end', () => next(aborted));
49
- stream.resume();
50
- return;
51
- }
52
-
53
- // Tarball entries: "{owner}-{repo}-{sha}/skills/real-estate-crm/SKILL.md"
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) {
65
+ // Skip symlinks and hardlinks. We never write them to disk, so they can't
66
+ // cause harm but real-world skill repos use symlinks to deduplicate
67
+ // shared docs (e.g. CLAUDE.md alias). Aborting the whole install over a
68
+ // symlink in an unrelated skill made multi-skill repos uninstallable.
69
+ if (header.type !== 'file') {
71
70
  stream.on('end', next);
72
71
  stream.resume();
73
72
  return;
74
73
  }
75
74
 
76
- const destPath = join(destDir, fileRelative);
77
- const destPathResolved = resolve(destPath);
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));
75
+ const relativePath = stripFirstSegment(header.name);
76
+ if (!relativePath) {
77
+ stream.on('end', next);
83
78
  stream.resume();
84
79
  return;
85
80
  }
86
81
 
87
- mkdirSync(dirname(destPathResolved), { recursive: true });
88
-
89
- const ws = createWriteStream(destPathResolved);
90
- let finished = false;
91
- ws.on('finish', () => {
92
- finished = true;
93
- filesWritten++;
94
- next();
82
+ const chunks = [];
83
+ let size = 0;
84
+ stream.on('data', (c) => {
85
+ size += c.length;
86
+ if (size > PER_ENTRY_SIZE_CAP && !aborted) {
87
+ aborted = new FsError(t('download.error.entry_too_large', {
88
+ name: header.name,
89
+ cap: PER_ENTRY_SIZE_CAP,
90
+ }));
91
+ }
92
+ if (!aborted) chunks.push(c);
95
93
  });
96
- ws.on('error', (err) => {
97
- if (!aborted) aborted = err instanceof FsError ? err : new FsError(err.message);
98
- if (!finished) next();
94
+ stream.on('end', () => {
95
+ if (!aborted) entries.set(relativePath, Buffer.concat(chunks));
96
+ next();
99
97
  });
100
98
  stream.on('error', (err) => {
101
99
  if (!aborted) aborted = err instanceof FsError ? err : new FsError(err.message);
102
100
  });
103
- stream.pipe(ws);
104
101
  });
105
102
 
106
103
  try {
107
- await pipeline(
108
- res.body,
109
- createGunzip(),
110
- extract,
111
- );
104
+ await pipeline(res.body, createGunzip(), extract);
112
105
  } catch (err) {
113
106
  if (aborted) throw aborted;
114
107
  throw err;
115
108
  }
116
-
117
109
  if (aborted) throw aborted;
118
110
 
119
- // Defense in depth: even with upfront path resolution, refuse to report a
120
- // successful install when zero files were written. Previously the CLI would
121
- // print a success banner with a path that didn't exist on disk.
111
+ // Discover skill directories from the buffered tarball.
112
+ const skillDirs = [...new Set(
113
+ [...entries.keys()]
114
+ .filter(isSkillMd)
115
+ .map(p => p.replace(/\/?SKILL\.md$/i, '')),
116
+ )];
117
+
118
+ // Choose which prefix to install.
119
+ let prefix;
120
+ if (requestedPath) {
121
+ if (skillDirs.includes(requestedPath)) {
122
+ prefix = requestedPath;
123
+ } else {
124
+ prefix = matchSkillDir(requestedPath, owner || skill.githubOwner, skillDirs);
125
+ }
126
+ if (prefix === null || prefix === undefined) {
127
+ throw new NoMatchError(t('resolve.error.skill_not_in_repo', {
128
+ skill: requestedPath,
129
+ owner: skill.githubOwner,
130
+ repo: skill.githubRepo,
131
+ candidates: skillDirs.length > 0
132
+ ? skillDirs.map(d => ` - ${d || '<root>'}`).join('\n')
133
+ : ' (no SKILL.md files found in this repo)',
134
+ }));
135
+ }
136
+ } else {
137
+ if (skillDirs.length === 0) {
138
+ throw new NoMatchError(t('resolve.error.no_skills', {
139
+ owner: skill.githubOwner,
140
+ repo: skill.githubRepo,
141
+ }));
142
+ }
143
+ if (skillDirs.length > 1) {
144
+ throw new NoMatchError(t('resolve.error.multiple_skills', {
145
+ owner: skill.githubOwner,
146
+ repo: skill.githubRepo,
147
+ candidates: skillDirs.map(d => ` - ${d || '<root>'}`).join('\n'),
148
+ }));
149
+ }
150
+ prefix = skillDirs[0];
151
+ }
152
+
153
+ // Write files under the resolved prefix.
154
+ const destDirResolved = resolve(destDir);
155
+ let filesWritten = 0;
156
+
157
+ for (const [relativePath, buf] of entries) {
158
+ let fileRelative;
159
+ if (prefix === '') {
160
+ fileRelative = relativePath;
161
+ } else if (relativePath === prefix) {
162
+ // Edge case: a file literally at the prefix path. Shouldn't happen for
163
+ // a SKILL.md dir but keep it safe.
164
+ fileRelative = relativePath.split('/').pop();
165
+ } else if (relativePath.startsWith(prefix + '/')) {
166
+ fileRelative = relativePath.slice(prefix.length + 1);
167
+ } else {
168
+ continue;
169
+ }
170
+ if (!fileRelative) continue;
171
+
172
+ const destPath = join(destDir, fileRelative);
173
+ const destPathResolved = resolve(destPath);
174
+
175
+ // Zip-slip guard: destination must stay within destDir.
176
+ if (destPathResolved !== destDirResolved && !destPathResolved.startsWith(destDirResolved + sep)) {
177
+ throw new FsError(t('download.error.traversal', { name: relativePath }));
178
+ }
179
+
180
+ mkdirSync(dirname(destPathResolved), { recursive: true });
181
+ writeFileSync(destPathResolved, buf);
182
+ filesWritten++;
183
+ }
184
+
122
185
  if (filesWritten === 0) {
123
186
  throw new NoMatchError(t('download.error.no_files_matched', {
124
- path: skillPath || '<root>',
187
+ path: prefix || '<root>',
125
188
  owner: skill.githubOwner,
126
189
  repo: skill.githubRepo,
127
190
  }));
128
191
  }
192
+
193
+ return { resolvedPath: prefix, skillDirs };
129
194
  }
@@ -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}',
@@ -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 directory name match
52
- // 2. Owner-prefix stripped match (vercel-foo foo)
53
- // 3. Fuzzy normalized match (case + non-alphanum collapsed)
54
- // 4. Single-candidate fallback when only one skill exists
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
- }