@haolin-ai/skillman 1.0.1 → 1.0.2
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 +185 -68
- 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,12 +5,13 @@
|
|
|
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
|
const VERSION = '1.0.0';
|
|
@@ -71,6 +72,7 @@ function showHelp() {
|
|
|
71
72
|
${c.cyan}${t('help.usage')}:${c.reset}
|
|
72
73
|
skillman ${t('help.cmd.interactive')}
|
|
73
74
|
skillman install <path> ${t('help.cmd.install')}
|
|
75
|
+
skillman i <path> ${t('help.cmd.install')}
|
|
74
76
|
skillman agents ${t('help.cmd.agents')}
|
|
75
77
|
|
|
76
78
|
${c.cyan}${t('help.options')}:${c.reset}
|
|
@@ -82,6 +84,7 @@ ${c.cyan}${t('help.examples')}:${c.reset}
|
|
|
82
84
|
skillman # ${t('help.cmd.interactive')}
|
|
83
85
|
skillman --dry-run # ${t('help.opt.dry_run')}
|
|
84
86
|
skillman install ./my-skill # ${t('help.cmd.install')}
|
|
87
|
+
skillman i github.com/owner/repo # ${t('help.cmd.install')}
|
|
85
88
|
skillman agents # ${t('help.cmd.agents')}
|
|
86
89
|
`);
|
|
87
90
|
}
|
|
@@ -105,14 +108,32 @@ async function listAgents() {
|
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
//
|
|
109
|
-
async function
|
|
111
|
+
// Install from URL or local path
|
|
112
|
+
async function installFromUrl(url, dryRun) {
|
|
110
113
|
console.log(`${c.green}${t('app.name')}${c.reset} - ${t('app.description')}${dryRun ? c.yellow + ' [DRY-RUN]' + c.reset : ''}\n`);
|
|
111
114
|
|
|
112
|
-
|
|
115
|
+
const parsed = parseUrl(url);
|
|
116
|
+
const isRemote = parsed.type !== 'local';
|
|
117
|
+
|
|
118
|
+
// Step 1: Download/resolve path
|
|
119
|
+
let sourcePath;
|
|
120
|
+
if (isRemote) {
|
|
121
|
+
log.step(t('msg.downloading') || 'Downloading...');
|
|
122
|
+
try {
|
|
123
|
+
sourcePath = await downloadSkill(url);
|
|
124
|
+
log.success(t('msg.downloaded') || 'Downloaded');
|
|
125
|
+
} catch (error) {
|
|
126
|
+
log.error(error.message);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
sourcePath = url;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Step 2: Scan skills
|
|
113
134
|
log.step(t('step.scan'));
|
|
135
|
+
const skills = await scanSkills(sourcePath);
|
|
114
136
|
|
|
115
|
-
const skills = await scanSkills(process.cwd());
|
|
116
137
|
if (skills.length === 0) {
|
|
117
138
|
log.error(t('msg.no_skills'));
|
|
118
139
|
process.exit(1);
|
|
@@ -120,25 +141,57 @@ async function interactiveInstall(dryRun) {
|
|
|
120
141
|
|
|
121
142
|
log.success(t('msg.found_skills', { count: skills.length }));
|
|
122
143
|
|
|
123
|
-
// Step
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
// Step 3: Select skills (if multiple)
|
|
145
|
+
let selectedSkills;
|
|
146
|
+
if (skills.length === 1) {
|
|
147
|
+
selectedSkills = [skills[0]];
|
|
148
|
+
log.success(`${t('msg.selected')}: ${skills[0].name}`);
|
|
149
|
+
} else {
|
|
150
|
+
const skillChoices = skills.map(s => ({
|
|
151
|
+
name: s.description
|
|
152
|
+
? `${s.name} ${c.gray}(${s.description.slice(0, 40)}${s.description.length > 40 ? '...' : ''})${c.reset}`
|
|
153
|
+
: s.name,
|
|
154
|
+
value: s
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
selectedSkills = await checkbox({
|
|
158
|
+
message: t('step.select_skills') + ':',
|
|
159
|
+
choices: skillChoices,
|
|
160
|
+
pageSize: 10,
|
|
161
|
+
loop: false,
|
|
162
|
+
validate: (selected) => {
|
|
163
|
+
if (selected.length === 0) {
|
|
164
|
+
return t('error.no_selection') || 'Please select at least one skill';
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
130
169
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
choices: skillChoices,
|
|
134
|
-
pageSize: 10
|
|
135
|
-
});
|
|
170
|
+
log.success(`${t('msg.selected_count', { count: selectedSkills.length })}`);
|
|
171
|
+
}
|
|
136
172
|
|
|
137
|
-
|
|
173
|
+
// Continue with rest of interactive flow
|
|
174
|
+
await continueInstallMultiple(selectedSkills, dryRun);
|
|
175
|
+
|
|
176
|
+
// Cleanup temp downloads
|
|
177
|
+
if (isRemote) {
|
|
178
|
+
await cleanupDownloads();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
138
181
|
|
|
139
|
-
|
|
182
|
+
// Continue installation after skill selection (multiple skills)
|
|
183
|
+
async function continueInstallMultiple(selectedSkills, dryRun) {
|
|
184
|
+
// Load last used preferences
|
|
185
|
+
const { lastAgent, lastScope } = await loadHistory();
|
|
140
186
|
const agents = await loadAgents();
|
|
141
|
-
|
|
187
|
+
|
|
188
|
+
// Step 4: Select agent (with default from history)
|
|
189
|
+
const agentList = Object.values(agents);
|
|
190
|
+
const defaultAgentIndex = lastAgent
|
|
191
|
+
? agentList.findIndex(a => a.name === lastAgent)
|
|
192
|
+
: -1;
|
|
193
|
+
|
|
194
|
+
const agentChoices = agentList.map(a => ({
|
|
142
195
|
name: a.displayName,
|
|
143
196
|
value: a
|
|
144
197
|
}));
|
|
@@ -146,31 +199,42 @@ async function interactiveInstall(dryRun) {
|
|
|
146
199
|
const agent = await select({
|
|
147
200
|
message: t('step.select_agent') + ':',
|
|
148
201
|
choices: agentChoices,
|
|
149
|
-
pageSize: 10
|
|
202
|
+
pageSize: 10,
|
|
203
|
+
default: defaultAgentIndex >= 0 ? agentChoices[defaultAgentIndex].value : undefined
|
|
150
204
|
});
|
|
151
205
|
|
|
152
206
|
log.success(`${t('msg.agent')}: ${agent.displayName}`);
|
|
153
207
|
|
|
154
|
-
// Step
|
|
208
|
+
// Step 5: Select scope (with default from history)
|
|
209
|
+
const scopeChoices = [
|
|
210
|
+
{ name: `${t('option.global')} ${c.gray}(${agent.globalSkillsDir})${c.reset}`, value: 'global' },
|
|
211
|
+
{ name: `${t('option.workspace')} ${c.gray}(${t('option.custom_path')})${c.reset}`, value: 'workspace' }
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const defaultScopeIndex = lastScope
|
|
215
|
+
? scopeChoices.findIndex(s => s.value === lastScope)
|
|
216
|
+
: -1;
|
|
217
|
+
|
|
155
218
|
const scope = await select({
|
|
156
219
|
message: t('step.select_scope') + ':',
|
|
157
|
-
choices:
|
|
158
|
-
|
|
159
|
-
{ name: `${t('option.workspace')} ${c.gray}(${t('option.custom_path')})${c.reset}`, value: 'workspace' }
|
|
160
|
-
]
|
|
220
|
+
choices: scopeChoices,
|
|
221
|
+
default: defaultScopeIndex >= 0 ? scopeChoices[defaultScopeIndex].value : undefined
|
|
161
222
|
});
|
|
162
223
|
|
|
163
|
-
//
|
|
224
|
+
// Save preferences for next time
|
|
225
|
+
await saveLastUsed(agent.name, scope);
|
|
226
|
+
|
|
227
|
+
// Step 6: If workspace scope, ask for workspace path
|
|
164
228
|
let workspacePath = agent.skillsDir;
|
|
165
229
|
if (scope === 'workspace') {
|
|
166
|
-
const
|
|
230
|
+
const { workspaces } = await loadHistory(agent.name);
|
|
167
231
|
|
|
168
232
|
let customPath;
|
|
169
233
|
|
|
170
|
-
if (
|
|
234
|
+
if (workspaces.length > 0) {
|
|
171
235
|
// Show history choices with separator
|
|
172
236
|
const historyChoices = [
|
|
173
|
-
...
|
|
237
|
+
...workspaces.map((h, idx) => ({
|
|
174
238
|
name: `${idx + 1}. ${h}`,
|
|
175
239
|
value: h
|
|
176
240
|
})),
|
|
@@ -218,64 +282,116 @@ async function interactiveInstall(dryRun) {
|
|
|
218
282
|
log.info(`${t('msg.workspace_dir')}: ${workspacePath}`);
|
|
219
283
|
}
|
|
220
284
|
|
|
221
|
-
//
|
|
222
|
-
const targetDir = scope === 'global'
|
|
223
|
-
? path.join(agent.globalSkillsDir, selectedSkill.name)
|
|
224
|
-
: path.join(workspacePath, selectedSkill.name);
|
|
225
|
-
|
|
226
|
-
// Dry-run preview
|
|
285
|
+
// Dry-run preview for all skills
|
|
227
286
|
if (dryRun) {
|
|
228
287
|
log.step(t('step.preview'));
|
|
229
|
-
log.dry(`${t('msg.source')}: ${selectedSkill.path}`);
|
|
230
|
-
log.dry(`${t('msg.target')}: ${targetDir}`);
|
|
231
288
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
289
|
+
for (const skill of selectedSkills) {
|
|
290
|
+
const targetDir = scope === 'global'
|
|
291
|
+
? path.join(agent.globalSkillsDir, skill.name)
|
|
292
|
+
: path.join(workspacePath, skill.name);
|
|
293
|
+
|
|
294
|
+
log.dry(`\n${skill.name}:`);
|
|
295
|
+
log.dry(` ${t('msg.source')}: ${skill.path}`);
|
|
296
|
+
log.dry(` ${t('msg.target')}: ${targetDir}`);
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
await fs.access(targetDir);
|
|
300
|
+
log.dry(` ${t('msg.exists')}`);
|
|
301
|
+
} catch {
|
|
302
|
+
log.dry(` ${t('msg.not_exists')}`);
|
|
303
|
+
}
|
|
237
304
|
}
|
|
238
305
|
|
|
239
|
-
log.dry(t('msg.copy'));
|
|
240
|
-
|
|
241
306
|
console.log(`\n${c.yellow}📋 ${t('msg.preview_summary')}${c.reset}\n`);
|
|
242
|
-
console.log(`
|
|
307
|
+
console.log(` ${t('msg.selected_count', { count: selectedSkills.length })}`);
|
|
243
308
|
console.log(` ${t('msg.agent')}: ${agent.displayName}`);
|
|
244
309
|
console.log(` ${t('msg.scope')}: ${scope}`);
|
|
245
|
-
console.log(` ${t('msg.location')}: ${targetDir}`);
|
|
246
310
|
console.log(`\n${c.gray}${t('msg.dry_run_hint')}${c.reset}\n`);
|
|
247
311
|
return;
|
|
248
312
|
}
|
|
249
313
|
|
|
250
|
-
// Step
|
|
314
|
+
// Step 8: Install all skills
|
|
251
315
|
log.step(t('step.install'));
|
|
252
316
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
317
|
+
let installedCount = 0;
|
|
318
|
+
let skippedCount = 0;
|
|
319
|
+
|
|
320
|
+
for (const skill of selectedSkills) {
|
|
321
|
+
const targetDir = scope === 'global'
|
|
322
|
+
? path.join(agent.globalSkillsDir, skill.name)
|
|
323
|
+
: path.join(workspacePath, skill.name);
|
|
324
|
+
|
|
325
|
+
log.step(`${t('msg.installing') || 'Installing'}: ${skill.name}`);
|
|
326
|
+
|
|
327
|
+
// Check if already exists
|
|
328
|
+
let shouldInstall = true;
|
|
329
|
+
try {
|
|
330
|
+
await fs.access(targetDir);
|
|
331
|
+
log.warn(t('msg.skill_exists'));
|
|
332
|
+
const overwrite = await confirm({ message: t('prompt.overwrite') + '?', default: false });
|
|
333
|
+
if (!overwrite) {
|
|
334
|
+
log.info(t('msg.skipped') || 'Skipped');
|
|
335
|
+
skippedCount++;
|
|
336
|
+
shouldInstall = false;
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// Directory doesn't exist, proceed
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (shouldInstall) {
|
|
343
|
+
await installSkill(skill.path, targetDir);
|
|
344
|
+
log.success(`${t('msg.target')}: ${targetDir}`);
|
|
345
|
+
installedCount++;
|
|
261
346
|
}
|
|
262
|
-
} catch {
|
|
263
|
-
// Directory doesn't exist, proceed
|
|
264
347
|
}
|
|
265
348
|
|
|
266
|
-
// Install
|
|
267
|
-
await installSkill(selectedSkill.path, targetDir);
|
|
268
|
-
log.success(`${t('msg.target')}: ${targetDir}`);
|
|
269
|
-
|
|
270
349
|
// Summary
|
|
271
350
|
console.log(`\n${c.green}✨ ${t('msg.install_complete')}${c.reset}\n`);
|
|
272
|
-
console.log(`
|
|
351
|
+
console.log(` ${t('msg.installed') || 'Installed'}: ${installedCount}`);
|
|
352
|
+
if (skippedCount > 0) {
|
|
353
|
+
console.log(` ${t('msg.skipped') || 'Skipped'}: ${skippedCount}`);
|
|
354
|
+
}
|
|
273
355
|
console.log(` ${t('msg.agent')}: ${agent.displayName}`);
|
|
274
356
|
console.log(` ${t('msg.scope')}: ${scope}`);
|
|
275
|
-
console.log(` ${t('msg.location')}: ${targetDir}`);
|
|
276
357
|
console.log();
|
|
277
358
|
}
|
|
278
359
|
|
|
360
|
+
// Interactive install flow
|
|
361
|
+
async function interactiveInstall(dryRun) {
|
|
362
|
+
console.log(`${c.green}${t('app.name')}${c.reset} - ${t('app.description')}${dryRun ? c.yellow + ' [DRY-RUN]' + c.reset : ''}\n`);
|
|
363
|
+
|
|
364
|
+
// Step 1: Scan skills
|
|
365
|
+
log.step(t('step.scan'));
|
|
366
|
+
|
|
367
|
+
const skills = await scanSkills(process.cwd());
|
|
368
|
+
if (skills.length === 0) {
|
|
369
|
+
log.error(t('msg.no_skills'));
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
log.success(t('msg.found_skills', { count: skills.length }));
|
|
374
|
+
|
|
375
|
+
// Step 2: Select skill
|
|
376
|
+
const skillChoices = skills.map(s => ({
|
|
377
|
+
name: s.description
|
|
378
|
+
? `${s.name} ${c.gray}(${s.description.slice(0, 40)}${s.description.length > 40 ? '...' : ''})${c.reset}`
|
|
379
|
+
: s.name,
|
|
380
|
+
value: s
|
|
381
|
+
}));
|
|
382
|
+
|
|
383
|
+
const selectedSkill = await select({
|
|
384
|
+
message: t('step.select_skill') + ':',
|
|
385
|
+
choices: skillChoices,
|
|
386
|
+
pageSize: 10
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
log.success(`${t('msg.selected')}: ${selectedSkill.name}`);
|
|
390
|
+
|
|
391
|
+
// Continue with agent selection and installation
|
|
392
|
+
await continueInstall(selectedSkill, dryRun);
|
|
393
|
+
}
|
|
394
|
+
|
|
279
395
|
// Main CLI function
|
|
280
396
|
export async function cli() {
|
|
281
397
|
const args = process.argv.slice(2);
|
|
@@ -296,9 +412,10 @@ export async function cli() {
|
|
|
296
412
|
return;
|
|
297
413
|
}
|
|
298
414
|
|
|
299
|
-
if (options.command === 'install') {
|
|
300
|
-
|
|
301
|
-
|
|
415
|
+
if (options.command === 'install' || options.command === 'i') {
|
|
416
|
+
const url = options.positional[0] || process.cwd();
|
|
417
|
+
await installFromUrl(url, options.dryRun);
|
|
418
|
+
return;
|
|
302
419
|
}
|
|
303
420
|
|
|
304
421
|
// 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
|
+
}
|