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 +17 -0
- package/README.md +192 -0
- package/dist/commands/add.d.ts +5 -0
- package/dist/commands/add.js +477 -0
- package/dist/commands/list.d.ts +5 -0
- package/dist/commands/list.js +278 -0
- package/dist/commands/remove.d.ts +5 -0
- package/dist/commands/remove.js +336 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +56 -0
- package/dist/lib/agents.d.ts +40 -0
- package/dist/lib/agents.js +146 -0
- package/dist/lib/constants.d.ts +65 -0
- package/dist/lib/constants.js +68 -0
- package/dist/lib/github.d.ts +52 -0
- package/dist/lib/github.js +185 -0
- package/dist/lib/installer.d.ts +64 -0
- package/dist/lib/installer.js +163 -0
- package/dist/telemetry.d.ts +10 -0
- package/dist/telemetry.js +27 -0
- package/dist/utils.d.ts +130 -0
- package/dist/utils.js +163 -0
- package/package.json +68 -0
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,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
|
+
}
|