agentskillsdk 0.5.3 → 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/src/index.js CHANGED
@@ -1,30 +1,102 @@
1
- import { Command } from 'commander';
1
+ import { Command, CommanderError, Option } from 'commander';
2
2
  import { createRequire } from 'node:module';
3
3
  import { addCommand } from './commands/add.js';
4
4
  import { listCommand } from './commands/list.js';
5
+ import { EXIT, UsageError } from './lib/errors.js';
6
+ import { t } from './lib/messages/index.js';
5
7
 
6
8
  const require = createRequire(import.meta.url);
7
9
  const pkg = require('../package.json');
8
10
 
9
- const program = new Command();
11
+ function normalizeOptions(options) {
12
+ if (options.nonInteractive) options.yes = true;
13
+ if (options.all) {
14
+ options.agent = options.agent && options.agent.length ? options.agent : ['*'];
15
+ }
16
+ return options;
17
+ }
10
18
 
11
- program
12
- .name('agentskillsdk')
13
- .description('installer agent-skills fra agentskills.dk')
14
- .version(pkg.version);
19
+ const ADD_EXAMPLES = `
20
+ Examples:
21
+ $ agentskillsdk add genkit-ai/skills --skill developing-genkit-js -y
22
+ $ agentskillsdk add obra/superpowers@brainstorming -a claude-code -y --json
23
+ $ CLAUDECODE=1 npx agentskillsdk add my-skill # agent mode auto-detected
15
24
 
16
- program
17
- .command('add')
18
- .description('installer en skill i dit projekt')
19
- .argument('<skill-name>', 'navn på skill (eller owner/repo for GitHub)')
20
- .option('--skill <path>', 'sti i repo, f.eks. skills/twitter')
21
- .option('-g, --global', 'installer globalt i ~/.claude/skills/')
22
- .option('-p, --project', 'installer i projekt .claude/skills/ (standard)')
23
- .action(addCommand);
25
+ Output:
26
+ stdout JSON payload (--json), or quiet status line (--quiet)
27
+ stderr banner, spinners, errors, human-readable output
24
28
 
25
- program
26
- .command('list')
27
- .description('vis alle tilgængelige skills')
28
- .action(listCommand);
29
+ Exit codes:
30
+ 0 success
31
+ 1 generic error
32
+ 2 invalid usage
33
+ 3 ambiguous target (multiple agents detected without --agent)
34
+ 4 no matching skills
35
+ 5 network / registry unreachable
36
+ 6 filesystem error
37
+ 7 no agent detected
38
+ 130 user cancelled
29
39
 
30
- program.parse();
40
+ Environment:
41
+ AI_AGENT, CLAUDECODE, CURSOR_AGENT, CODEX_*, COPILOT_*, OPENCODE_CLIENT, ...
42
+ Any of these flip the CLI into agent mode (non-interactive, English, JSON-safe).
43
+ AGENTSKILLSDK_LANG=en|da
44
+ UI language. Same as --lang.
45
+ `;
46
+
47
+ export function buildProgram() {
48
+ const program = new Command();
49
+
50
+ program
51
+ .name('agentskillsdk')
52
+ .description(t('cli.description'))
53
+ .version(pkg.version)
54
+ .exitOverride((err) => {
55
+ if (err.exitCode === 0) throw err;
56
+ const wrapped = new UsageError(err.message);
57
+ wrapped.silent = true;
58
+ throw wrapped;
59
+ });
60
+
61
+ program
62
+ .command('add')
63
+ .description(t('cli.add.description'))
64
+ .argument('<skill-name>', t('cli.add.arg'))
65
+ .option('--skill <path>', t('cli.add.skill_option'))
66
+ .option('-g, --global', t('cli.add.global_option'))
67
+ .option('-p, --project', t('cli.add.project_option'))
68
+ .option('-y, --yes', t('cli.add.yes_option'))
69
+ .option('--non-interactive', t('cli.add.non_interactive_option'))
70
+ .option('-a, --agent <slugs...>', t('cli.add.agent_option'))
71
+ .option('--all', t('cli.add.all_option'))
72
+ .option('--json', t('cli.add.json_option'))
73
+ .option('--quiet', t('cli.add.quiet_option'))
74
+ .option('--lang <code>', t('cli.add.lang_option'))
75
+ .option('--dry-run', t('cli.add.dry_run_option'))
76
+ .option('--force', t('cli.add.force_option'))
77
+ .addHelpText('after', ADD_EXAMPLES)
78
+ .action((skillName, options) => addCommand(skillName, normalizeOptions(options)));
79
+
80
+ program
81
+ .command('list')
82
+ .description(t('cli.list.description'))
83
+ .option('--json', t('cli.add.json_option'))
84
+ .option('--quiet', t('cli.add.quiet_option'))
85
+ .option('--lang <code>', t('cli.add.lang_option'))
86
+ .action((options) => listCommand(normalizeOptions(options)));
87
+
88
+ return program;
89
+ }
90
+
91
+ export async function run(argv = process.argv) {
92
+ const program = buildProgram();
93
+ try {
94
+ await program.parseAsync(argv);
95
+ } catch (err) {
96
+ if (err instanceof CommanderError && err.exitCode === 0) {
97
+ return EXIT.OK;
98
+ }
99
+ throw err;
100
+ }
101
+ return EXIT.OK;
102
+ }
package/src/lib/api.js CHANGED
@@ -1,24 +1,27 @@
1
+ import { NetworkError } from './errors.js';
2
+ import { t } from './messages/index.js';
3
+
1
4
  const API_BASE = 'https://agentskills.dk/api/cli';
2
5
 
3
- export async function fetchSkills() {
6
+ async function fetchJson(url) {
4
7
  let res;
5
8
  try {
6
- res = await fetch(`${API_BASE}/skills`);
9
+ res = await fetch(url);
7
10
  } catch {
8
- throw new Error('Could not reach agentskills.dk. Check your internet connection.');
11
+ throw new NetworkError(t('api.error.unreachable'));
9
12
  }
10
- if (!res.ok) throw new Error(`Failed to fetch skills: ${res.status}`);
13
+ return res;
14
+ }
15
+
16
+ export async function fetchSkills() {
17
+ const res = await fetchJson(`${API_BASE}/skills`);
18
+ if (!res.ok) throw new NetworkError(t('api.error.fetch_skills', { status: res.status }));
11
19
  return res.json();
12
20
  }
13
21
 
14
22
  export async function fetchSkill(name) {
15
- let res;
16
- try {
17
- res = await fetch(`${API_BASE}/skills/${encodeURIComponent(name)}`);
18
- } catch {
19
- throw new Error('Could not reach agentskills.dk. Check your internet connection.');
20
- }
23
+ const res = await fetchJson(`${API_BASE}/skills/${encodeURIComponent(name)}`);
21
24
  if (res.status === 404) return null;
22
- if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status}`);
25
+ if (!res.ok) throw new NetworkError(t('api.error.fetch_skill', { status: res.status }));
23
26
  return res.json();
24
27
  }
@@ -2,19 +2,26 @@ import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
- function makeAgent(name, folder, { globalFolder, detectFolder, universal } = {}) {
5
+ // `slug` is the canonical kebab-case identifier used by:
6
+ // - the `-a/--agent <slug>` CLI flag
7
+ // - env-var → agent mapping in detect-runtime.js
8
+ // - JSON output payloads
9
+ // Slugs are frozen once shipped. Renames go through `legacySlugAliases` in
10
+ // the resolver, not by editing this list.
11
+ function makeAgent(name, folder, slug, { globalFolder, detectFolder, path } = {}) {
6
12
  const gFolder = globalFolder || folder;
7
13
  const dFolder = detectFolder || folder;
14
+ const defaultPath = (skill, { cwd, scope }) => {
15
+ if (scope === 'global') return join(homedir(), gFolder, 'skills', skill);
16
+ return join(cwd, folder, 'skills', skill);
17
+ };
8
18
  return {
9
19
  name,
20
+ slug,
10
21
  folder,
11
22
  globalFolder: gFolder,
12
23
  detectFolder: dFolder,
13
- universal: !!universal,
14
- path: (skill, { cwd, scope }) => {
15
- if (scope === 'global') return join(homedir(), gFolder, 'skills', skill);
16
- return join(cwd, folder, 'skills', skill);
17
- },
24
+ path: path || defaultPath,
18
25
  displayPath: (skill, scope) => {
19
26
  if (scope === 'global') return `~/${gFolder}/skills/${skill}/`;
20
27
  return `${folder}/skills/${skill}/`;
@@ -22,47 +29,41 @@ function makeAgent(name, folder, { globalFolder, detectFolder, universal } = {})
22
29
  };
23
30
  }
24
31
 
32
+ // GitHub Copilot note: `folder` is `.github` and the default `path()` then
33
+ // resolves to `.github/skills/<skill>/`, which is the convention the
34
+ // community has settled on. `detectFolder` of `.github/skills` matches that
35
+ // install location for auto-detect.
25
36
  export const AGENTS = [
26
- makeAgent('Amp', '.agents', { detectFolder: '.config/amp', universal: true }),
27
- makeAgent('Cline', '.cline'),
28
- makeAgent('Claude Code', '.claude'),
29
- makeAgent('CodeBuddy', '.codebuddy'),
30
- makeAgent('Codex CLI', '.agents', { globalFolder: '.codex', detectFolder: '.codex', universal: true }),
31
- makeAgent('Command Code', '.commandcode'),
32
- makeAgent('Continue', '.continue'),
33
- makeAgent('Crush', '.crush'),
34
- makeAgent('Cursor', '.agents', { globalFolder: '.cursor', detectFolder: '.cursor', universal: true }),
35
- makeAgent('Droid', '.factory'),
36
- makeAgent('Gemini CLI', '.agents', { detectFolder: '.gemini', universal: true }),
37
- makeAgent('GitHub Copilot', '.agents', { detectFolder: '.github/skills', globalFolder: '.github', universal: true }),
38
- makeAgent('Goose', '.goose'),
39
- makeAgent('Kilo Code', '.kilocode'),
40
- makeAgent('Kimi CLI', '.agents', { detectFolder: '.kimi', universal: true }),
41
- makeAgent('Kiro CLI', '.kiro'),
42
- makeAgent('MCPJam', '.mcpjam'),
43
- makeAgent('Mux', '.mux'),
44
- makeAgent('Neovate', '.neovate'),
45
- makeAgent('OpenClaw', '.openclaw'),
46
- makeAgent('OpenCode', '.agents', { globalFolder: '.opencode', detectFolder: '.opencode', universal: true }),
47
- makeAgent('OpenHands', '.openhands'),
48
- makeAgent('Pi', '.pi'),
49
- makeAgent('Qoder', '.qoder'),
50
- makeAgent('Qwen Code', '.qwen'),
51
- makeAgent('Replit', '.agents', { detectFolder: '.replit', universal: true }),
52
- makeAgent('Roo Code', '.roo'),
53
- makeAgent('Trae', '.trae'),
54
- makeAgent('Windsurf', '.windsurf'),
55
- makeAgent('Zencoder', '.zencoder'),
37
+ makeAgent('Cline', '.cline', 'cline'),
38
+ makeAgent('Claude Code', '.claude', 'claude-code'),
39
+ makeAgent('CodeBuddy', '.codebuddy', 'codebuddy'),
40
+ makeAgent('Codex CLI', '.agents', 'codex', { globalFolder: '.codex' }),
41
+ makeAgent('Command Code', '.commandcode', 'commandcode'),
42
+ makeAgent('Continue', '.continue', 'continue'),
43
+ makeAgent('Crush', '.crush', 'crush'),
44
+ makeAgent('Cursor', '.cursor', 'cursor'),
45
+ makeAgent('Droid', '.factory', 'droid'),
46
+ makeAgent('GitHub Copilot', '.github', 'copilot', { detectFolder: '.github/skills' }),
47
+ makeAgent('Goose', '.goose', 'goose'),
48
+ makeAgent('Kilo Code', '.kilocode', 'kilo-code'),
49
+ makeAgent('Kiro CLI', '.kiro', 'kiro'),
50
+ makeAgent('MCPJam', '.mcpjam', 'mcpjam'),
51
+ makeAgent('Mux', '.mux', 'mux'),
52
+ makeAgent('Neovate', '.neovate', 'neovate'),
53
+ makeAgent('OpenClaw', '.openclaw', 'openclaw'),
54
+ makeAgent('OpenCode', '.opencode', 'opencode'),
55
+ makeAgent('OpenHands', '.openhands', 'openhands'),
56
+ makeAgent('Pi', '.pi', 'pi'),
57
+ makeAgent('Qoder', '.qoder', 'qoder'),
58
+ makeAgent('Qwen Code', '.qwen', 'qwen'),
59
+ makeAgent('Roo Code', '.roo', 'roo'),
60
+ makeAgent('Trae', '.trae', 'trae'),
61
+ makeAgent('Windsurf', '.windsurf', 'windsurf'),
62
+ makeAgent('Zencoder', '.zencoder', 'zencoder'),
56
63
  ];
57
64
 
65
+ export const AGENT_BY_SLUG = Object.fromEntries(AGENTS.map(a => [a.slug, a]));
66
+
58
67
  export function detectAgents(cwd) {
59
68
  return AGENTS.filter(agent => existsSync(join(cwd, agent.detectFolder)));
60
69
  }
61
-
62
- export function getUniversalAgents(agentList) {
63
- return agentList.filter(a => a.universal);
64
- }
65
-
66
- export function getNonUniversalAgents(agentList) {
67
- return agentList.filter(a => !a.universal);
68
- }
@@ -0,0 +1,63 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { AGENT_BY_SLUG } from './detect-agent.js';
3
+
4
+ // Env-var matrix for agent detection. Order matters: the first truthy match wins.
5
+ // Mirrors @vercel/detect-agent; re-sync quarterly. `slug` is null when the
6
+ // agent is known to be running but we don't have an install target for it
7
+ // (e.g. Gemini, Replit, Devin); the caller still sees isAgent=true and the
8
+ // install will fail noisily on the missing -a flag.
9
+ const ENV_MATRIX = [
10
+ { env: 'CLAUDECODE', slug: 'claude-code' },
11
+ { env: 'CLAUDE_CODE', slug: 'claude-code' },
12
+ { env: 'CURSOR_TRACE_ID', slug: 'cursor' },
13
+ { env: 'CURSOR_AGENT', slug: 'cursor' },
14
+ { env: 'GEMINI_CLI', slug: null },
15
+ { env: 'CODEX_SANDBOX', slug: 'codex' },
16
+ { env: 'CODEX_CI', slug: 'codex' },
17
+ { env: 'CODEX_THREAD_ID', slug: 'codex' },
18
+ { env: 'ANTIGRAVITY_AGENT', slug: null },
19
+ { env: 'AUGMENT_AGENT', slug: null },
20
+ { env: 'OPENCODE_CLIENT', slug: 'opencode' },
21
+ { env: 'REPL_ID', slug: null },
22
+ { env: 'COPILOT_MODEL', slug: 'copilot' },
23
+ { env: 'COPILOT_ALLOW_ALL', slug: 'copilot' },
24
+ { env: 'COPILOT_GITHUB_TOKEN', slug: 'copilot' },
25
+ ];
26
+
27
+ const DEVIN_MARKER = '/opt/.devin';
28
+
29
+ export function detectRuntime(env = process.env) {
30
+ // AI_AGENT is the explicit "I'm an agent" signal. If its value happens to
31
+ // be a known slug, use it as the agent target. If not (e.g. Conductor sets
32
+ // it to a versioned string), still mark isAgent=true but fall through to
33
+ // the implicit env matrix so we can recover a slug from CLAUDECODE etc.
34
+ let aiAgentSlug = null;
35
+ if (env.AI_AGENT) {
36
+ aiAgentSlug = AGENT_BY_SLUG[env.AI_AGENT] ? env.AI_AGENT : null;
37
+ if (aiAgentSlug) {
38
+ return { isAgent: true, agentSlug: aiAgentSlug, source: 'AI_AGENT' };
39
+ }
40
+ }
41
+
42
+ for (const { env: name, slug } of ENV_MATRIX) {
43
+ if (env[name]) {
44
+ return {
45
+ isAgent: true,
46
+ agentSlug: slug && AGENT_BY_SLUG[slug] ? slug : null,
47
+ source: name,
48
+ };
49
+ }
50
+ }
51
+
52
+ if (env.AI_AGENT) {
53
+ // AI_AGENT was set but didn't match a known slug and no implicit env
54
+ // var picked it up. Still flag as agent — the caller has to pass -a.
55
+ return { isAgent: true, agentSlug: null, source: 'AI_AGENT' };
56
+ }
57
+
58
+ if (existsSync(DEVIN_MARKER)) {
59
+ return { isAgent: true, agentSlug: null, source: DEVIN_MARKER };
60
+ }
61
+
62
+ return { isAgent: false, agentSlug: null, source: null };
63
+ }
@@ -1,11 +1,39 @@
1
1
  import { pipeline } from 'node:stream/promises';
2
2
  import { createGunzip } from 'node:zlib';
3
- import { createWriteStream, mkdirSync } from 'node:fs';
4
- import { join, dirname } from 'node:path';
3
+ import { writeFileSync, mkdirSync } from 'node:fs';
4
+ import { join, dirname, resolve, sep } from 'node:path';
5
5
  import tar from 'tar-stream';
6
+ import { NetworkError, FsError, NoMatchError } from './errors.js';
7
+ import { matchSkillDir } from './resolve-github-path.js';
8
+ import { t } from './messages/index.js';
6
9
 
7
- export async function downloadSkill(skill, destDir) {
8
- const url = `https://api.github.com/repos/${skill.githubOwner}/${skill.githubRepo}/tarball/${skill.defaultBranch}`;
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;
35
+ const base = `https://api.github.com/repos/${skill.githubOwner}/${skill.githubRepo}/tarball`;
36
+ const url = skill.ref ? `${base}/${skill.ref}` : base;
9
37
 
10
38
  let res;
11
39
  try {
@@ -17,65 +45,154 @@ export async function downloadSkill(skill, destDir) {
17
45
  redirect: 'follow',
18
46
  });
19
47
  } catch {
20
- throw new Error('kunne ikke downloade skill-filer. tjek din internetforbindelse.');
48
+ throw new NetworkError(t('download.error.network'));
21
49
  }
22
-
23
- if (!res.ok) throw new Error(`GitHub download fejlede: ${res.status}`);
50
+ if (!res.ok) throw new NetworkError(t('download.error.github', { status: res.status }));
24
51
 
25
52
  const extract = tar.extract();
26
- const skillPath = skill.githubPath;
27
- const filePromises = [];
53
+ const entries = new Map(); // relativePath -> Buffer
54
+ let aborted = null;
55
+
56
+ extract.on('error', () => {});
28
57
 
29
58
  extract.on('entry', (header, stream, next) => {
30
- // Tarball entries: "{owner}-{repo}-{sha}/skills/real-estate-crm/SKILL.md"
31
- // Strip the first path segment (the repo prefix)
32
- const parts = header.name.split('/');
33
- parts.shift();
34
- const relativePath = parts.join('/');
59
+ if (aborted) {
60
+ stream.on('end', next);
61
+ stream.resume();
62
+ return;
63
+ }
35
64
 
36
- let fileRelative;
37
- if (skillPath === '') {
38
- if (header.type === 'file' && relativePath) {
39
- fileRelative = relativePath;
65
+ // Reject symlink/link entries outright (same posture as before).
66
+ if (header.type === 'symlink' || header.type === 'link') {
67
+ aborted = new FsError(t('download.error.unsupported_entry', { type: header.type, name: header.name }));
68
+ stream.on('end', () => next());
69
+ stream.resume();
70
+ return;
71
+ }
72
+
73
+ if (header.type !== 'file') {
74
+ stream.on('end', next);
75
+ stream.resume();
76
+ return;
77
+ }
78
+
79
+ const relativePath = stripFirstSegment(header.name);
80
+ if (!relativePath) {
81
+ stream.on('end', next);
82
+ stream.resume();
83
+ return;
84
+ }
85
+
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
+ }));
40
95
  }
96
+ if (!aborted) chunks.push(c);
97
+ });
98
+ stream.on('end', () => {
99
+ if (!aborted) entries.set(relativePath, Buffer.concat(chunks));
100
+ next();
101
+ });
102
+ stream.on('error', (err) => {
103
+ if (!aborted) aborted = err instanceof FsError ? err : new FsError(err.message);
104
+ });
105
+ });
106
+
107
+ try {
108
+ await pipeline(res.body, createGunzip(), extract);
109
+ } catch (err) {
110
+ if (aborted) throw aborted;
111
+ throw err;
112
+ }
113
+ if (aborted) throw aborted;
114
+
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;
41
127
  } else {
42
- const marker = skillPath + '/';
43
- const idx = relativePath.indexOf(marker);
44
- if (header.type === 'file' && idx !== -1) {
45
- fileRelative = relativePath.slice(idx + marker.length);
46
- }
128
+ prefix = matchSkillDir(requestedPath, owner || skill.githubOwner, skillDirs);
47
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
+ }
48
156
 
49
- if (fileRelative) {
50
- const destPath = join(destDir, fileRelative);
51
-
52
- mkdirSync(dirname(destPath), { recursive: true });
157
+ // Write files under the resolved prefix.
158
+ const destDirResolved = resolve(destDir);
159
+ let filesWritten = 0;
53
160
 
54
- const writePromise = new Promise((resolve, reject) => {
55
- stream.pipe(createWriteStream(destPath))
56
- .on('finish', resolve)
57
- .on('error', reject);
58
- });
59
- filePromises.push(writePromise);
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);
60
171
  } else {
61
- stream.resume();
172
+ continue;
62
173
  }
63
- next();
64
- });
174
+ if (!fileRelative) continue;
65
175
 
66
- await pipeline(
67
- res.body,
68
- createGunzip(),
69
- extract,
70
- );
176
+ const destPath = join(destDir, fileRelative);
177
+ const destPathResolved = resolve(destPath);
71
178
 
72
- await Promise.all(filePromises);
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
+ }
73
183
 
74
- if (filePromises.length === 0) {
75
- throw new Error(
76
- skillPath
77
- ? `ingen filer fundet for "${skillPath}" i repository.`
78
- : 'ingen filer fundet i repository.'
79
- );
184
+ mkdirSync(dirname(destPathResolved), { recursive: true });
185
+ writeFileSync(destPathResolved, buf);
186
+ filesWritten++;
80
187
  }
188
+
189
+ if (filesWritten === 0) {
190
+ throw new NoMatchError(t('download.error.no_files_matched', {
191
+ path: prefix || '<root>',
192
+ owner: skill.githubOwner,
193
+ repo: skill.githubRepo,
194
+ }));
195
+ }
196
+
197
+ return { resolvedPath: prefix, skillDirs };
81
198
  }
@@ -0,0 +1,58 @@
1
+ export const EXIT = {
2
+ OK: 0,
3
+ GENERIC: 1,
4
+ USAGE: 2,
5
+ AMBIGUOUS: 3,
6
+ NO_MATCH: 4,
7
+ NETWORK: 5,
8
+ FS: 6,
9
+ NO_AGENT: 7,
10
+ CANCELED: 130,
11
+ };
12
+
13
+ class CliError extends Error {
14
+ constructor(message, code) {
15
+ super(message);
16
+ this.name = this.constructor.name;
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ export class UsageError extends CliError {
22
+ constructor(message) { super(message, EXIT.USAGE); }
23
+ }
24
+
25
+ export class AmbiguousError extends CliError {
26
+ constructor(message, { candidates } = {}) {
27
+ super(message, EXIT.AMBIGUOUS);
28
+ this.candidates = candidates;
29
+ }
30
+ }
31
+
32
+ export class NoMatchError extends CliError {
33
+ constructor(message) { super(message, EXIT.NO_MATCH); }
34
+ }
35
+
36
+ export class NetworkError extends CliError {
37
+ constructor(message) { super(message, EXIT.NETWORK); }
38
+ }
39
+
40
+ export class FsError extends CliError {
41
+ constructor(message) { super(message, EXIT.FS); }
42
+ }
43
+
44
+ export class NoAgentError extends CliError {
45
+ constructor(message, { candidates } = {}) {
46
+ super(message, EXIT.NO_AGENT);
47
+ this.candidates = candidates;
48
+ }
49
+ }
50
+
51
+ export class CanceledError extends CliError {
52
+ constructor(message) { super(message, EXIT.CANCELED); }
53
+ }
54
+
55
+ export function exitCodeFor(err) {
56
+ if (err && typeof err.code === 'number') return err.code;
57
+ return EXIT.GENERIC;
58
+ }