skillfish 1.0.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/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (c) 2024-2026 Graeme Knox
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published by
8
+ the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
package/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # skillfish
2
+
3
+ Install AI agent skills from GitHub with a single command.
4
+
5
+ ```bash
6
+ npx skillfish owner/repo
7
+ ```
8
+
9
+ ## Overview
10
+
11
+ One command installs skills to **all detected agents**:
12
+
13
+ | Agent | Skills Directory |
14
+ |-------|------------------|
15
+ | Claude Code | `~/.claude/skills/` |
16
+ | Cursor | `~/.cursor/skills/` |
17
+ | Windsurf | `~/.codeium/windsurf/skills/` |
18
+ | Codex | `~/.codex/skills/` |
19
+ | GitHub Copilot | `~/.github/skills/` |
20
+ | Gemini CLI | `~/.gemini/skills/` |
21
+ | OpenCode | `~/.opencode/skills/` |
22
+ | Goose | `~/.goose/skills/` |
23
+ | Amp | `~/.agents/skills/` |
24
+ | Roo Code | `~/.roo/skills/` |
25
+ | Kiro CLI | `~/.kiro/skills/` |
26
+ | Kilo Code | `~/.kilocode/skills/` |
27
+ | Trae | `~/.trae/skills/` |
28
+ | Cline | `~/.cline/skills/` |
29
+ | Antigravity | `~/.gemini/antigravity/skills/` |
30
+ | Droid | `~/.factory/skills/` |
31
+ | Clawdbot | `~/.clawdbot/skills/` |
32
+
33
+ ## Usage
34
+
35
+ ### Install Skills
36
+
37
+ ```bash
38
+ # Install a skill (auto-discovers SKILL.md location)
39
+ npx skillfish add owner/repo
40
+
41
+ # Full path from GitHub (plugin/skill syntax)
42
+ npx skillfish add owner/repo/plugin/skill
43
+
44
+ # Specify explicit path
45
+ npx skillfish add owner/repo --path path/to/skill
46
+
47
+ # Install all skills from a repo (non-interactive)
48
+ npx skillfish add owner/repo --all
49
+
50
+ # Overwrite existing skills
51
+ npx skillfish add owner/repo --force
52
+
53
+ # Skip confirmation prompt
54
+ npx skillfish add owner/repo --yes
55
+ ```
56
+
57
+ ### List Skills
58
+
59
+ ```bash
60
+ # Interactive agent and location picker
61
+ npx skillfish list
62
+
63
+ # List global skills only
64
+ npx skillfish list --global
65
+
66
+ # List project skills only
67
+ npx skillfish list --project
68
+
69
+ # List skills for a specific agent
70
+ npx skillfish list --agent "Claude Code"
71
+ ```
72
+
73
+ ### Remove Skills
74
+
75
+ ```bash
76
+ # Interactive skill picker
77
+ npx skillfish remove
78
+
79
+ # Remove a skill by name
80
+ npx skillfish remove my-skill
81
+
82
+ # Remove all installed skills
83
+ npx skillfish remove --all
84
+
85
+ # Remove from current project only
86
+ npx skillfish remove my-skill --project
87
+
88
+ # Remove from home directory only
89
+ npx skillfish remove my-skill --global
90
+
91
+ # Remove from specific agent
92
+ npx skillfish remove my-skill --agent "Claude Code"
93
+
94
+ # Skip confirmation prompt
95
+ npx skillfish remove my-skill --yes
96
+ ```
97
+
98
+ ## Interactive Selection
99
+
100
+ When a repo contains multiple skills, you'll get an interactive multi-select menu with skill names and descriptions from frontmatter:
101
+
102
+ ```
103
+ ◆ Select skills to install
104
+ │ ◻ Frontend Design - Create distinctive, production-grade frontend interfaces
105
+ │ ◻ Agent Browser - Browser automation using Vercel's agent-browser CLI
106
+ │ ◻ Git Worktree - Manage Git worktrees for isolated parallel development
107
+ │ ...
108
+
109
+ ```
110
+
111
+ Use `--all` to install all skills non-interactively (useful for automation).
112
+
113
+ ## Examples
114
+
115
+ ```bash
116
+ # Install from a skill repo with SKILL.md at root
117
+ npx skillfish add user/my-skill
118
+
119
+ # Install using full path from GitHub
120
+ npx skillfish add EveryInc/compound-engineering-plugin/compound-engineering/frontend-design
121
+
122
+ # Install from a plugin repo with explicit path
123
+ npx skillfish add org/plugin-repo --path plugins/my-plugin/skills/skill-name
124
+
125
+ # Install all skills non-interactively
126
+ npx skillfish add org/plugin-repo --all --yes
127
+
128
+ # Force reinstall
129
+ npx skillfish add user/skill --force
130
+
131
+ # JSON output for automation
132
+ npx skillfish add owner/repo --json
133
+ ```
134
+
135
+ ## Discovery
136
+
137
+ The CLI searches these locations for `SKILL.md`:
138
+ 1. Repository root
139
+ 2. `.claude/skills/{repo}/`
140
+ 3. `skills/{repo}/`
141
+ 4. `plugins/{repo}/skills/{repo}/`
142
+
143
+ Use `--path` to skip discovery and specify the exact location.
144
+
145
+ ## Telemetry
146
+
147
+ This CLI collects anonymous, aggregate install counts to understand skill popularity. No personally identifiable information is collected.
148
+
149
+ **What is collected:**
150
+ - Skill identifier (e.g., `owner/repo/skill-name`)
151
+ - Incremented install count
152
+
153
+ **What is NOT collected:**
154
+ - IP addresses
155
+ - User identifiers
156
+ - System information
157
+ - Usage patterns
158
+
159
+ To opt out, set `DO_NOT_TRACK=1` in your environment:
160
+
161
+ ```bash
162
+ DO_NOT_TRACK=1 npx skillfish owner/repo
163
+ ```
164
+
165
+ Telemetry is also automatically disabled in CI environments (`CI=true`).
166
+
167
+ ## Exit Codes
168
+
169
+ Exit codes help agents and scripts understand command results without parsing error messages.
170
+
171
+ | Code | Name | Meaning |
172
+ |------|------|---------|
173
+ | 0 | Success | Command completed successfully |
174
+ | 1 | General Error | Unspecified error |
175
+ | 2 | Invalid Args | Invalid arguments or options provided |
176
+ | 3 | Network Error | Network failure (timeout, rate limit) |
177
+ | 4 | Not Found | Requested resource not found (skill, agent, repo) |
178
+
179
+ JSON output includes `exit_code` for programmatic access:
180
+
181
+ ```bash
182
+ skillfish add owner/repo --json
183
+ # Output includes: "exit_code": 0 (or error code)
184
+ ```
185
+
186
+ ## Contributing
187
+
188
+ Contributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
189
+
190
+ ## License
191
+
192
+ AGPL-3.0
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `skillfish add` command - Install a skill from a GitHub repository.
3
+ */
4
+ import { Command } from 'commander';
5
+ export declare const addCommand: Command;
@@ -0,0 +1,477 @@
1
+ /**
2
+ * `skillfish add` command - Install a skill from a GitHub repository.
3
+ */
4
+ import { Command } from 'commander';
5
+ import { homedir } from 'os';
6
+ import { dirname, basename } from 'path';
7
+ import * as p from '@clack/prompts';
8
+ import pc from 'picocolors';
9
+ import { trackInstall } from '../telemetry.js';
10
+ import { isValidPath, parseFrontmatter, deriveSkillName, toTitleCase, truncate, batchMap, createJsonOutput, isInputTTY, isTTY, } from '../utils.js';
11
+ import { getDetectedAgents, AGENT_CONFIGS } from '../lib/agents.js';
12
+ import { findAllSkillMdFiles, fetchSkillMdContent, SKILL_FILENAME, RateLimitError, RepoNotFoundError, NetworkError, GitHubApiError, } from '../lib/github.js';
13
+ import { installSkill } from '../lib/installer.js';
14
+ import { EXIT_CODES, isValidName } from '../lib/constants.js';
15
+ // === Command Definition ===
16
+ export const addCommand = new Command('add')
17
+ .description('Install a skill from a GitHub repository')
18
+ .argument('<repo>', 'GitHub repository (owner/repo or owner/repo/plugin/skill)')
19
+ .option('--force', 'Overwrite existing skills without prompting')
20
+ .option('-y, --yes', 'Skip all confirmation prompts')
21
+ .option('--all', 'Install all skills found in the repository')
22
+ .option('--project', 'Install to current project (./.claude)')
23
+ .option('--global', 'Install to home directory (~/.claude)')
24
+ .option('--path <path>', 'Path to a specific skill in the repository')
25
+ .helpOption('-h, --help', 'Display help for command')
26
+ .addHelpText('after', `
27
+ Examples:
28
+ $ skillfish add owner/repo Install from a repository
29
+ $ skillfish add owner/repo --all Install all skills in repo
30
+ $ skillfish add owner/repo/plugin/skill Install a specific skill
31
+ $ skillfish add owner/repo --path path/to Install skill at specific path
32
+ $ skillfish add owner/repo --project Install to current project only`)
33
+ .action(async (repoArg, options, command) => {
34
+ const jsonMode = command.parent?.opts().json ?? false;
35
+ const jsonOutput = createJsonOutput();
36
+ const version = command.parent?.opts().version ?? '0.0.0';
37
+ // Helper to add error and optionally output JSON
38
+ function addError(message) {
39
+ jsonOutput.errors.push(message);
40
+ jsonOutput.success = false;
41
+ }
42
+ function outputJsonAndExit(exitCode) {
43
+ jsonOutput.exit_code = exitCode;
44
+ console.log(JSON.stringify(jsonOutput, null, 2));
45
+ process.exit(exitCode);
46
+ }
47
+ /**
48
+ * Unified error handler that handles both JSON and TTY modes.
49
+ * In JSON mode: adds error to output and exits with JSON.
50
+ * In TTY mode: logs error to console and exits.
51
+ * @param useClackLog - Use p.log.error() instead of console.error()
52
+ */
53
+ function exitWithError(message, exitCode, useClackLog = false) {
54
+ if (jsonMode) {
55
+ addError(message);
56
+ outputJsonAndExit(exitCode);
57
+ }
58
+ if (useClackLog) {
59
+ p.log.error(message);
60
+ }
61
+ else {
62
+ console.error(message);
63
+ }
64
+ process.exit(exitCode);
65
+ }
66
+ // Show banner and intro (TTY only, not in JSON mode)
67
+ if (isTTY() && !jsonMode) {
68
+ console.log();
69
+ console.log(pc.cyan(' ≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋'));
70
+ console.log(` ${pc.cyan('><>')} ${pc.bold('SKILL FISH')} ${pc.cyan('><>')}`);
71
+ console.log(pc.cyan(' ≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋≋'));
72
+ console.log();
73
+ p.intro(`${pc.bgCyan(pc.black(' skillfish '))} ${pc.dim(`v${version}`)}`);
74
+ }
75
+ const force = options.force ?? false;
76
+ const trustSource = options.yes ?? false;
77
+ const installAll = options.all ?? false;
78
+ const projectFlag = options.project ?? false;
79
+ const globalFlag = options.global ?? false;
80
+ let explicitPath = options.path ?? null;
81
+ // Validate --path if provided
82
+ if (explicitPath !== null) {
83
+ if (!isValidPath(explicitPath)) {
84
+ exitWithError('Invalid --path value. Path must be relative and contain only safe characters.', EXIT_CODES.INVALID_ARGS);
85
+ }
86
+ }
87
+ // Parse repo format - supports both owner/repo and owner/repo/plugin/skill
88
+ const parts = repoArg.split('/');
89
+ let owner;
90
+ let repo;
91
+ if (parts.length === 2) {
92
+ [owner, repo] = parts;
93
+ }
94
+ else if (parts.length === 4) {
95
+ const [o, r, plugin, skill] = parts;
96
+ owner = o;
97
+ repo = r;
98
+ // Security: validate plugin and skill names
99
+ if (!isValidName(plugin) || !isValidName(skill)) {
100
+ exitWithError('Invalid plugin or skill name. Use only alphanumeric characters, dots, hyphens, and underscores.', EXIT_CODES.INVALID_ARGS);
101
+ }
102
+ explicitPath = explicitPath || `plugins/${plugin}/skills/${skill}`;
103
+ if (!jsonMode) {
104
+ console.log(`Installing skill from: ${plugin}/${skill}`);
105
+ }
106
+ }
107
+ else {
108
+ exitWithError('Invalid format. Use: owner/repo or owner/repo/plugin/skill', EXIT_CODES.INVALID_ARGS);
109
+ }
110
+ // Validate owner/repo (security: prevent injection)
111
+ if (!owner || !repo || !isValidName(owner) || !isValidName(repo)) {
112
+ exitWithError('Invalid repository format. Use: owner/repo', EXIT_CODES.INVALID_ARGS);
113
+ }
114
+ // 1. Discover or select skills
115
+ const skillPaths = explicitPath
116
+ ? [explicitPath]
117
+ : await discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput);
118
+ if (!skillPaths || skillPaths.length === 0) {
119
+ if (jsonMode) {
120
+ outputJsonAndExit(EXIT_CODES.NOT_FOUND);
121
+ }
122
+ process.exit(EXIT_CODES.NOT_FOUND);
123
+ }
124
+ // 2. Determine install location (global vs project)
125
+ const baseDir = await selectInstallLocation(projectFlag, globalFlag, jsonMode);
126
+ // 3. Select agents to install to
127
+ const detected = getDetectedAgents();
128
+ if (detected.length === 0) {
129
+ const errorMsg = 'No agents detected. Install Claude Code, Cursor, or another supported agent first.';
130
+ if (jsonMode) {
131
+ addError(errorMsg);
132
+ outputJsonAndExit(EXIT_CODES.GENERAL_ERROR);
133
+ }
134
+ p.log.error(errorMsg);
135
+ p.outro(pc.dim('https://skill.fish/agents'));
136
+ process.exit(EXIT_CODES.GENERAL_ERROR);
137
+ }
138
+ let targetAgents;
139
+ if (!isInputTTY() || jsonMode) {
140
+ // Non-TTY or JSON mode: use all detected agents
141
+ if (!jsonMode) {
142
+ console.log(`Installing to ${detected.length} agent(s): ${detected.map((a) => a.name).join(', ')}`);
143
+ }
144
+ targetAgents = detected;
145
+ }
146
+ else {
147
+ // Interactive: let user choose from detected agents
148
+ const isLocal = baseDir !== homedir();
149
+ targetAgents = await selectAgents(detected, isLocal, jsonMode);
150
+ }
151
+ // Install each selected skill
152
+ let totalInstalled = 0;
153
+ let totalSkipped = 0;
154
+ // SECURITY: Ask for confirmation before installation (unless --yes is used)
155
+ // Single confirmation for all selected skills
156
+ if (!trustSource && !jsonMode && isInputTTY()) {
157
+ const skillNames = skillPaths.map((sp) => deriveSkillName(sp, repo));
158
+ const shouldInstall = await confirmInstallBatch(owner, repo, skillNames);
159
+ if (!shouldInstall) {
160
+ for (const skillName of skillNames) {
161
+ p.log.warn(`Skipped ${pc.bold(skillName)} (not confirmed)`);
162
+ jsonOutput.skipped.push({ skill: skillName, agent: 'all', reason: 'User declined' });
163
+ }
164
+ p.outro(pc.dim('Cancelled'));
165
+ process.exit(EXIT_CODES.SUCCESS);
166
+ }
167
+ }
168
+ for (const skillPath of skillPaths) {
169
+ const skillName = deriveSkillName(skillPath, repo);
170
+ // Show install progress
171
+ let spinner = null;
172
+ if (!jsonMode) {
173
+ p.log.step(`Installing ${pc.bold(skillName)}`);
174
+ spinner = p.spinner();
175
+ spinner.start(`Downloading ${skillName}...`);
176
+ }
177
+ const result = await installSkill(owner, repo, skillPath, skillName, targetAgents, {
178
+ force,
179
+ baseDir,
180
+ });
181
+ if (spinner) {
182
+ if (result.failed) {
183
+ spinner.stop(pc.red(`${SKILL_FILENAME} not found`));
184
+ }
185
+ else {
186
+ spinner.stop(pc.green('Installed'));
187
+ }
188
+ }
189
+ // Handle result
190
+ if (result.failed) {
191
+ if (jsonMode) {
192
+ addError(`Install failed: ${result.failureReason}`);
193
+ }
194
+ else {
195
+ console.error(pc.red(`Error: ${result.failureReason}`));
196
+ }
197
+ continue;
198
+ }
199
+ // Log installed/skipped for this skill
200
+ const isLocal = baseDir !== homedir();
201
+ const pathPrefix = isLocal ? '.' : '~';
202
+ for (const installed of result.installed) {
203
+ if (!jsonMode) {
204
+ const displayPath = `${pathPrefix}/${AGENT_CONFIGS.find((c) => c.name === installed.agent)?.dir ?? 'skills'}/${skillName}`;
205
+ console.log(` ${pc.green('✓')} ${installed.agent} ${pc.dim(`→ ${displayPath}`)}`);
206
+ }
207
+ jsonOutput.installed.push(installed);
208
+ }
209
+ for (const skipped of result.skipped) {
210
+ if (!jsonMode) {
211
+ console.log(` ${pc.yellow('●')} ${skipped.agent} ${pc.dim('(already installed)')}`);
212
+ }
213
+ jsonOutput.skipped.push(skipped);
214
+ }
215
+ // Add warnings as errors in JSON output
216
+ for (const warning of result.warnings) {
217
+ jsonOutput.errors.push(warning);
218
+ if (!jsonMode) {
219
+ console.log(` ${pc.yellow('!')} ${warning}`);
220
+ }
221
+ }
222
+ totalInstalled += result.installed.length;
223
+ totalSkipped += result.skipped.length;
224
+ // Track successful installs (fire-and-forget telemetry)
225
+ if (result.installed.length > 0) {
226
+ // Construct github value to match skills.github column format: owner/repo/path/to/skill
227
+ const skillDir = skillPath.replace(/\/?SKILL\.md$/i, '').replace(/^\.?\/?/, '');
228
+ const github = skillDir ? `${owner}/${repo}/${skillDir}` : `${owner}/${repo}`;
229
+ trackInstall(github);
230
+ }
231
+ }
232
+ // Summary
233
+ if (jsonMode) {
234
+ outputJsonAndExit(EXIT_CODES.SUCCESS);
235
+ }
236
+ console.log();
237
+ if (totalInstalled > 0) {
238
+ p.outro(pc.green(`Done! Installed ${totalInstalled} skill${totalInstalled === 1 ? '' : 's'}`));
239
+ }
240
+ else if (totalSkipped > 0) {
241
+ p.outro(pc.yellow(`Skipped ${totalSkipped} existing skill${totalSkipped === 1 ? '' : 's'} - use --force to overwrite`));
242
+ }
243
+ else {
244
+ p.outro(pc.yellow('No skills installed'));
245
+ }
246
+ process.exit(EXIT_CODES.SUCCESS);
247
+ });
248
+ // === Helper Functions ===
249
+ async function selectInstallLocation(projectFlag, globalFlag, jsonMode) {
250
+ // If flag specified, use it
251
+ if (projectFlag) {
252
+ if (!jsonMode) {
253
+ p.log.info(`Location: ${pc.cyan('Project')} ${pc.dim('(./')}${pc.dim(AGENT_CONFIGS[0].dir)}${pc.dim(')')}`);
254
+ }
255
+ return process.cwd();
256
+ }
257
+ if (globalFlag) {
258
+ if (!jsonMode) {
259
+ p.log.info(`Location: ${pc.cyan('Global')} ${pc.dim('(~/')}${pc.dim(AGENT_CONFIGS[0].dir)}${pc.dim(')')}`);
260
+ }
261
+ return homedir();
262
+ }
263
+ // Non-TTY or JSON mode defaults to global
264
+ if (!isInputTTY() || jsonMode) {
265
+ return homedir();
266
+ }
267
+ // Interactive selection
268
+ const location = await p.select({
269
+ message: 'Install location',
270
+ options: [
271
+ {
272
+ value: 'global',
273
+ label: 'Global',
274
+ hint: 'Available in all projects',
275
+ },
276
+ {
277
+ value: 'project',
278
+ label: 'Project',
279
+ hint: 'For this project only',
280
+ },
281
+ ],
282
+ });
283
+ if (p.isCancel(location)) {
284
+ p.cancel('Cancelled');
285
+ process.exit(EXIT_CODES.SUCCESS);
286
+ }
287
+ return location === 'project' ? process.cwd() : homedir();
288
+ }
289
+ async function selectAgents(agents, isLocal, jsonMode) {
290
+ const pathPrefix = isLocal ? '.' : '~';
291
+ // Show detected agents
292
+ if (!jsonMode) {
293
+ p.log.info(`Detected ${pc.cyan(agents.length.toString())} agent${agents.length === 1 ? '' : 's'}: ${agents.map((a) => a.name).join(', ')}`);
294
+ }
295
+ const installAll = await p.confirm({
296
+ message: 'Install to all detected agents?',
297
+ initialValue: true,
298
+ });
299
+ if (p.isCancel(installAll)) {
300
+ p.cancel('Cancelled');
301
+ process.exit(EXIT_CODES.SUCCESS);
302
+ }
303
+ if (installAll) {
304
+ return agents;
305
+ }
306
+ // User wants to choose specific agents
307
+ const options = agents.map((a) => ({
308
+ value: a.name,
309
+ label: a.name,
310
+ hint: `${pathPrefix}/${a.dir}`,
311
+ }));
312
+ const selected = await p.multiselect({
313
+ message: 'Select agents',
314
+ options,
315
+ required: true,
316
+ });
317
+ if (p.isCancel(selected)) {
318
+ p.cancel('Cancelled');
319
+ process.exit(EXIT_CODES.SUCCESS);
320
+ }
321
+ return agents.filter((a) => selected.includes(a.name));
322
+ }
323
+ async function discoverSkillPaths(owner, repo, installAll, jsonMode, jsonOutput) {
324
+ let skillPaths;
325
+ try {
326
+ skillPaths = await findAllSkillMdFiles(owner, repo);
327
+ }
328
+ catch (err) {
329
+ let errorMsg;
330
+ let exitCode = EXIT_CODES.GENERAL_ERROR;
331
+ if (err instanceof RateLimitError) {
332
+ errorMsg = err.message;
333
+ exitCode = EXIT_CODES.NETWORK_ERROR;
334
+ }
335
+ else if (err instanceof RepoNotFoundError) {
336
+ errorMsg = err.message;
337
+ exitCode = EXIT_CODES.NOT_FOUND;
338
+ }
339
+ else if (err instanceof NetworkError) {
340
+ errorMsg = err.message;
341
+ exitCode = EXIT_CODES.NETWORK_ERROR;
342
+ }
343
+ else if (err instanceof GitHubApiError) {
344
+ errorMsg = err.message;
345
+ }
346
+ else {
347
+ errorMsg = err instanceof Error ? err.message : String(err);
348
+ }
349
+ if (jsonMode) {
350
+ jsonOutput.errors.push(errorMsg);
351
+ jsonOutput.success = false;
352
+ console.log(JSON.stringify(jsonOutput, null, 2));
353
+ }
354
+ else {
355
+ p.log.error(errorMsg);
356
+ }
357
+ process.exit(exitCode);
358
+ }
359
+ if (skillPaths.length === 0) {
360
+ const errorMsg = `No ${SKILL_FILENAME} found in repository`;
361
+ if (jsonMode) {
362
+ jsonOutput.errors.push(errorMsg);
363
+ jsonOutput.success = false;
364
+ }
365
+ else {
366
+ p.log.error(errorMsg);
367
+ }
368
+ return null;
369
+ }
370
+ // Fetch frontmatter metadata for all skills in parallel
371
+ let spinner = null;
372
+ if (!jsonMode) {
373
+ spinner = p.spinner();
374
+ spinner.start('Fetching skill metadata...');
375
+ }
376
+ // Fetch metadata with bounded concurrency (max 10 parallel requests)
377
+ const skills = await batchMap(skillPaths, async (sp) => {
378
+ const skillDir = sp === SKILL_FILENAME ? '.' : dirname(sp);
379
+ const folderName = sp === SKILL_FILENAME ? repo : basename(skillDir);
380
+ // Fetch raw content to parse frontmatter
381
+ const content = await fetchSkillMdContent(owner, repo, sp);
382
+ const frontmatter = content ? parseFrontmatter(content) : {};
383
+ return {
384
+ path: sp,
385
+ dir: skillDir === '.' ? SKILL_FILENAME : skillDir,
386
+ name: frontmatter.name || folderName,
387
+ description: frontmatter.description || '',
388
+ };
389
+ }, 10);
390
+ if (spinner) {
391
+ spinner.stop(`Found ${pc.cyan(skills.length.toString())} skill${skills.length === 1 ? '' : 's'}`);
392
+ }
393
+ // Store found skills in JSON output
394
+ jsonOutput.skills_found = skills.map((s) => s.name);
395
+ if (skills.length === 1) {
396
+ const skill = skills[0];
397
+ const displayName = toTitleCase(skill.name);
398
+ const desc = skill.description ? truncate(skill.description, 60) : '';
399
+ if (!jsonMode) {
400
+ p.log.info(`${pc.bold(displayName)}${desc ? pc.dim(` - ${desc}`) : ''}`);
401
+ }
402
+ return [skill.dir];
403
+ }
404
+ // Build options for selection with frontmatter metadata
405
+ // Title in label, description in hint (shows on focus)
406
+ const optionsList = skills.map((skill) => ({
407
+ value: skill.dir,
408
+ label: pc.bold(toTitleCase(skill.name)),
409
+ hint: skill.description || undefined,
410
+ }));
411
+ // Non-TTY or JSON mode: require --all or --path for multiple skills
412
+ if (!isInputTTY() || jsonMode) {
413
+ // If --all flag is set, install all skills
414
+ if (installAll) {
415
+ if (!jsonMode) {
416
+ console.log(`Installing all ${skills.length} skills`);
417
+ }
418
+ return skills.map((s) => s.dir);
419
+ }
420
+ // Otherwise, list skills and exit with guidance
421
+ if (jsonMode) {
422
+ jsonOutput.errors.push('Multiple skills found. Use --path or --all to specify which one(s).');
423
+ jsonOutput.success = false;
424
+ }
425
+ else {
426
+ console.log(`\nFound ${skills.length} skills in this repository:`);
427
+ for (const skill of skills) {
428
+ const displayName = toTitleCase(skill.name);
429
+ const desc = skill.description ? pc.dim(` - ${truncate(skill.description, 80)}`) : '';
430
+ console.log(` - ${displayName}${desc}`);
431
+ }
432
+ console.error('\nMultiple skills found. Use --path or --all to specify which one(s) (non-interactive mode).');
433
+ }
434
+ return null;
435
+ }
436
+ // Interactive multi-select
437
+ const selected = await p.multiselect({
438
+ message: 'Select skills to install',
439
+ options: optionsList,
440
+ required: true,
441
+ });
442
+ if (p.isCancel(selected)) {
443
+ p.cancel('Cancelled');
444
+ process.exit(EXIT_CODES.SUCCESS);
445
+ }
446
+ return selected;
447
+ }
448
+ /**
449
+ * SECURITY: Show warning and ask for user confirmation before installing.
450
+ * This mitigates supply chain attacks by making users acknowledge the source.
451
+ * Handles batch confirmation for multiple skills at once.
452
+ */
453
+ async function confirmInstallBatch(owner, repo, skillNames) {
454
+ console.log();
455
+ p.log.warn(pc.yellow('Skills can instruct AI agents to perform actions on your behalf.'));
456
+ console.log(pc.dim(` Source: github.com/${owner}/${repo}`));
457
+ console.log(pc.dim(' Use --yes to skip this prompt for trusted sources.'));
458
+ if (skillNames.length > 1) {
459
+ console.log();
460
+ console.log(pc.dim(' Skills to install:'));
461
+ for (const name of skillNames) {
462
+ console.log(pc.dim(` • ${name}`));
463
+ }
464
+ }
465
+ console.log();
466
+ const skillLabel = skillNames.length === 1
467
+ ? pc.bold(skillNames[0])
468
+ : `${pc.bold(skillNames.length.toString())} skills`;
469
+ const proceed = await p.confirm({
470
+ message: `Install ${skillLabel} from ${pc.cyan(`${owner}/${repo}`)}?`,
471
+ initialValue: true,
472
+ });
473
+ if (p.isCancel(proceed)) {
474
+ return false;
475
+ }
476
+ return proceed;
477
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * `skillfish list` command - List installed skills.
3
+ */
4
+ import { Command } from 'commander';
5
+ export declare const listCommand: Command;