@haolin-ai/skillman 1.0.1 → 1.0.3
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/README.md +102 -0
- package/package.json +1 -1
- package/src/cli.js +196 -69
- package/src/downloader.js +122 -0
- package/src/history.js +29 -7
- package/src/i18n.js +16 -0
- package/src/scanner.js +37 -4
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Skillman
|
|
2
|
+
|
|
3
|
+
A CLI tool to install AI agent skills across multiple platforms.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @haolin-ai/skillman
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Interactive Mode (Recommended)
|
|
14
|
+
|
|
15
|
+
Run without arguments to start the interactive installation wizard:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
skillman
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This will guide you through:
|
|
22
|
+
1. Scanning available skills in the current directory
|
|
23
|
+
2. Selecting a skill to install
|
|
24
|
+
3. Choosing the target agent (Claude Code, OpenClaw, Qoder, etc.)
|
|
25
|
+
4. Selecting installation scope (global or workspace)
|
|
26
|
+
5. Confirming the installation
|
|
27
|
+
|
|
28
|
+
### Install from URL
|
|
29
|
+
|
|
30
|
+
Install skills directly from GitHub or any git repository:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# GitHub shorthand
|
|
34
|
+
skillman install vercel-labs/agent-skills
|
|
35
|
+
|
|
36
|
+
# Full GitHub URL
|
|
37
|
+
skillman install https://github.com/vercel-labs/agent-skills
|
|
38
|
+
|
|
39
|
+
# With subdirectory
|
|
40
|
+
skillman install https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design
|
|
41
|
+
|
|
42
|
+
# GitLab or other git URLs
|
|
43
|
+
skillman install https://gitlab.com/org/repo
|
|
44
|
+
skillman install git@github.com:org/repo.git
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Commands
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# List all available agents
|
|
51
|
+
skillman agents
|
|
52
|
+
|
|
53
|
+
# Preview installation without making changes
|
|
54
|
+
skillman --dry-run
|
|
55
|
+
|
|
56
|
+
# Show help
|
|
57
|
+
skillman --help
|
|
58
|
+
|
|
59
|
+
# Show version
|
|
60
|
+
skillman --version
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **Multi-Agent Support**: Works with Claude Code, OpenClaw, Qoder, Codex, Cursor, and more
|
|
66
|
+
- **Workspace History**: Remembers previously used workspace paths for quick selection
|
|
67
|
+
- **Bilingual Support**: Automatically switches between English and Chinese based on system language
|
|
68
|
+
- **Dry-Run Mode**: Preview installations before applying changes
|
|
69
|
+
- **Smart Path Resolution**: Automatically resolves relative paths to absolute paths
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
Agent configurations are stored in `src/agents.yaml`. Each agent has:
|
|
74
|
+
- `globalSkillsDir`: Global skills directory (usually in home directory)
|
|
75
|
+
- `skillsDir`: Relative path for workspace-specific skills
|
|
76
|
+
|
|
77
|
+
## Workspace History
|
|
78
|
+
|
|
79
|
+
When installing to a workspace scope, Skillman remembers your paths in:
|
|
80
|
+
```
|
|
81
|
+
~/.config/skillman/history.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
History is organized by agent, so each agent has its own list of recently used workspaces.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
# Clone the repository
|
|
90
|
+
git clone <repo-url>
|
|
91
|
+
cd skillman
|
|
92
|
+
|
|
93
|
+
# Install dependencies
|
|
94
|
+
pnpm install
|
|
95
|
+
|
|
96
|
+
# Run locally
|
|
97
|
+
node bin/skillman.mjs
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -5,15 +5,26 @@
|
|
|
5
5
|
import fs from 'fs/promises';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
|
-
import { select, confirm, input, Separator } from '@inquirer/prompts';
|
|
8
|
+
import { select, confirm, input, Separator, checkbox } from '@inquirer/prompts';
|
|
9
9
|
import { scanSkills } from './scanner.js';
|
|
10
10
|
import { installSkill } from './installer.js';
|
|
11
11
|
import { loadAgents } from './config.js';
|
|
12
12
|
import { t } from './i18n.js';
|
|
13
|
-
import { loadHistory, addWorkspace } from './history.js';
|
|
13
|
+
import { loadHistory, addWorkspace, saveLastUsed } from './history.js';
|
|
14
|
+
import { downloadSkill, parseUrl, cleanupDownloads } from './downloader.js';
|
|
14
15
|
|
|
15
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
// Read version from package.json
|
|
19
|
+
let VERSION = '1.0.0';
|
|
20
|
+
try {
|
|
21
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
22
|
+
const pkgContent = await fs.readFile(pkgPath, 'utf8');
|
|
23
|
+
const pkg = JSON.parse(pkgContent);
|
|
24
|
+
VERSION = pkg.version || '1.0.0';
|
|
25
|
+
} catch {
|
|
26
|
+
// Fallback to default version
|
|
27
|
+
}
|
|
17
28
|
|
|
18
29
|
// ANSI colors
|
|
19
30
|
const c = {
|
|
@@ -71,6 +82,7 @@ function showHelp() {
|
|
|
71
82
|
${c.cyan}${t('help.usage')}:${c.reset}
|
|
72
83
|
skillman ${t('help.cmd.interactive')}
|
|
73
84
|
skillman install <path> ${t('help.cmd.install')}
|
|
85
|
+
skillman i <path> ${t('help.cmd.install')}
|
|
74
86
|
skillman agents ${t('help.cmd.agents')}
|
|
75
87
|
|
|
76
88
|
${c.cyan}${t('help.options')}:${c.reset}
|
|
@@ -82,6 +94,7 @@ ${c.cyan}${t('help.examples')}:${c.reset}
|
|
|
82
94
|
skillman # ${t('help.cmd.interactive')}
|
|
83
95
|
skillman --dry-run # ${t('help.opt.dry_run')}
|
|
84
96
|
skillman install ./my-skill # ${t('help.cmd.install')}
|
|
97
|
+
skillman i github.com/owner/repo # ${t('help.cmd.install')}
|
|
85
98
|
skillman agents # ${t('help.cmd.agents')}
|
|
86
99
|
`);
|
|
87
100
|
}
|
|
@@ -105,14 +118,32 @@ async function listAgents() {
|
|
|
105
118
|
}
|
|
106
119
|
}
|
|
107
120
|
|
|
108
|
-
//
|
|
109
|
-
async function
|
|
121
|
+
// Install from URL or local path
|
|
122
|
+
async function installFromUrl(url, dryRun) {
|
|
110
123
|
console.log(`${c.green}${t('app.name')}${c.reset} - ${t('app.description')}${dryRun ? c.yellow + ' [DRY-RUN]' + c.reset : ''}\n`);
|
|
111
124
|
|
|
112
|
-
|
|
125
|
+
const parsed = parseUrl(url);
|
|
126
|
+
const isRemote = parsed.type !== 'local';
|
|
127
|
+
|
|
128
|
+
// Step 1: Download/resolve path
|
|
129
|
+
let sourcePath;
|
|
130
|
+
if (isRemote) {
|
|
131
|
+
log.step(t('msg.downloading') || 'Downloading...');
|
|
132
|
+
try {
|
|
133
|
+
sourcePath = await downloadSkill(url);
|
|
134
|
+
log.success(t('msg.downloaded') || 'Downloaded');
|
|
135
|
+
} catch (error) {
|
|
136
|
+
log.error(error.message);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
sourcePath = url;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Step 2: Scan skills
|
|
113
144
|
log.step(t('step.scan'));
|
|
145
|
+
const skills = await scanSkills(sourcePath);
|
|
114
146
|
|
|
115
|
-
const skills = await scanSkills(process.cwd());
|
|
116
147
|
if (skills.length === 0) {
|
|
117
148
|
log.error(t('msg.no_skills'));
|
|
118
149
|
process.exit(1);
|
|
@@ -120,25 +151,57 @@ async function interactiveInstall(dryRun) {
|
|
|
120
151
|
|
|
121
152
|
log.success(t('msg.found_skills', { count: skills.length }));
|
|
122
153
|
|
|
123
|
-
// Step
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
154
|
+
// Step 3: Select skills (if multiple)
|
|
155
|
+
let selectedSkills;
|
|
156
|
+
if (skills.length === 1) {
|
|
157
|
+
selectedSkills = [skills[0]];
|
|
158
|
+
log.success(`${t('msg.selected')}: ${skills[0].name}`);
|
|
159
|
+
} else {
|
|
160
|
+
const skillChoices = skills.map(s => ({
|
|
161
|
+
name: s.description
|
|
162
|
+
? `${s.name} ${c.gray}(${s.description.slice(0, 40)}${s.description.length > 40 ? '...' : ''})${c.reset}`
|
|
163
|
+
: s.name,
|
|
164
|
+
value: s
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
selectedSkills = await checkbox({
|
|
168
|
+
message: t('step.select_skills') + ':',
|
|
169
|
+
choices: skillChoices,
|
|
170
|
+
pageSize: 10,
|
|
171
|
+
loop: false,
|
|
172
|
+
validate: (selected) => {
|
|
173
|
+
if (selected.length === 0) {
|
|
174
|
+
return t('error.no_selection') || 'Please select at least one skill';
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
130
179
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
choices: skillChoices,
|
|
134
|
-
pageSize: 10
|
|
135
|
-
});
|
|
180
|
+
log.success(`${t('msg.selected_count', { count: selectedSkills.length })}`);
|
|
181
|
+
}
|
|
136
182
|
|
|
137
|
-
|
|
183
|
+
// Continue with rest of interactive flow
|
|
184
|
+
await continueInstallMultiple(selectedSkills, dryRun);
|
|
138
185
|
|
|
139
|
-
//
|
|
186
|
+
// Cleanup temp downloads
|
|
187
|
+
if (isRemote) {
|
|
188
|
+
await cleanupDownloads();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Continue installation after skill selection (multiple skills)
|
|
193
|
+
async function continueInstallMultiple(selectedSkills, dryRun) {
|
|
194
|
+
// Load last used preferences
|
|
195
|
+
const { lastAgent, lastScope } = await loadHistory();
|
|
140
196
|
const agents = await loadAgents();
|
|
141
|
-
|
|
197
|
+
|
|
198
|
+
// Step 4: Select agent (with default from history)
|
|
199
|
+
const agentList = Object.values(agents);
|
|
200
|
+
const defaultAgentIndex = lastAgent
|
|
201
|
+
? agentList.findIndex(a => a.name === lastAgent)
|
|
202
|
+
: -1;
|
|
203
|
+
|
|
204
|
+
const agentChoices = agentList.map(a => ({
|
|
142
205
|
name: a.displayName,
|
|
143
206
|
value: a
|
|
144
207
|
}));
|
|
@@ -146,31 +209,42 @@ async function interactiveInstall(dryRun) {
|
|
|
146
209
|
const agent = await select({
|
|
147
210
|
message: t('step.select_agent') + ':',
|
|
148
211
|
choices: agentChoices,
|
|
149
|
-
pageSize: 10
|
|
212
|
+
pageSize: 10,
|
|
213
|
+
default: defaultAgentIndex >= 0 ? agentChoices[defaultAgentIndex].value : undefined
|
|
150
214
|
});
|
|
151
215
|
|
|
152
216
|
log.success(`${t('msg.agent')}: ${agent.displayName}`);
|
|
153
217
|
|
|
154
|
-
// Step
|
|
218
|
+
// Step 5: Select scope (with default from history)
|
|
219
|
+
const scopeChoices = [
|
|
220
|
+
{ name: `${t('option.global')} ${c.gray}(${agent.globalSkillsDir})${c.reset}`, value: 'global' },
|
|
221
|
+
{ name: `${t('option.workspace')} ${c.gray}(${t('option.custom_path')})${c.reset}`, value: 'workspace' }
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const defaultScopeIndex = lastScope
|
|
225
|
+
? scopeChoices.findIndex(s => s.value === lastScope)
|
|
226
|
+
: -1;
|
|
227
|
+
|
|
155
228
|
const scope = await select({
|
|
156
229
|
message: t('step.select_scope') + ':',
|
|
157
|
-
choices:
|
|
158
|
-
|
|
159
|
-
{ name: `${t('option.workspace')} ${c.gray}(${t('option.custom_path')})${c.reset}`, value: 'workspace' }
|
|
160
|
-
]
|
|
230
|
+
choices: scopeChoices,
|
|
231
|
+
default: defaultScopeIndex >= 0 ? scopeChoices[defaultScopeIndex].value : undefined
|
|
161
232
|
});
|
|
162
233
|
|
|
163
|
-
//
|
|
234
|
+
// Save preferences for next time
|
|
235
|
+
await saveLastUsed(agent.name, scope);
|
|
236
|
+
|
|
237
|
+
// Step 6: If workspace scope, ask for workspace path
|
|
164
238
|
let workspacePath = agent.skillsDir;
|
|
165
239
|
if (scope === 'workspace') {
|
|
166
|
-
const
|
|
240
|
+
const { workspaces } = await loadHistory(agent.name);
|
|
167
241
|
|
|
168
242
|
let customPath;
|
|
169
243
|
|
|
170
|
-
if (
|
|
244
|
+
if (workspaces.length > 0) {
|
|
171
245
|
// Show history choices with separator
|
|
172
246
|
const historyChoices = [
|
|
173
|
-
...
|
|
247
|
+
...workspaces.map((h, idx) => ({
|
|
174
248
|
name: `${idx + 1}. ${h}`,
|
|
175
249
|
value: h
|
|
176
250
|
})),
|
|
@@ -218,64 +292,116 @@ async function interactiveInstall(dryRun) {
|
|
|
218
292
|
log.info(`${t('msg.workspace_dir')}: ${workspacePath}`);
|
|
219
293
|
}
|
|
220
294
|
|
|
221
|
-
//
|
|
222
|
-
const targetDir = scope === 'global'
|
|
223
|
-
? path.join(agent.globalSkillsDir, selectedSkill.name)
|
|
224
|
-
: path.join(workspacePath, selectedSkill.name);
|
|
225
|
-
|
|
226
|
-
// Dry-run preview
|
|
295
|
+
// Dry-run preview for all skills
|
|
227
296
|
if (dryRun) {
|
|
228
297
|
log.step(t('step.preview'));
|
|
229
|
-
log.dry(`${t('msg.source')}: ${selectedSkill.path}`);
|
|
230
|
-
log.dry(`${t('msg.target')}: ${targetDir}`);
|
|
231
298
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
299
|
+
for (const skill of selectedSkills) {
|
|
300
|
+
const targetDir = scope === 'global'
|
|
301
|
+
? path.join(agent.globalSkillsDir, skill.name)
|
|
302
|
+
: path.join(workspacePath, skill.name);
|
|
303
|
+
|
|
304
|
+
log.dry(`\n${skill.name}:`);
|
|
305
|
+
log.dry(` ${t('msg.source')}: ${skill.path}`);
|
|
306
|
+
log.dry(` ${t('msg.target')}: ${targetDir}`);
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await fs.access(targetDir);
|
|
310
|
+
log.dry(` ${t('msg.exists')}`);
|
|
311
|
+
} catch {
|
|
312
|
+
log.dry(` ${t('msg.not_exists')}`);
|
|
313
|
+
}
|
|
237
314
|
}
|
|
238
315
|
|
|
239
|
-
log.dry(t('msg.copy'));
|
|
240
|
-
|
|
241
316
|
console.log(`\n${c.yellow}📋 ${t('msg.preview_summary')}${c.reset}\n`);
|
|
242
|
-
console.log(`
|
|
317
|
+
console.log(` ${t('msg.selected_count', { count: selectedSkills.length })}`);
|
|
243
318
|
console.log(` ${t('msg.agent')}: ${agent.displayName}`);
|
|
244
319
|
console.log(` ${t('msg.scope')}: ${scope}`);
|
|
245
|
-
console.log(` ${t('msg.location')}: ${targetDir}`);
|
|
246
320
|
console.log(`\n${c.gray}${t('msg.dry_run_hint')}${c.reset}\n`);
|
|
247
321
|
return;
|
|
248
322
|
}
|
|
249
323
|
|
|
250
|
-
// Step
|
|
324
|
+
// Step 8: Install all skills
|
|
251
325
|
log.step(t('step.install'));
|
|
252
326
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
327
|
+
let installedCount = 0;
|
|
328
|
+
let skippedCount = 0;
|
|
329
|
+
|
|
330
|
+
for (const skill of selectedSkills) {
|
|
331
|
+
const targetDir = scope === 'global'
|
|
332
|
+
? path.join(agent.globalSkillsDir, skill.name)
|
|
333
|
+
: path.join(workspacePath, skill.name);
|
|
334
|
+
|
|
335
|
+
log.step(`${t('msg.installing') || 'Installing'}: ${skill.name}`);
|
|
336
|
+
|
|
337
|
+
// Check if already exists
|
|
338
|
+
let shouldInstall = true;
|
|
339
|
+
try {
|
|
340
|
+
await fs.access(targetDir);
|
|
341
|
+
log.warn(t('msg.skill_exists'));
|
|
342
|
+
const overwrite = await confirm({ message: t('prompt.overwrite') + '?', default: false });
|
|
343
|
+
if (!overwrite) {
|
|
344
|
+
log.info(t('msg.skipped') || 'Skipped');
|
|
345
|
+
skippedCount++;
|
|
346
|
+
shouldInstall = false;
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
// Directory doesn't exist, proceed
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (shouldInstall) {
|
|
353
|
+
await installSkill(skill.path, targetDir);
|
|
354
|
+
log.success(`${t('msg.target')}: ${targetDir}`);
|
|
355
|
+
installedCount++;
|
|
261
356
|
}
|
|
262
|
-
} catch {
|
|
263
|
-
// Directory doesn't exist, proceed
|
|
264
357
|
}
|
|
265
358
|
|
|
266
|
-
// Install
|
|
267
|
-
await installSkill(selectedSkill.path, targetDir);
|
|
268
|
-
log.success(`${t('msg.target')}: ${targetDir}`);
|
|
269
|
-
|
|
270
359
|
// Summary
|
|
271
360
|
console.log(`\n${c.green}✨ ${t('msg.install_complete')}${c.reset}\n`);
|
|
272
|
-
console.log(`
|
|
361
|
+
console.log(` ${t('msg.installed') || 'Installed'}: ${installedCount}`);
|
|
362
|
+
if (skippedCount > 0) {
|
|
363
|
+
console.log(` ${t('msg.skipped') || 'Skipped'}: ${skippedCount}`);
|
|
364
|
+
}
|
|
273
365
|
console.log(` ${t('msg.agent')}: ${agent.displayName}`);
|
|
274
366
|
console.log(` ${t('msg.scope')}: ${scope}`);
|
|
275
|
-
console.log(` ${t('msg.location')}: ${targetDir}`);
|
|
276
367
|
console.log();
|
|
277
368
|
}
|
|
278
369
|
|
|
370
|
+
// Interactive install flow
|
|
371
|
+
async function interactiveInstall(dryRun) {
|
|
372
|
+
console.log(`${c.green}${t('app.name')}${c.reset} - ${t('app.description')}${dryRun ? c.yellow + ' [DRY-RUN]' + c.reset : ''}\n`);
|
|
373
|
+
|
|
374
|
+
// Step 1: Scan skills
|
|
375
|
+
log.step(t('step.scan'));
|
|
376
|
+
|
|
377
|
+
const skills = await scanSkills(process.cwd());
|
|
378
|
+
if (skills.length === 0) {
|
|
379
|
+
log.error(t('msg.no_skills'));
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
log.success(t('msg.found_skills', { count: skills.length }));
|
|
384
|
+
|
|
385
|
+
// Step 2: Select skill
|
|
386
|
+
const skillChoices = skills.map(s => ({
|
|
387
|
+
name: s.description
|
|
388
|
+
? `${s.name} ${c.gray}(${s.description.slice(0, 40)}${s.description.length > 40 ? '...' : ''})${c.reset}`
|
|
389
|
+
: s.name,
|
|
390
|
+
value: s
|
|
391
|
+
}));
|
|
392
|
+
|
|
393
|
+
const selectedSkill = await select({
|
|
394
|
+
message: t('step.select_skill') + ':',
|
|
395
|
+
choices: skillChoices,
|
|
396
|
+
pageSize: 10
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
log.success(`${t('msg.selected')}: ${selectedSkill.name}`);
|
|
400
|
+
|
|
401
|
+
// Continue with agent selection and installation
|
|
402
|
+
await continueInstall(selectedSkill, dryRun);
|
|
403
|
+
}
|
|
404
|
+
|
|
279
405
|
// Main CLI function
|
|
280
406
|
export async function cli() {
|
|
281
407
|
const args = process.argv.slice(2);
|
|
@@ -296,9 +422,10 @@ export async function cli() {
|
|
|
296
422
|
return;
|
|
297
423
|
}
|
|
298
424
|
|
|
299
|
-
if (options.command === 'install') {
|
|
300
|
-
|
|
301
|
-
|
|
425
|
+
if (options.command === 'install' || options.command === 'i') {
|
|
426
|
+
const url = options.positional[0] || process.cwd();
|
|
427
|
+
await installFromUrl(url, options.dryRun);
|
|
428
|
+
return;
|
|
302
429
|
}
|
|
303
430
|
|
|
304
431
|
// Default: interactive install
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL downloader for remote skill installation
|
|
3
|
+
* Supports GitHub shorthand, full URLs, and git URLs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
|
|
11
|
+
const TEMP_DIR = path.join(os.tmpdir(), 'skillman-downloads');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse URL to determine type and extract info
|
|
15
|
+
* @param {string} url - Input URL or shorthand
|
|
16
|
+
* @returns {Object} { type, url, subPath }
|
|
17
|
+
*/
|
|
18
|
+
export function parseUrl(url) {
|
|
19
|
+
// GitHub shorthand: owner/repo or owner/repo/path
|
|
20
|
+
if (/^[\w-]+\/[\w-]+/.test(url) && !url.includes('://') && !url.startsWith('git@')) {
|
|
21
|
+
const parts = url.split('/');
|
|
22
|
+
if (parts.length === 2) {
|
|
23
|
+
return { type: 'github', url: `https://github.com/${url}.git`, subPath: null };
|
|
24
|
+
} else if (parts.length >= 3) {
|
|
25
|
+
// owner/repo/sub/path
|
|
26
|
+
return {
|
|
27
|
+
type: 'github',
|
|
28
|
+
url: `https://github.com/${parts[0]}/${parts[1]}.git`,
|
|
29
|
+
subPath: parts.slice(2).join('/')
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Full GitHub URL with tree/main/path
|
|
35
|
+
const treeMatch = url.match(/github\.com\/([^\/]+)\/([^\/]+)\/tree\/[^\/]+\/(.+)/);
|
|
36
|
+
if (treeMatch) {
|
|
37
|
+
return {
|
|
38
|
+
type: 'github',
|
|
39
|
+
url: `https://github.com/${treeMatch[1]}/${treeMatch[2]}.git`,
|
|
40
|
+
subPath: treeMatch[3]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Regular git URLs
|
|
45
|
+
if (url.startsWith('https://') || url.startsWith('git@') || url.startsWith('http://')) {
|
|
46
|
+
// Remove .git suffix if present for consistency
|
|
47
|
+
const cleanUrl = url.replace(/\.git$/, '');
|
|
48
|
+
return { type: 'git', url: cleanUrl + '.git', subPath: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Local path
|
|
52
|
+
return { type: 'local', url, subPath: null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Download skill from URL
|
|
57
|
+
* @param {string} inputUrl - URL or shorthand
|
|
58
|
+
* @returns {Promise<string>} Path to downloaded skill directory
|
|
59
|
+
*/
|
|
60
|
+
export async function downloadSkill(inputUrl) {
|
|
61
|
+
const parsed = parseUrl(inputUrl);
|
|
62
|
+
|
|
63
|
+
if (parsed.type === 'local') {
|
|
64
|
+
return parsed.url;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Create temp directory
|
|
68
|
+
await fs.mkdir(TEMP_DIR, { recursive: true });
|
|
69
|
+
|
|
70
|
+
// Generate unique directory name
|
|
71
|
+
const repoName = path.basename(parsed.url, '.git');
|
|
72
|
+
const timestamp = Date.now();
|
|
73
|
+
const downloadDir = path.join(TEMP_DIR, `${repoName}-${timestamp}`);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// Clone repository
|
|
77
|
+
execSync(`git clone --depth 1 "${parsed.url}" "${downloadDir}"`, {
|
|
78
|
+
stdio: 'pipe',
|
|
79
|
+
timeout: 60000
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// If subPath specified, return that subdirectory
|
|
83
|
+
if (parsed.subPath) {
|
|
84
|
+
const subDir = path.join(downloadDir, parsed.subPath);
|
|
85
|
+
try {
|
|
86
|
+
await fs.access(subDir);
|
|
87
|
+
return subDir;
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(`Subdirectory not found: ${parsed.subPath}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return downloadDir;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// Cleanup on failure
|
|
96
|
+
try {
|
|
97
|
+
await fs.rm(downloadDir, { recursive: true, force: true });
|
|
98
|
+
} catch {}
|
|
99
|
+
throw new Error(`Failed to download: ${error.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Clean up old downloads
|
|
105
|
+
*/
|
|
106
|
+
export async function cleanupDownloads() {
|
|
107
|
+
try {
|
|
108
|
+
const entries = await fs.readdir(TEMP_DIR);
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const oneHour = 60 * 60 * 1000;
|
|
111
|
+
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
const entryPath = path.join(TEMP_DIR, entry);
|
|
114
|
+
try {
|
|
115
|
+
const stats = await fs.stat(entryPath);
|
|
116
|
+
if (now - stats.mtime.getTime() > oneHour) {
|
|
117
|
+
await fs.rm(entryPath, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
package/src/history.js
CHANGED
|
@@ -24,9 +24,13 @@ export async function loadHistory(agentName) {
|
|
|
24
24
|
await ensureDir();
|
|
25
25
|
const data = await fs.readFile(HISTORY_FILE, 'utf8');
|
|
26
26
|
const history = JSON.parse(data);
|
|
27
|
-
return
|
|
27
|
+
return {
|
|
28
|
+
workspaces: history[agentName]?.workspaces || [],
|
|
29
|
+
lastAgent: history.lastAgent || null,
|
|
30
|
+
lastScope: history.lastScope || null
|
|
31
|
+
};
|
|
28
32
|
} catch {
|
|
29
|
-
return [];
|
|
33
|
+
return { workspaces: [], lastAgent: null, lastScope: null };
|
|
30
34
|
}
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -48,16 +52,34 @@ export async function saveHistory(agentName, workspaces) {
|
|
|
48
52
|
await fs.writeFile(HISTORY_FILE, JSON.stringify(data, null, 2));
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
export async function saveLastUsed(agentName, scope) {
|
|
56
|
+
await ensureDir();
|
|
57
|
+
let data = {};
|
|
58
|
+
try {
|
|
59
|
+
const existing = await fs.readFile(HISTORY_FILE, 'utf8');
|
|
60
|
+
data = JSON.parse(existing);
|
|
61
|
+
} catch {
|
|
62
|
+
// File doesn't exist or is invalid
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (agentName) data.lastAgent = agentName;
|
|
66
|
+
if (scope) data.lastScope = scope;
|
|
67
|
+
data.updatedAt = new Date().toISOString();
|
|
68
|
+
|
|
69
|
+
await fs.writeFile(HISTORY_FILE, JSON.stringify(data, null, 2));
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
export async function addWorkspace(agentName, workspacePath) {
|
|
52
73
|
const normalized = path.resolve(workspacePath);
|
|
53
|
-
|
|
74
|
+
const { workspaces } = await loadHistory(agentName);
|
|
75
|
+
let newWorkspaces = [...workspaces];
|
|
54
76
|
|
|
55
77
|
// Remove if exists (to move to front)
|
|
56
|
-
|
|
78
|
+
newWorkspaces = newWorkspaces.filter(w => path.resolve(w) !== normalized);
|
|
57
79
|
|
|
58
80
|
// Add to front
|
|
59
|
-
|
|
81
|
+
newWorkspaces.unshift(normalized);
|
|
60
82
|
|
|
61
|
-
await saveHistory(agentName,
|
|
62
|
-
return
|
|
83
|
+
await saveHistory(agentName, newWorkspaces);
|
|
84
|
+
return newWorkspaces;
|
|
63
85
|
}
|
package/src/i18n.js
CHANGED
|
@@ -34,6 +34,7 @@ const translations = {
|
|
|
34
34
|
// Steps
|
|
35
35
|
'step.scan': '扫描可安装的 Skills...',
|
|
36
36
|
'step.select_skill': '选择要安装的 Skill',
|
|
37
|
+
'step.select_skills': '选择要安装的 Skills(空格选择,回车确认)',
|
|
37
38
|
'step.select_agent': '选择目标 Agent',
|
|
38
39
|
'step.select_scope': '选择安装范围',
|
|
39
40
|
'step.install': '开始安装...',
|
|
@@ -43,6 +44,7 @@ const translations = {
|
|
|
43
44
|
'msg.found_skills': '找到 {count} 个 skill',
|
|
44
45
|
'msg.no_skills': '当前目录未找到任何 skill (需要包含 SKILL.md 的文件夹)',
|
|
45
46
|
'msg.selected': '选择',
|
|
47
|
+
'msg.selected_count': '已选择 {count} 个',
|
|
46
48
|
'msg.agent': 'Agent',
|
|
47
49
|
'msg.scope': '范围',
|
|
48
50
|
'msg.location': '位置',
|
|
@@ -57,6 +59,11 @@ const translations = {
|
|
|
57
59
|
'msg.dry_run_hint': '使用 --dry-run 预览,未执行任何操作',
|
|
58
60
|
'msg.skill_exists': '该 skill 已存在',
|
|
59
61
|
'msg.install_cancelled': '安装已取消',
|
|
62
|
+
'msg.downloading': '正在下载...',
|
|
63
|
+
'msg.downloaded': '下载完成',
|
|
64
|
+
'msg.installing': '正在安装',
|
|
65
|
+
'msg.skipped': '已跳过',
|
|
66
|
+
'msg.installed': '已安装',
|
|
60
67
|
|
|
61
68
|
// Prompts
|
|
62
69
|
'prompt.workspace_path': '输入 Workspace 路径',
|
|
@@ -72,6 +79,7 @@ const translations = {
|
|
|
72
79
|
// Errors
|
|
73
80
|
'error.empty_path': '路径不能为空',
|
|
74
81
|
'error.not_implemented': '该命令尚未实现,请使用交互模式',
|
|
82
|
+
'error.no_selection': '请至少选择一个 skill',
|
|
75
83
|
|
|
76
84
|
// Help
|
|
77
85
|
'help.usage': '用法',
|
|
@@ -94,6 +102,7 @@ const translations = {
|
|
|
94
102
|
// Steps
|
|
95
103
|
'step.scan': 'Scanning available skills...',
|
|
96
104
|
'step.select_skill': 'Select skill to install',
|
|
105
|
+
'step.select_skills': 'Select skills to install (space to select, enter to confirm)',
|
|
97
106
|
'step.select_agent': 'Select target agent',
|
|
98
107
|
'step.select_scope': 'Select installation scope',
|
|
99
108
|
'step.install': 'Starting installation...',
|
|
@@ -103,6 +112,7 @@ const translations = {
|
|
|
103
112
|
'msg.found_skills': 'Found {count} skill(s)',
|
|
104
113
|
'msg.no_skills': 'No skills found in current directory (folders with SKILL.md required)',
|
|
105
114
|
'msg.selected': 'Selected',
|
|
115
|
+
'msg.selected_count': 'Selected {count}',
|
|
106
116
|
'msg.agent': 'Agent',
|
|
107
117
|
'msg.scope': 'Scope',
|
|
108
118
|
'msg.location': 'Location',
|
|
@@ -117,6 +127,11 @@ const translations = {
|
|
|
117
127
|
'msg.dry_run_hint': 'Running with --dry-run, no changes made',
|
|
118
128
|
'msg.skill_exists': 'Skill already exists',
|
|
119
129
|
'msg.install_cancelled': 'Installation cancelled',
|
|
130
|
+
'msg.downloading': 'Downloading...',
|
|
131
|
+
'msg.downloaded': 'Downloaded',
|
|
132
|
+
'msg.installing': 'Installing',
|
|
133
|
+
'msg.skipped': 'Skipped',
|
|
134
|
+
'msg.installed': 'Installed',
|
|
120
135
|
|
|
121
136
|
// Prompts
|
|
122
137
|
'prompt.workspace_path': 'Enter workspace path',
|
|
@@ -132,6 +147,7 @@ const translations = {
|
|
|
132
147
|
// Errors
|
|
133
148
|
'error.empty_path': 'Path cannot be empty',
|
|
134
149
|
'error.not_implemented': 'Command not implemented, use interactive mode',
|
|
150
|
+
'error.no_selection': 'Please select at least one skill',
|
|
135
151
|
|
|
136
152
|
// Help
|
|
137
153
|
'help.usage': 'Usage',
|
package/src/scanner.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Skill Scanner
|
|
3
3
|
* Scans a directory for installable skills (folders with SKILL.md)
|
|
4
|
+
* Also checks common skill subdirectories like 'skills/'
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import fs from 'fs/promises';
|
|
7
8
|
import path from 'path';
|
|
8
9
|
|
|
10
|
+
// Common skill container directories
|
|
11
|
+
const SKILL_CONTAINERS = ['skills', '.agents/skills', '.claude/skills'];
|
|
12
|
+
|
|
9
13
|
/**
|
|
10
|
-
* Scan directory for skills
|
|
14
|
+
* Scan a single directory for skills
|
|
11
15
|
* @param {string} dir - Directory to scan
|
|
12
|
-
* @returns {Promise<Array
|
|
16
|
+
* @returns {Promise<Array>} Found skills
|
|
13
17
|
*/
|
|
14
|
-
|
|
18
|
+
async function scanSingleDir(dir) {
|
|
15
19
|
const skills = [];
|
|
16
20
|
|
|
17
21
|
try {
|
|
@@ -40,9 +44,38 @@ export async function scanSkills(dir) {
|
|
|
40
44
|
// No SKILL.md or parse error, skip
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
|
-
} catch
|
|
47
|
+
} catch {
|
|
44
48
|
// Directory read error
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
return skills;
|
|
48
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scan directory for skills
|
|
56
|
+
* @param {string} dir - Directory to scan
|
|
57
|
+
* @returns {Promise<Array<{name: string, path: string, description: string}>>}
|
|
58
|
+
*/
|
|
59
|
+
export async function scanSkills(dir) {
|
|
60
|
+
// First scan root directory
|
|
61
|
+
const skills = await scanSingleDir(dir);
|
|
62
|
+
if (skills.length > 0) {
|
|
63
|
+
return skills;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If no skills found, check common skill container directories
|
|
67
|
+
for (const container of SKILL_CONTAINERS) {
|
|
68
|
+
const containerPath = path.join(dir, container);
|
|
69
|
+
try {
|
|
70
|
+
await fs.access(containerPath);
|
|
71
|
+
const containerSkills = await scanSingleDir(containerPath);
|
|
72
|
+
if (containerSkills.length > 0) {
|
|
73
|
+
return containerSkills;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Container doesn't exist, skip
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [];
|
|
81
|
+
}
|