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.
@@ -0,0 +1,96 @@
1
+ export const da = {
2
+ 'banner.subtitle': 'installer skills til AI-agenter',
3
+
4
+ 'add.error.both_flags': 'kan ikke bruge både --global og --project. vælg én.',
5
+ 'add.error.dest_exists': 'destinationen findes allerede: {path} (brug --force for at overskrive)',
6
+ 'add.dry_run.header': 'dry-run — ingen filer bliver skrevet',
7
+ 'add.dry_run.would_write': 'ville skrive til {path}',
8
+ 'add.dry_run.scope': 'omfang: {scope}',
9
+ 'add.dry_run.source': 'kilde: {source}',
10
+ 'add.step.removing_existing': 'fjerner eksisterende {path}...',
11
+ 'add.spinner.lookup': 'søger efter {name}...',
12
+ 'add.error.registry_fail': 'opslag i registret fejlede',
13
+ 'add.error.not_found': 'skill "{name}" ikke fundet',
14
+ 'add.suggest.header': 'mente du:',
15
+ 'add.suggest.browse': 'se alle skills:',
16
+ 'add.spinner.found': 'fundet skill: {name}',
17
+ 'add.prompt.how_install': 'hvordan vil du installere?',
18
+ 'add.prompt.all_detected': 'alle fundne agenter ({count})',
19
+ 'add.prompt.choose_specific': 'vælg specifikke agenter',
20
+ 'add.prompt.select_agents': 'vælg agenter:',
21
+ 'add.prompt.scope': 'hvor skal "{name}" installeres?',
22
+ 'add.prompt.scope_project': 'projekt',
23
+ 'add.prompt.scope_project_hint': '(lokalt {folder}/skills/)',
24
+ 'add.prompt.scope_global': 'globalt',
25
+ 'add.prompt.scope_global_hint': '(~/{folder}/skills/)',
26
+ 'add.spinner.downloading': 'downloader skill-filer...',
27
+ 'add.error.download_fail': 'download fejlede',
28
+ 'add.spinner.downloaded': 'skill-filer downloadet',
29
+ 'add.spinner.resolved_path': 'matchede "{from}" → "{to}"',
30
+ 'add.source.github': 'GitHub-kilde: {owner}/{repo}',
31
+ 'add.source.github_with_skill': 'GitHub-kilde: {owner}/{repo} (skill: {skill})',
32
+ 'add.source.github_with_ref': 'GitHub-kilde: {owner}/{repo}@{ref}',
33
+ 'add.source.github_with_ref_and_skill': 'GitHub-kilde: {owner}/{repo}@{ref} (skill: {skill})',
34
+ 'add.step.agent_single': 'agent: {name}',
35
+ 'add.step.agent_multi': 'agenter: {names}',
36
+
37
+ 'summary.title': 'skill installeret!',
38
+ 'summary.skill': 'skill:',
39
+ 'summary.scope': 'omfang:',
40
+ 'summary.scope_global': 'globalt (alle projekter)',
41
+ 'summary.scope_project': 'projekt (lokalt)',
42
+ 'summary.agent': 'agent:',
43
+ 'summary.agents': 'agenter:',
44
+ 'summary.path': 'sti:',
45
+ 'summary.paths': 'stier:',
46
+ 'summary.session_single': 'start en ny {agent}-session for at bruge den.',
47
+ 'summary.session_multi': 'start en ny session i en af disse agenter for at bruge den.',
48
+ 'summary.learn_more': 'læs mere:',
49
+
50
+ 'hints.select': '↑↓ naviger · enter vælg · esc tilbage',
51
+ 'hints.checkbox': '↑↓ naviger · space skift · enter bekræft · esc tilbage',
52
+
53
+ 'list.empty': 'ingen skills tilgængelige endnu.',
54
+ 'list.header': 'tilgængelige skills:',
55
+ 'list.install': 'installer:',
56
+ 'list.browse': 'gennemse:',
57
+
58
+ 'api.error.unreachable': 'kunne ikke kontakte agentskills.dk. tjek din internetforbindelse.',
59
+ 'api.error.fetch_skills': 'kunne ikke hente skills: {status}',
60
+ 'api.error.fetch_skill': 'kunne ikke hente skill: {status}',
61
+
62
+ 'download.error.network': 'kunne ikke downloade skill-filer. tjek din internetforbindelse.',
63
+ 'download.error.github': 'GitHub-download fejlede: {status}',
64
+ 'download.error.traversal': 'afviste usikker tarball-fil (sti-traversal): {name}',
65
+ 'download.error.unsupported_entry': 'afviste ikke-understøttet tarball-fil ({type}): {name}',
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}',
68
+
69
+ 'resolve.error.no_skills': 'ingen SKILL.md-filer fundet i {owner}/{repo}',
70
+ 'resolve.error.multiple_skills': '{owner}/{repo} indeholder flere skills — angiv --skill <navn>:\n{candidates}',
71
+ 'resolve.error.skill_not_in_repo': 'skill "{skill}" findes ikke i {owner}/{repo}. tilgængelige:\n{candidates}',
72
+
73
+ 'cli.description': 'installer agent-skills fra agentskills.dk',
74
+ 'cli.add.description': 'installer en skill i dit projekt',
75
+ 'cli.add.arg': 'navn på skill (eller owner/repo for GitHub)',
76
+ 'cli.add.skill_option': 'sti i repo, f.eks. skills/twitter',
77
+ 'cli.add.global_option': 'installer globalt i ~/.claude/skills/',
78
+ 'cli.add.project_option': 'installer i projekt .claude/skills/ (standard)',
79
+ 'cli.add.yes_option': 'spring prompts over (påkrævet i agent-kontekster)',
80
+ 'cli.add.non_interactive_option': 'alias for --yes',
81
+ 'cli.add.agent_option': "agent-slug(s), f.eks. -a claude-code -a cursor (brug '*' for alle)",
82
+ 'cli.add.all_option': "alle kendte agenter (alias for -a '*')",
83
+ 'cli.add.json_option': 'maskinlæsbar JSON på stdout',
84
+ 'cli.add.quiet_option': 'undertryk banner og spinners',
85
+ 'cli.add.lang_option': 'UI-sprog (en|da)',
86
+ 'cli.add.dry_run_option': 'vis planlagte skrivninger uden at røre disken',
87
+ 'cli.add.force_option': 'overskriv eksisterende skill-mappe',
88
+ 'cli.list.description': 'vis alle tilgængelige skills',
89
+
90
+ 'error.no_tty_no_yes': 'ikke-interaktiv kontekst: brug --yes (-y) og påkrævede flag',
91
+ 'error.unknown_agent_slug': 'ukendt agent-slug: {slug}',
92
+ 'error.ambiguous_agent': 'flere agenter opdaget — brug --agent for at vælge ({candidates})',
93
+ 'error.no_agent_target': 'ingen agent-target — brug --agent eller kør i et projekt med en af: {candidates}',
94
+ 'error.canceled': 'annulleret',
95
+ 'agent.folder_created': 'oprettede {folder} til {name}',
96
+ };
@@ -0,0 +1,96 @@
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.resolved_path': 'resolved "{from}" → "{to}"',
30
+ 'add.source.github': 'GitHub source: {owner}/{repo}',
31
+ 'add.source.github_with_skill': 'GitHub source: {owner}/{repo} (skill: {skill})',
32
+ 'add.source.github_with_ref': 'GitHub source: {owner}/{repo}@{ref}',
33
+ 'add.source.github_with_ref_and_skill': 'GitHub source: {owner}/{repo}@{ref} (skill: {skill})',
34
+ 'add.step.agent_single': 'agent: {name}',
35
+ 'add.step.agent_multi': 'agents: {names}',
36
+
37
+ 'summary.title': 'skill installed!',
38
+ 'summary.skill': 'skill:',
39
+ 'summary.scope': 'scope:',
40
+ 'summary.scope_global': 'global (all projects)',
41
+ 'summary.scope_project': 'project (local)',
42
+ 'summary.agent': 'agent:',
43
+ 'summary.agents': 'agents:',
44
+ 'summary.path': 'path:',
45
+ 'summary.paths': 'paths:',
46
+ 'summary.session_single': 'start a new {agent} session to use it.',
47
+ 'summary.session_multi': 'start a new session in one of these agents to use it.',
48
+ 'summary.learn_more': 'learn more:',
49
+
50
+ 'hints.select': '↑↓ navigate · enter select · esc back',
51
+ 'hints.checkbox': '↑↓ navigate · space toggle · enter confirm · esc back',
52
+
53
+ 'list.empty': 'no skills available yet.',
54
+ 'list.header': 'available skills:',
55
+ 'list.install': 'install:',
56
+ 'list.browse': 'browse:',
57
+
58
+ 'api.error.unreachable': 'could not reach agentskills.dk. check your internet connection.',
59
+ 'api.error.fetch_skills': 'failed to fetch skills: {status}',
60
+ 'api.error.fetch_skill': 'failed to fetch skill: {status}',
61
+
62
+ 'download.error.network': 'could not download skill files. check your internet connection.',
63
+ 'download.error.github': 'GitHub download failed: {status}',
64
+ 'download.error.traversal': 'refused unsafe tarball entry (path traversal): {name}',
65
+ 'download.error.unsupported_entry': 'refused unsupported tarball entry type ({type}): {name}',
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}',
68
+
69
+ 'resolve.error.no_skills': 'no SKILL.md files found in {owner}/{repo}',
70
+ 'resolve.error.multiple_skills': '{owner}/{repo} contains multiple skills — pass --skill <name>:\n{candidates}',
71
+ 'resolve.error.skill_not_in_repo': 'skill "{skill}" not found in {owner}/{repo}. available skills:\n{candidates}',
72
+
73
+ 'cli.description': 'install agent skills from agentskills.dk',
74
+ 'cli.add.description': 'install a skill in your project',
75
+ 'cli.add.arg': 'skill name (or owner/repo for GitHub)',
76
+ 'cli.add.skill_option': 'subpath in repo, e.g. skills/twitter',
77
+ 'cli.add.global_option': 'install globally in ~/.claude/skills/',
78
+ 'cli.add.project_option': 'install in project .claude/skills/ (default)',
79
+ 'cli.add.yes_option': 'skip prompts (required in agent contexts)',
80
+ 'cli.add.non_interactive_option': 'alias for --yes',
81
+ 'cli.add.agent_option': "target agent slug(s), e.g. -a claude-code -a cursor (use '*' for all)",
82
+ 'cli.add.all_option': "target every known agent (alias for -a '*')",
83
+ 'cli.add.json_option': 'machine-readable JSON on stdout',
84
+ 'cli.add.quiet_option': 'suppress banner and spinners',
85
+ 'cli.add.lang_option': 'UI language (en|da)',
86
+ 'cli.add.dry_run_option': 'show planned writes without touching disk',
87
+ 'cli.add.force_option': 'overwrite existing skill directory',
88
+ 'cli.list.description': 'list all available skills',
89
+
90
+ 'error.no_tty_no_yes': 'non-interactive context: pass --yes (-y) and required flags',
91
+ 'error.unknown_agent_slug': 'unknown agent slug: {slug}',
92
+ 'error.ambiguous_agent': 'multiple agents detected — pass --agent to disambiguate ({candidates})',
93
+ 'error.no_agent_target': 'no agent target — pass --agent or run in a project with one of: {candidates}',
94
+ 'error.canceled': 'cancelled',
95
+ 'agent.folder_created': 'created {folder} for {name}',
96
+ };
@@ -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
+ }
@@ -1,7 +1,117 @@
1
- export function parseGithubSource(input) {
2
- const parts = input.split('/');
3
- if (parts.length === 2 && parts[0] && parts[1]) {
4
- return { owner: parts[0], repo: parts[1] };
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
- return null;
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(' ↑↓ naviger · enter vælg · esc tilbage'));
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, { pageSize = 10 } = {}) {
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 visible = choices.slice(scrollOffset, scrollOffset + pageSize);
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(' ↑↓ naviger · space skift · enter bekræft · esc tilbage'));
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,55 @@
1
+ // Picks the best matching skill directory for `requested` from `candidates`.
2
+ // Strategies, in order:
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
8
+ // Returns the matched directory path, or null if no match.
9
+ //
10
+ // Ported from the agentskills website's `matchSkillToPath` so behavior stays
11
+ // in sync between registry indexing and CLI installs.
12
+ export function matchSkillDir(requested, owner, candidates) {
13
+ if (candidates.length === 0) return null;
14
+
15
+ const wantLower = requested.toLowerCase();
16
+
17
+ // 1. Exact directory match (the last path segment).
18
+ const exact = candidates.find(p => {
19
+ const last = p.split('/').pop();
20
+ return last === requested || last.toLowerCase() === wantLower;
21
+ });
22
+ if (exact) return exact;
23
+
24
+ // 2. Same-path match (user passed the full path already).
25
+ const fullMatch = candidates.find(p => p === requested || p.toLowerCase() === wantLower);
26
+ if (fullMatch) return fullMatch;
27
+
28
+ // 3. Strip common owner-style prefixes.
29
+ const prefixes = [`${owner}-`, `${owner.toLowerCase()}-`, 'vercel-', 'anthropic-', 'claude-'];
30
+ let stripped = requested;
31
+ for (const pre of prefixes) {
32
+ if (wantLower.startsWith(pre.toLowerCase())) {
33
+ stripped = requested.slice(pre.length);
34
+ break;
35
+ }
36
+ }
37
+ if (stripped !== requested) {
38
+ const m = candidates.find(p => {
39
+ const last = p.split('/').pop().toLowerCase();
40
+ return last === stripped.toLowerCase();
41
+ });
42
+ if (m) return m;
43
+ }
44
+
45
+ // 4. Fuzzy: collapse to alphanumerics, lowercase, then compare.
46
+ const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
47
+ const wantNorm = normalize(requested);
48
+ const fuzzy = candidates.find(p => normalize(p.split('/').pop()) === wantNorm);
49
+ if (fuzzy) return fuzzy;
50
+
51
+ // 5. Single-skill fallback.
52
+ if (candidates.length === 1) return candidates[0];
53
+
54
+ return null;
55
+ }