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/bin/cli.js +49 -1
- package/package.json +5 -2
- package/src/commands/add.js +273 -173
- package/src/commands/list.js +18 -16
- package/src/index.js +91 -19
- package/src/lib/api.js +14 -11
- package/src/lib/detect-agent.js +45 -44
- package/src/lib/detect-runtime.js +63 -0
- package/src/lib/download.js +165 -48
- package/src/lib/errors.js +58 -0
- package/src/lib/messages/da.js +96 -0
- package/src/lib/messages/en.js +96 -0
- package/src/lib/messages/index.js +49 -0
- package/src/lib/output.js +139 -0
- package/src/lib/parse-source.js +115 -5
- package/src/lib/prompt.js +14 -20
- package/src/lib/resolve-github-path.js +55 -0
- package/src/lib/ui.js +27 -116
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
+
async function fetchJson(url) {
|
|
4
7
|
let res;
|
|
5
8
|
try {
|
|
6
|
-
res = await fetch(
|
|
9
|
+
res = await fetch(url);
|
|
7
10
|
} catch {
|
|
8
|
-
throw new
|
|
11
|
+
throw new NetworkError(t('api.error.unreachable'));
|
|
9
12
|
}
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
25
|
+
if (!res.ok) throw new NetworkError(t('api.error.fetch_skill', { status: res.status }));
|
|
23
26
|
return res.json();
|
|
24
27
|
}
|
package/src/lib/detect-agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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('
|
|
27
|
-
makeAgent('
|
|
28
|
-
makeAgent('
|
|
29
|
-
makeAgent('
|
|
30
|
-
makeAgent('
|
|
31
|
-
makeAgent('
|
|
32
|
-
makeAgent('
|
|
33
|
-
makeAgent('
|
|
34
|
-
makeAgent('
|
|
35
|
-
makeAgent('
|
|
36
|
-
makeAgent('
|
|
37
|
-
makeAgent('
|
|
38
|
-
makeAgent('
|
|
39
|
-
makeAgent('
|
|
40
|
-
makeAgent('
|
|
41
|
-
makeAgent('
|
|
42
|
-
makeAgent('
|
|
43
|
-
makeAgent('
|
|
44
|
-
makeAgent('
|
|
45
|
-
makeAgent('
|
|
46
|
-
makeAgent('
|
|
47
|
-
makeAgent('
|
|
48
|
-
makeAgent('
|
|
49
|
-
makeAgent('
|
|
50
|
-
makeAgent('
|
|
51
|
-
makeAgent('
|
|
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
|
+
}
|
package/src/lib/download.js
CHANGED
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
import { pipeline } from 'node:stream/promises';
|
|
2
2
|
import { createGunzip } from 'node:zlib';
|
|
3
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
if (aborted) {
|
|
60
|
+
stream.on('end', next);
|
|
61
|
+
stream.resume();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
35
64
|
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
172
|
+
continue;
|
|
62
173
|
}
|
|
63
|
-
|
|
64
|
-
});
|
|
174
|
+
if (!fileRelative) continue;
|
|
65
175
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
createGunzip(),
|
|
69
|
-
extract,
|
|
70
|
-
);
|
|
176
|
+
const destPath = join(destDir, fileRelative);
|
|
177
|
+
const destPathResolved = resolve(destPath);
|
|
71
178
|
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|