@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haolin-ai/skillman",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "A CLI tool to install AI agent skills across multiple platforms",
5
5
  "type": "module",
6
6
  "bin": {
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
- const VERSION = '1.0.0';
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
- // Interactive install flow
109
- async function interactiveInstall(dryRun) {
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
- // Step 1: Scan skills
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 2: Select skill
124
- const skillChoices = skills.map(s => ({
125
- name: s.description
126
- ? `${s.name} ${c.gray}(${s.description.slice(0, 40)}${s.description.length > 40 ? '...' : ''})${c.reset}`
127
- : s.name,
128
- value: s
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
- const selectedSkill = await select({
132
- message: t('step.select_skill') + ':',
133
- choices: skillChoices,
134
- pageSize: 10
135
- });
180
+ log.success(`${t('msg.selected_count', { count: selectedSkills.length })}`);
181
+ }
136
182
 
137
- log.success(`${t('msg.selected')}: ${selectedSkill.name}`);
183
+ // Continue with rest of interactive flow
184
+ await continueInstallMultiple(selectedSkills, dryRun);
138
185
 
139
- // Step 3: Select agent
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
- const agentChoices = Object.values(agents).map(a => ({
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 4: Select scope
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
- { name: `${t('option.global')} ${c.gray}(${agent.globalSkillsDir})${c.reset}`, value: 'global' },
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
- // Step 5: If workspace scope, ask for workspace path
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 history = await loadHistory(agent.name);
240
+ const { workspaces } = await loadHistory(agent.name);
167
241
 
168
242
  let customPath;
169
243
 
170
- if (history.length > 0) {
244
+ if (workspaces.length > 0) {
171
245
  // Show history choices with separator
172
246
  const historyChoices = [
173
- ...history.map((h, idx) => ({
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
- // Step 6: Calculate target path
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
- try {
233
- await fs.access(targetDir);
234
- log.dry(t('msg.exists'));
235
- } catch {
236
- log.dry(t('msg.not_exists'));
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(` Skill: ${selectedSkill.name}`);
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 6: Install
324
+ // Step 8: Install all skills
251
325
  log.step(t('step.install'));
252
326
 
253
- // Check if already exists
254
- try {
255
- await fs.access(targetDir);
256
- log.warn(t('msg.skill_exists'));
257
- const overwrite = await confirm({ message: t('prompt.overwrite') + '?', default: false });
258
- if (!overwrite) {
259
- log.info(t('msg.install_cancelled'));
260
- process.exit(0);
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(` Skill: ${selectedSkill.name}`);
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
- log.error(t('error.not_implemented'));
301
- process.exit(1);
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 history[agentName]?.workspaces || [];
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
- let workspaces = await loadHistory(agentName);
74
+ const { workspaces } = await loadHistory(agentName);
75
+ let newWorkspaces = [...workspaces];
54
76
 
55
77
  // Remove if exists (to move to front)
56
- workspaces = workspaces.filter(w => path.resolve(w) !== normalized);
78
+ newWorkspaces = newWorkspaces.filter(w => path.resolve(w) !== normalized);
57
79
 
58
80
  // Add to front
59
- workspaces.unshift(normalized);
81
+ newWorkspaces.unshift(normalized);
60
82
 
61
- await saveHistory(agentName, workspaces);
62
- return workspaces;
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<{name: string, path: string, description: string}>>}
16
+ * @returns {Promise<Array>} Found skills
13
17
  */
14
- export async function scanSkills(dir) {
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 (err) {
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
+ }