agentskillsdk 0.5.2 → 0.6.0
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 +279 -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 +82 -24
- package/src/lib/errors.js +58 -0
- package/src/lib/messages/da.js +98 -0
- package/src/lib/messages/en.js +98 -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 +141 -0
- package/src/lib/ui.js +27 -116
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export const en = {
|
|
2
|
+
'banner.subtitle': 'install skills for AI agents',
|
|
3
|
+
|
|
4
|
+
'add.error.both_flags': 'cannot use both --global and --project',
|
|
5
|
+
'add.error.dest_exists': 'destination already exists: {path} (use --force to overwrite)',
|
|
6
|
+
'add.dry_run.header': 'dry-run — no files will be written',
|
|
7
|
+
'add.dry_run.would_write': 'would write to {path}',
|
|
8
|
+
'add.dry_run.scope': 'scope: {scope}',
|
|
9
|
+
'add.dry_run.source': 'source: {source}',
|
|
10
|
+
'add.step.removing_existing': 'removing existing {path}...',
|
|
11
|
+
'add.spinner.lookup': 'looking up {name}...',
|
|
12
|
+
'add.error.registry_fail': 'registry lookup failed',
|
|
13
|
+
'add.error.not_found': 'skill not found: {name}',
|
|
14
|
+
'add.suggest.header': 'did you mean:',
|
|
15
|
+
'add.suggest.browse': 'browse all skills:',
|
|
16
|
+
'add.spinner.found': 'found skill: {name}',
|
|
17
|
+
'add.prompt.how_install': 'how would you like to install?',
|
|
18
|
+
'add.prompt.all_detected': 'all detected agents ({count})',
|
|
19
|
+
'add.prompt.choose_specific': 'choose specific agents',
|
|
20
|
+
'add.prompt.select_agents': 'select agents:',
|
|
21
|
+
'add.prompt.scope': 'install scope for "{name}":',
|
|
22
|
+
'add.prompt.scope_project': 'project',
|
|
23
|
+
'add.prompt.scope_project_hint': '(local {folder}/skills/)',
|
|
24
|
+
'add.prompt.scope_global': 'global',
|
|
25
|
+
'add.prompt.scope_global_hint': '(~/{folder}/skills/)',
|
|
26
|
+
'add.spinner.downloading': 'downloading skill files...',
|
|
27
|
+
'add.error.download_fail': 'download failed',
|
|
28
|
+
'add.spinner.downloaded': 'skill files downloaded',
|
|
29
|
+
'add.spinner.resolving_path': 'resolving skill path in repo...',
|
|
30
|
+
'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
|
+
'add.source.github': 'GitHub source: {owner}/{repo}',
|
|
34
|
+
'add.source.github_with_skill': 'GitHub source: {owner}/{repo} (skill: {skill})',
|
|
35
|
+
'add.source.github_with_ref': 'GitHub source: {owner}/{repo}@{ref}',
|
|
36
|
+
'add.source.github_with_ref_and_skill': 'GitHub source: {owner}/{repo}@{ref} (skill: {skill})',
|
|
37
|
+
'add.step.agent_single': 'agent: {name}',
|
|
38
|
+
'add.step.agent_multi': 'agents: {names}',
|
|
39
|
+
|
|
40
|
+
'summary.title': 'skill installed!',
|
|
41
|
+
'summary.skill': 'skill:',
|
|
42
|
+
'summary.scope': 'scope:',
|
|
43
|
+
'summary.scope_global': 'global (all projects)',
|
|
44
|
+
'summary.scope_project': 'project (local)',
|
|
45
|
+
'summary.agent': 'agent:',
|
|
46
|
+
'summary.agents': 'agents:',
|
|
47
|
+
'summary.path': 'path:',
|
|
48
|
+
'summary.paths': 'paths:',
|
|
49
|
+
'summary.session_single': 'start a new {agent} session to use it.',
|
|
50
|
+
'summary.session_multi': 'start a new session in one of these agents to use it.',
|
|
51
|
+
'summary.learn_more': 'learn more:',
|
|
52
|
+
|
|
53
|
+
'hints.select': '↑↓ navigate · enter select · esc back',
|
|
54
|
+
'hints.checkbox': '↑↓ navigate · space toggle · enter confirm · esc back',
|
|
55
|
+
|
|
56
|
+
'list.empty': 'no skills available yet.',
|
|
57
|
+
'list.header': 'available skills:',
|
|
58
|
+
'list.install': 'install:',
|
|
59
|
+
'list.browse': 'browse:',
|
|
60
|
+
|
|
61
|
+
'api.error.unreachable': 'could not reach agentskills.dk. check your internet connection.',
|
|
62
|
+
'api.error.fetch_skills': 'failed to fetch skills: {status}',
|
|
63
|
+
'api.error.fetch_skill': 'failed to fetch skill: {status}',
|
|
64
|
+
|
|
65
|
+
'download.error.network': 'could not download skill files. check your internet connection.',
|
|
66
|
+
'download.error.github': 'GitHub download failed: {status}',
|
|
67
|
+
'download.error.traversal': 'refused unsafe tarball entry (path traversal): {name}',
|
|
68
|
+
'download.error.unsupported_entry': 'refused unsupported tarball entry type ({type}): {name}',
|
|
69
|
+
'download.error.no_files_matched': 'no files found at path "{path}" in {owner}/{repo}',
|
|
70
|
+
|
|
71
|
+
'resolve.error.no_skills': 'no SKILL.md files found in {owner}/{repo}',
|
|
72
|
+
'resolve.error.multiple_skills': '{owner}/{repo} contains multiple skills — pass --skill <name>:\n{candidates}',
|
|
73
|
+
'resolve.error.skill_not_in_repo': 'skill "{skill}" not found in {owner}/{repo}. available skills:\n{candidates}',
|
|
74
|
+
|
|
75
|
+
'cli.description': 'install agent skills from agentskills.dk',
|
|
76
|
+
'cli.add.description': 'install a skill in your project',
|
|
77
|
+
'cli.add.arg': 'skill name (or owner/repo for GitHub)',
|
|
78
|
+
'cli.add.skill_option': 'subpath in repo, e.g. skills/twitter',
|
|
79
|
+
'cli.add.global_option': 'install globally in ~/.claude/skills/',
|
|
80
|
+
'cli.add.project_option': 'install in project .claude/skills/ (default)',
|
|
81
|
+
'cli.add.yes_option': 'skip prompts (required in agent contexts)',
|
|
82
|
+
'cli.add.non_interactive_option': 'alias for --yes',
|
|
83
|
+
'cli.add.agent_option': "target agent slug(s), e.g. -a claude-code -a cursor (use '*' for all)",
|
|
84
|
+
'cli.add.all_option': "target every known agent (alias for -a '*')",
|
|
85
|
+
'cli.add.json_option': 'machine-readable JSON on stdout',
|
|
86
|
+
'cli.add.quiet_option': 'suppress banner and spinners',
|
|
87
|
+
'cli.add.lang_option': 'UI language (en|da)',
|
|
88
|
+
'cli.add.dry_run_option': 'show planned writes without touching disk',
|
|
89
|
+
'cli.add.force_option': 'overwrite existing skill directory',
|
|
90
|
+
'cli.list.description': 'list all available skills',
|
|
91
|
+
|
|
92
|
+
'error.no_tty_no_yes': 'non-interactive context: pass --yes (-y) and required flags',
|
|
93
|
+
'error.unknown_agent_slug': 'unknown agent slug: {slug}',
|
|
94
|
+
'error.ambiguous_agent': 'multiple agents detected — pass --agent to disambiguate ({candidates})',
|
|
95
|
+
'error.no_agent_target': 'no agent target — pass --agent or run in a project with one of: {candidates}',
|
|
96
|
+
'error.canceled': 'cancelled',
|
|
97
|
+
'agent.folder_created': 'created {folder} for {name}',
|
|
98
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { en } from './en.js';
|
|
2
|
+
import { da } from './da.js';
|
|
3
|
+
|
|
4
|
+
const LOCALES = { en, da };
|
|
5
|
+
let currentLocale = 'en';
|
|
6
|
+
let warnedUnknown = false;
|
|
7
|
+
|
|
8
|
+
export function setLocale(locale) {
|
|
9
|
+
if (!locale) return;
|
|
10
|
+
if (LOCALES[locale]) {
|
|
11
|
+
currentLocale = locale;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!warnedUnknown) {
|
|
15
|
+
process.stderr.write(`agentskillsdk: unknown --lang "${locale}", falling back to en\n`);
|
|
16
|
+
warnedUnknown = true;
|
|
17
|
+
}
|
|
18
|
+
currentLocale = 'en';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getLocale() {
|
|
22
|
+
return currentLocale;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolution order: explicit flag > AGENTSKILLSDK_LANG > 'en'.
|
|
26
|
+
export function resolveLocale({ flag, env = process.env } = {}) {
|
|
27
|
+
return flag || env.AGENTSKILLSDK_LANG || 'en';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function t(key, params) {
|
|
31
|
+
let value = LOCALES[currentLocale]?.[key];
|
|
32
|
+
if (value === undefined && currentLocale !== 'en') value = LOCALES.en[key];
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
if (process.env.NODE_ENV === 'test') {
|
|
35
|
+
process.stderr.write(`agentskillsdk: missing message key "${key}"\n`);
|
|
36
|
+
}
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
39
|
+
if (!params) return value;
|
|
40
|
+
return value.replace(/\{(\w+)\}/g, (_, k) =>
|
|
41
|
+
params[k] !== undefined ? String(params[k]) : `{${k}}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Test-only helper to reset module state between cases.
|
|
46
|
+
export function __resetForTests() {
|
|
47
|
+
currentLocale = 'en';
|
|
48
|
+
warnedUnknown = false;
|
|
49
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { formatBanner, formatCompletionSummary } from './ui.js';
|
|
4
|
+
import { t } from './messages/index.js';
|
|
5
|
+
|
|
6
|
+
// One predicate, six inputs, applied once. See plan §"Decision matrix".
|
|
7
|
+
export function shouldShowBanner(options = {}, runtime = {}, env = process.env, stdout = process.stdout) {
|
|
8
|
+
if (options.json) return false;
|
|
9
|
+
if (options.quiet) return false;
|
|
10
|
+
if (options.nonInteractive) return false;
|
|
11
|
+
if (runtime.isAgent) return false;
|
|
12
|
+
if (env.CI) return false;
|
|
13
|
+
if (!stdout.isTTY) return false;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function noopSpinner() {
|
|
18
|
+
// Matches the subset of the ora API the CLI calls.
|
|
19
|
+
const noop = {
|
|
20
|
+
start() { return noop; },
|
|
21
|
+
succeed() { return noop; },
|
|
22
|
+
fail() { return noop; },
|
|
23
|
+
stop() { return noop; },
|
|
24
|
+
text: '',
|
|
25
|
+
};
|
|
26
|
+
return noop;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createOutput(options = {}, runtime = {}, {
|
|
30
|
+
stdout = process.stdout,
|
|
31
|
+
stderr = process.stderr,
|
|
32
|
+
env = process.env,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const json = !!options.json;
|
|
35
|
+
const quiet = !!options.quiet || !!runtime.isAgent;
|
|
36
|
+
const showBanner = shouldShowBanner(options, runtime, env, stdout);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
banner() {
|
|
40
|
+
if (!showBanner) return;
|
|
41
|
+
stderr.write('\n' + formatBanner() + '\n\n');
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
step(text) {
|
|
45
|
+
if (json || quiet) return;
|
|
46
|
+
stderr.write(chalk.green(' ✓') + ` ${text}\n`);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
info(text) {
|
|
50
|
+
if (json || quiet) return;
|
|
51
|
+
stderr.write(` ${text}\n`);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
error(text, payload) {
|
|
55
|
+
if (json) {
|
|
56
|
+
stderr.write(JSON.stringify({ error: text, ...(payload || {}) }) + '\n');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
stderr.write(chalk.red(` ${text}`) + '\n');
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
success(payload) {
|
|
63
|
+
if (json) {
|
|
64
|
+
const safe = {
|
|
65
|
+
skill: payload.skill,
|
|
66
|
+
source: payload.source,
|
|
67
|
+
agents: (payload.agents || []).map(a => ({ slug: a.slug, path: a.path })),
|
|
68
|
+
scope: payload.scope,
|
|
69
|
+
};
|
|
70
|
+
if (payload.files !== undefined) safe.files = payload.files;
|
|
71
|
+
if (payload.duration_ms !== undefined) safe.duration_ms = payload.duration_ms;
|
|
72
|
+
stdout.write(JSON.stringify(safe) + '\n');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (quiet) {
|
|
76
|
+
const { skill, agents, scope } = payload;
|
|
77
|
+
const agentSlugs = (agents || []).map(a => a.slug || a.name).join(',');
|
|
78
|
+
stdout.write(`installed ${skill} for ${agentSlugs} (${scope})\n`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
stderr.write('\n' + formatCompletionSummary({
|
|
82
|
+
skillName: payload.skill,
|
|
83
|
+
scope: payload.scope,
|
|
84
|
+
agents: payload.agents,
|
|
85
|
+
isGithub: payload.isGithub,
|
|
86
|
+
namespace: payload.namespace,
|
|
87
|
+
}) + '\n\n');
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
dryRun(payload) {
|
|
91
|
+
if (json) {
|
|
92
|
+
const safe = {
|
|
93
|
+
dry_run: true,
|
|
94
|
+
skill: payload.skill,
|
|
95
|
+
source: payload.source,
|
|
96
|
+
agents: (payload.agents || []).map(a => ({ slug: a.slug, path: a.path })),
|
|
97
|
+
scope: payload.scope,
|
|
98
|
+
};
|
|
99
|
+
stdout.write(JSON.stringify(safe) + '\n');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (quiet) {
|
|
103
|
+
const { skill, agents, scope } = payload;
|
|
104
|
+
const agentSlugs = (agents || []).map(a => a.slug || a.name).join(',');
|
|
105
|
+
stdout.write(`would install ${skill} for ${agentSlugs} (${scope})\n`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
stderr.write('\n' + chalk.yellow(` ${t('add.dry_run.header')}`) + '\n');
|
|
109
|
+
const src = payload.source || {};
|
|
110
|
+
let srcStr;
|
|
111
|
+
if (src.type === 'github') {
|
|
112
|
+
srcStr = `${src.owner}/${src.repo}`;
|
|
113
|
+
if (src.ref) srcStr += `@${src.ref}`;
|
|
114
|
+
if (src.path) srcStr += ` (${src.path})`;
|
|
115
|
+
} else {
|
|
116
|
+
srcStr = src.namespace ? `registry (${src.namespace})` : 'registry';
|
|
117
|
+
}
|
|
118
|
+
stderr.write(` ${t('add.dry_run.source', { source: srcStr })}\n`);
|
|
119
|
+
stderr.write(` ${t('add.dry_run.scope', { scope: payload.scope })}\n`);
|
|
120
|
+
for (const a of payload.agents || []) {
|
|
121
|
+
stderr.write(` ${chalk.cyan('•')} ${t('add.dry_run.would_write', { path: a.path })}\n`);
|
|
122
|
+
}
|
|
123
|
+
stderr.write('\n');
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
json(obj) {
|
|
127
|
+
stdout.write(JSON.stringify(obj) + '\n');
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
spinner(text) {
|
|
131
|
+
if (json || quiet) return noopSpinner();
|
|
132
|
+
return ora({ text, indent: 2, stream: stderr }).start();
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
showBanner,
|
|
136
|
+
isQuiet: quiet,
|
|
137
|
+
isJson: json,
|
|
138
|
+
};
|
|
139
|
+
}
|
package/src/lib/parse-source.js
CHANGED
|
@@ -1,7 +1,117 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { UsageError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
// `parseSource(input)` returns:
|
|
4
|
+
// { kind: 'registry', name } (bare slug)
|
|
5
|
+
// { kind: 'github', owner, repo, ref?, path?, skill? } (any GitHub form)
|
|
6
|
+
// or throws UsageError.
|
|
7
|
+
//
|
|
8
|
+
// Supported GitHub shapes:
|
|
9
|
+
// owner/repo
|
|
10
|
+
// owner/repo@skill
|
|
11
|
+
// owner/repo#ref
|
|
12
|
+
// owner/repo#ref@skill
|
|
13
|
+
// https://github.com/owner/repo[.git]
|
|
14
|
+
// https://github.com/owner/repo/tree/<ref>/<sub/path>
|
|
15
|
+
//
|
|
16
|
+
// SSH (`git@github.com:owner/repo`) and local paths are intentionally rejected
|
|
17
|
+
// for v1 — see the plan's Scope Boundaries.
|
|
18
|
+
|
|
19
|
+
function rejectTraversal(value, label) {
|
|
20
|
+
if (!value) return;
|
|
21
|
+
const segments = value.split(/[\\/]/);
|
|
22
|
+
if (segments.some(s => s === '..' || s === '.')) {
|
|
23
|
+
throw new UsageError(`invalid ${label}: path traversal not allowed`);
|
|
5
24
|
}
|
|
6
|
-
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseSlugTail(tail) {
|
|
28
|
+
// tail looks like "" | "#ref" | "@skill" | "#ref@skill"
|
|
29
|
+
let ref;
|
|
30
|
+
let skill;
|
|
31
|
+
if (tail.startsWith('#')) {
|
|
32
|
+
const rest = tail.slice(1);
|
|
33
|
+
const at = rest.indexOf('@');
|
|
34
|
+
if (at === -1) {
|
|
35
|
+
ref = rest;
|
|
36
|
+
} else {
|
|
37
|
+
ref = rest.slice(0, at);
|
|
38
|
+
skill = rest.slice(at + 1);
|
|
39
|
+
}
|
|
40
|
+
if (ref === '') throw new UsageError('invalid source: empty ref after "#"');
|
|
41
|
+
if (at !== -1 && skill === '') throw new UsageError('invalid source: empty skill after "@"');
|
|
42
|
+
} else if (tail.startsWith('@')) {
|
|
43
|
+
skill = tail.slice(1);
|
|
44
|
+
if (skill === '') throw new UsageError('invalid source: empty skill after "@"');
|
|
45
|
+
} else if (tail.length > 0) {
|
|
46
|
+
throw new UsageError(`invalid source: unexpected suffix "${tail}"`);
|
|
47
|
+
}
|
|
48
|
+
return { ref: ref || undefined, skill: skill || undefined };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseSource(input) {
|
|
52
|
+
if (typeof input !== 'string' || input.trim() === '') {
|
|
53
|
+
throw new UsageError('empty source: pass a skill name or owner/repo');
|
|
54
|
+
}
|
|
55
|
+
const trimmed = input.trim();
|
|
56
|
+
|
|
57
|
+
if (trimmed.startsWith('git@')) {
|
|
58
|
+
throw new UsageError('SSH GitHub URLs not yet supported — use https://github.com/owner/repo');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
62
|
+
// `new URL()` silently normalizes ../ out of the path — check the raw
|
|
63
|
+
// input first so a crafted URL like ".../tree/main/../etc" is rejected.
|
|
64
|
+
rejectTraversal(trimmed.split('://')[1] || trimmed, 'URL');
|
|
65
|
+
let url;
|
|
66
|
+
try { url = new URL(trimmed); }
|
|
67
|
+
catch { throw new UsageError(`invalid URL: ${trimmed}`); }
|
|
68
|
+
|
|
69
|
+
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') {
|
|
70
|
+
throw new UsageError(`only github.com URLs are supported, got ${url.hostname}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
74
|
+
if (parts.length < 2) throw new UsageError(`URL must include owner/repo: ${trimmed}`);
|
|
75
|
+
const owner = parts[0];
|
|
76
|
+
let repo = parts[1].replace(/\.git$/, '');
|
|
77
|
+
|
|
78
|
+
const out = { kind: 'github', owner, repo };
|
|
79
|
+
|
|
80
|
+
if (parts[2] === 'tree' && parts.length >= 4) {
|
|
81
|
+
out.ref = parts[3];
|
|
82
|
+
if (parts.length > 4) {
|
|
83
|
+
const subPath = parts.slice(4).join('/');
|
|
84
|
+
rejectTraversal(subPath, 'path');
|
|
85
|
+
out.path = subPath;
|
|
86
|
+
}
|
|
87
|
+
} else if (parts.length > 2) {
|
|
88
|
+
throw new UsageError(`unsupported URL shape: ${trimmed}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!trimmed.includes('/')) {
|
|
95
|
+
return { kind: 'registry', name: trimmed };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// owner/repo[#ref][@skill]
|
|
99
|
+
const tailStart = Math.min(
|
|
100
|
+
trimmed.indexOf('#') === -1 ? Infinity : trimmed.indexOf('#'),
|
|
101
|
+
trimmed.indexOf('@') === -1 ? Infinity : trimmed.indexOf('@'),
|
|
102
|
+
);
|
|
103
|
+
const head = tailStart === Infinity ? trimmed : trimmed.slice(0, tailStart);
|
|
104
|
+
const tail = tailStart === Infinity ? '' : trimmed.slice(tailStart);
|
|
105
|
+
|
|
106
|
+
const parts = head.split('/');
|
|
107
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
108
|
+
throw new UsageError(`invalid source: expected owner/repo, got "${input}"`);
|
|
109
|
+
}
|
|
110
|
+
rejectTraversal(head, 'source');
|
|
111
|
+
|
|
112
|
+
const { ref, skill } = parseSlugTail(tail);
|
|
113
|
+
const out = { kind: 'github', owner: parts[0], repo: parts[1] };
|
|
114
|
+
if (ref) out.ref = ref;
|
|
115
|
+
if (skill) out.skill = skill;
|
|
116
|
+
return out;
|
|
7
117
|
}
|
package/src/lib/prompt.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { UsageError } from './errors.js';
|
|
3
|
+
import { t } from './messages/index.js';
|
|
2
4
|
|
|
3
5
|
const orange = chalk.hex('#FF8C00');
|
|
4
6
|
|
|
7
|
+
function assertTty() {
|
|
8
|
+
if (!process.stdin.isTTY) {
|
|
9
|
+
throw new UsageError(t('error.no_tty_no_yes'));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
// --- strip ANSI for width calculation ---
|
|
6
14
|
|
|
7
15
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
@@ -19,6 +27,7 @@ function stripAnsi(str) {
|
|
|
19
27
|
* @returns {Promise<any>} selected value
|
|
20
28
|
*/
|
|
21
29
|
export function selectPrompt(question, choices, { defaultIndex = 0 } = {}) {
|
|
30
|
+
assertTty();
|
|
22
31
|
return new Promise((resolve) => {
|
|
23
32
|
let selected = defaultIndex;
|
|
24
33
|
const { stdin, stdout } = process;
|
|
@@ -43,7 +52,7 @@ export function selectPrompt(question, choices, { defaultIndex = 0 } = {}) {
|
|
|
43
52
|
return ` ${marker} ${label}${hint}`;
|
|
44
53
|
});
|
|
45
54
|
lines.push('');
|
|
46
|
-
lines.push(chalk.dim(
|
|
55
|
+
lines.push(chalk.dim(` ${t('hints.select')}`));
|
|
47
56
|
return lines;
|
|
48
57
|
}
|
|
49
58
|
|
|
@@ -134,10 +143,10 @@ export function selectPrompt(question, choices, { defaultIndex = 0 } = {}) {
|
|
|
134
143
|
* @param {{ label: string, value: any }[]} choices
|
|
135
144
|
* @returns {Promise<any[]|null>} array of selected values, or null if Esc
|
|
136
145
|
*/
|
|
137
|
-
export function checkboxPrompt(question, choices
|
|
146
|
+
export function checkboxPrompt(question, choices) {
|
|
147
|
+
assertTty();
|
|
138
148
|
return new Promise((resolve) => {
|
|
139
149
|
let cursor = 0;
|
|
140
|
-
let scrollOffset = 0;
|
|
141
150
|
const checked = new Array(choices.length).fill(false);
|
|
142
151
|
const { stdin, stdout } = process;
|
|
143
152
|
const cols = stdout.columns || 80;
|
|
@@ -152,22 +161,13 @@ export function checkboxPrompt(question, choices, { pageSize = 10 } = {}) {
|
|
|
152
161
|
}
|
|
153
162
|
|
|
154
163
|
function render() {
|
|
155
|
-
const
|
|
156
|
-
const lines = visible.map((c, vi) => {
|
|
157
|
-
const i = vi + scrollOffset;
|
|
164
|
+
const lines = choices.map((c, i) => {
|
|
158
165
|
const box = checked[i] ? orange('◼') : '◻';
|
|
159
166
|
const label = i === cursor ? orange(c.label) : c.label;
|
|
160
167
|
return ` ${box} ${label}`;
|
|
161
168
|
});
|
|
162
|
-
if (scrollOffset > 0) {
|
|
163
|
-
lines.unshift(chalk.dim(` ↑ ${scrollOffset} mere`));
|
|
164
|
-
}
|
|
165
|
-
const remaining = choices.length - (scrollOffset + pageSize);
|
|
166
|
-
if (remaining > 0) {
|
|
167
|
-
lines.push(chalk.dim(` ↓ ${remaining} mere`));
|
|
168
|
-
}
|
|
169
169
|
lines.push('');
|
|
170
|
-
lines.push(chalk.dim(
|
|
170
|
+
lines.push(chalk.dim(` ${t('hints.checkbox')}`));
|
|
171
171
|
return lines;
|
|
172
172
|
}
|
|
173
173
|
|
|
@@ -234,20 +234,14 @@ export function checkboxPrompt(question, choices, { pageSize = 10 } = {}) {
|
|
|
234
234
|
// Up / Left
|
|
235
235
|
else if (key === '\x1b[A' || key === '\x1b[D') {
|
|
236
236
|
cursor = (cursor - 1 + choices.length) % choices.length;
|
|
237
|
-
if (cursor === choices.length - 1) scrollOffset = Math.max(0, choices.length - pageSize);
|
|
238
237
|
}
|
|
239
238
|
// Down / Right
|
|
240
239
|
else if (key === '\x1b[B' || key === '\x1b[C') {
|
|
241
240
|
cursor = (cursor + 1) % choices.length;
|
|
242
|
-
if (cursor === 0) scrollOffset = 0;
|
|
243
241
|
} else {
|
|
244
242
|
return;
|
|
245
243
|
}
|
|
246
244
|
|
|
247
|
-
// Keep cursor within the visible scroll window
|
|
248
|
-
if (cursor < scrollOffset) scrollOffset = cursor;
|
|
249
|
-
if (cursor >= scrollOffset + pageSize) scrollOffset = cursor - pageSize + 1;
|
|
250
|
-
|
|
251
245
|
// Redraw
|
|
252
246
|
const upCount = totalPhysicalLines(prevLines) - 1;
|
|
253
247
|
if (upCount > 0) stdout.write(`\x1b[${upCount}A`);
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
// Picks the best matching skill directory for `requested` from `candidates`.
|
|
50
|
+
// 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
|
|
55
|
+
// Returns the matched directory path, or null if no match.
|
|
56
|
+
//
|
|
57
|
+
// Ported from the agentskills website's `matchSkillToPath` so behavior stays
|
|
58
|
+
// in sync between registry indexing and CLI installs.
|
|
59
|
+
export function matchSkillDir(requested, owner, candidates) {
|
|
60
|
+
if (candidates.length === 0) return null;
|
|
61
|
+
|
|
62
|
+
const wantLower = requested.toLowerCase();
|
|
63
|
+
|
|
64
|
+
// 1. Exact directory match (the last path segment).
|
|
65
|
+
const exact = candidates.find(p => {
|
|
66
|
+
const last = p.split('/').pop();
|
|
67
|
+
return last === requested || last.toLowerCase() === wantLower;
|
|
68
|
+
});
|
|
69
|
+
if (exact) return exact;
|
|
70
|
+
|
|
71
|
+
// 2. Same-path match (user passed the full path already).
|
|
72
|
+
const fullMatch = candidates.find(p => p === requested || p.toLowerCase() === wantLower);
|
|
73
|
+
if (fullMatch) return fullMatch;
|
|
74
|
+
|
|
75
|
+
// 3. Strip common owner-style prefixes.
|
|
76
|
+
const prefixes = [`${owner}-`, `${owner.toLowerCase()}-`, 'vercel-', 'anthropic-', 'claude-'];
|
|
77
|
+
let stripped = requested;
|
|
78
|
+
for (const pre of prefixes) {
|
|
79
|
+
if (wantLower.startsWith(pre.toLowerCase())) {
|
|
80
|
+
stripped = requested.slice(pre.length);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (stripped !== requested) {
|
|
85
|
+
const m = candidates.find(p => {
|
|
86
|
+
const last = p.split('/').pop().toLowerCase();
|
|
87
|
+
return last === stripped.toLowerCase();
|
|
88
|
+
});
|
|
89
|
+
if (m) return m;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4. Fuzzy: collapse to alphanumerics, lowercase, then compare.
|
|
93
|
+
const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
94
|
+
const wantNorm = normalize(requested);
|
|
95
|
+
const fuzzy = candidates.find(p => normalize(p.split('/').pop()) === wantNorm);
|
|
96
|
+
if (fuzzy) return fuzzy;
|
|
97
|
+
|
|
98
|
+
// 5. Single-skill fallback.
|
|
99
|
+
if (candidates.length === 1) return candidates[0];
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
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
|
+
}
|