@haolin-ai/skillman 1.0.6 → 1.0.8

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 CHANGED
@@ -47,9 +47,22 @@ skillman install git@github.com:org/repo.git
47
47
  ### Commands
48
48
 
49
49
  ```bash
50
+ # Initialize a new skill template
51
+ skillman init [skill-name]
52
+ skillman init my-skill -v 2.0.0 -d "Description" -a "Author"
53
+
50
54
  # List all available agents
51
55
  skillman agents
52
56
 
57
+ # List installed skills
58
+ skillman list
59
+
60
+ # Update a skill
61
+ skillman update my-skill
62
+
63
+ # Uninstall a skill
64
+ skillman uninstall my-skill
65
+
53
66
  # Preview installation without making changes
54
67
  skillman --dry-run
55
68
 
@@ -63,6 +76,8 @@ skillman --version
63
76
  ## Features
64
77
 
65
78
  - **Multi-Agent Support**: Works with Claude Code, OpenClaw, Qoder, Codex, Cursor, and more
79
+ - **Skill Template Generator**: Quickly create new skills with `skillman init`
80
+ - **Version Management**: Track and manage skill versions with `list`, `update`, and `uninstall` commands
66
81
  - **Workspace History**: Remembers previously used workspace paths for quick selection
67
82
  - **Bilingual Support**: Automatically switches between English and Chinese based on system language
68
83
  - **Dry-Run Mode**: Preview installations before applying changes
@@ -91,7 +106,7 @@ git clone <repo-url>
91
106
  cd skillman
92
107
 
93
108
  # Install dependencies
94
- pnpm install
109
+ npm install
95
110
 
96
111
  # Run locally
97
112
  node bin/skillman.mjs
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@haolin-ai/skillman",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "A CLI tool to install AI agent skills across multiple platforms",
5
5
  "type": "module",
6
6
  "bin": {
7
- "skillman": "./bin/skillman.mjs"
7
+ "skillman": "bin/skillman.mjs"
8
8
  },
9
9
  "files": [
10
10
  "bin",
package/src/cli.js CHANGED
@@ -7,16 +7,27 @@ import path from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { select, confirm, input, Separator, checkbox } from '@inquirer/prompts';
9
9
  import pkg from '../package.json' with { type: 'json' };
10
- import { scanSkills } from './scanner.js';
10
+ import { scanSkills, parseSkillFile } from './scanner.js';
11
11
  import { installSkill } from './installer.js';
12
12
  import { loadAgents } from './config.js';
13
13
  import { t } from './i18n.js';
14
14
  import { loadHistory, addWorkspace, saveLastUsed } from './history.js';
15
15
  import { downloadSkill, parseUrl, cleanupDownloads } from './downloader.js';
16
+ import { InstalledSkillRegistry, formatInstalledSkills, uninstallSkill, updateSkill } from './version.js';
16
17
 
17
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
19
  const VERSION = pkg.version || '1.0.0';
19
20
 
21
+ // ASCII Art Logo
22
+ const LOGO = `
23
+ ███████╗██╗ ██╗██╗██╗ ██╗ ███╗ ███╗ █████╗ ███╗ ██╗
24
+ ██╔════╝██║ ██╔╝██║██║ ██║ ████╗ ████║██╔══██╗████╗ ██║
25
+ ███████╗█████╔╝ ██║██║ ██║ ██╔████╔██║███████║██╔██╗ ██║
26
+ ╚════██║██╔═██╗ ██║██║ ██║ ██║╚██╔╝██║██╔══██║██║╚██╗██║
27
+ ███████║██║ ██╗██║███████╗███████╗██║ ╚═╝ ██║██║ ██║██║ ╚████║
28
+ ╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝
29
+ `;
30
+
20
31
  // ANSI colors
21
32
  const c = {
22
33
  reset: '\x1b[0m',
@@ -41,22 +52,51 @@ const log = {
41
52
  function parseArgs(args) {
42
53
  const result = {
43
54
  command: null,
55
+ subcommand: null,
44
56
  dryRun: false,
45
57
  help: false,
46
58
  version: false,
59
+ initVersion: '1.0.0',
60
+ initDescription: '',
61
+ initAuthor: '',
62
+ initDir: true,
47
63
  positional: []
48
64
  };
49
65
 
50
- for (const arg of args) {
66
+ for (let i = 0; i < args.length; i++) {
67
+ const arg = args[i];
51
68
  if (arg === '--dry-run' || arg === '-n') {
52
69
  result.dryRun = true;
53
70
  } else if (arg === '--help' || arg === '-h') {
54
71
  result.help = true;
55
72
  } else if (arg === '--version' || arg === '-v') {
56
- result.version = true;
73
+ // Check if next arg is a value (for init command)
74
+ if (args[i + 1] && !args[i + 1].startsWith('-')) {
75
+ result.initVersion = args[i + 1];
76
+ i++;
77
+ } else {
78
+ result.version = true;
79
+ }
80
+ } else if (arg === '--description' || arg === '-d') {
81
+ if (args[i + 1] && !args[i + 1].startsWith('-')) {
82
+ result.initDescription = args[i + 1];
83
+ i++;
84
+ }
85
+ } else if (arg === '--author' || arg === '-a') {
86
+ if (args[i + 1] && !args[i + 1].startsWith('-')) {
87
+ result.initAuthor = args[i + 1];
88
+ i++;
89
+ }
90
+ } else if (arg === '--dir') {
91
+ if (args[i + 1] && !args[i + 1].startsWith('-')) {
92
+ result.initDir = args[i + 1].toLowerCase() !== 'false';
93
+ i++;
94
+ }
57
95
  } else if (!arg.startsWith('-')) {
58
96
  if (!result.command) {
59
97
  result.command = arg;
98
+ } else if (!result.subcommand) {
99
+ result.subcommand = arg;
60
100
  } else {
61
101
  result.positional.push(arg);
62
102
  }
@@ -72,8 +112,13 @@ function showHelp() {
72
112
 
73
113
  ${c.cyan}${t('help.usage')}:${c.reset}
74
114
  skillman ${t('help.cmd.interactive')}
115
+ skillman init [name] ${t('help.cmd.init')}
75
116
  skillman install <path> ${t('help.cmd.install')}
76
117
  skillman i <path> ${t('help.cmd.install')}
118
+ skillman list ${t('help.cmd.list')}
119
+ skillman update <skill> ${t('help.cmd.update')}
120
+ skillman u <skill> ${t('help.cmd.update')}
121
+ skillman uninstall <skill> ${t('help.cmd.uninstall')}
77
122
  skillman agents ${t('help.cmd.agents')}
78
123
 
79
124
  ${c.cyan}${t('help.options')}:${c.reset}
@@ -83,9 +128,15 @@ ${c.cyan}${t('help.options')}:${c.reset}
83
128
 
84
129
  ${c.cyan}${t('help.examples')}:${c.reset}
85
130
  skillman # ${t('help.cmd.interactive')}
131
+ skillman init # Create skill with default name
132
+ skillman init my-skill # Create skill with custom name
133
+ skillman init -v 2.0.0 # Create skill with specific version
86
134
  skillman --dry-run # ${t('help.opt.dry_run')}
87
135
  skillman install ./my-skill # ${t('help.cmd.install')}
88
136
  skillman i github.com/owner/repo # ${t('help.cmd.install')}
137
+ skillman list # ${t('help.cmd.list')}
138
+ skillman update my-skill # ${t('help.cmd.update')}
139
+ skillman uninstall my-skill # ${t('help.cmd.uninstall')}
89
140
  skillman agents # ${t('help.cmd.agents')}
90
141
  `);
91
142
  }
@@ -109,11 +160,75 @@ async function listAgents() {
109
160
  }
110
161
  }
111
162
 
112
- // Install from URL or local path
163
+ // Initialize a new skill template
164
+ export async function initSkill(skillName, options) {
165
+ const name = skillName || 'my-skill';
166
+ const version = options.initVersion || '1.0.0';
167
+ const description = options.initDescription || '';
168
+ const author = options.initAuthor || '';
169
+ const createDir = options.initDir !== false;
170
+
171
+ console.log(`${c.cyan}${LOGO}${c.reset}`);
172
+ log.step(t('msg.init_skill') || 'Initializing skill template');
173
+
174
+ const targetDir = createDir ? path.join(process.cwd(), name) : process.cwd();
175
+ const skillFile = path.join(targetDir, 'SKILL.md');
176
+
177
+ // Check if already exists
178
+ try {
179
+ await fs.access(skillFile);
180
+ log.error(t('error.skill_exists') || 'SKILL.md already exists');
181
+ process.exit(1);
182
+ } catch {
183
+ // File doesn't exist, proceed
184
+ }
185
+
186
+ // Create directory if needed
187
+ if (createDir) {
188
+ await fs.mkdir(targetDir, { recursive: true });
189
+ }
190
+
191
+ // Build metadata section
192
+ let metadataSection = `metadata:\n version: ${version}`;
193
+ if (author) {
194
+ metadataSection += `\n author: ${author}`;
195
+ }
196
+
197
+ // Generate SKILL.md content
198
+ const skillContent = `---
199
+ name: ${name}
200
+ description: ${description}
201
+ ${metadataSection}
202
+ ---
203
+
204
+ # ${name}
205
+
206
+ ## Purpose
207
+
208
+ ## Responsibilities
209
+
210
+ ## Decision Rules
211
+
212
+ ## Output Contract
213
+ `;
214
+
215
+ await fs.writeFile(skillFile, skillContent);
216
+
217
+ log.success(`${t('msg.created') || 'Created'}: ${skillFile}`);
218
+ console.log(`\n${c.gray}${t('msg.init_hint') || 'Edit SKILL.md to customize your skill'}${c.reset}`);
219
+ }
220
+
221
+ // Install from URL or local path with optional version
113
222
  async function installFromUrl(url, dryRun) {
114
- console.log(`${c.green}${t('app.name')}${c.reset} - ${t('app.description')}${dryRun ? c.yellow + ' [DRY-RUN]' + c.reset : ''}\n`);
223
+ // Parse version from URL (e.g., skill@1.2.3 or github.com/owner/repo@1.2.3)
224
+ const versionMatch = url.match(/@([^@\/]+)$/);
225
+ const requestedVersion = versionMatch ? versionMatch[1] : null;
226
+ const cleanUrl = requestedVersion ? url.slice(0, -versionMatch[0].length) : url;
227
+
228
+ console.log(`${c.cyan}${LOGO}${c.reset}`);
229
+ console.log(`${c.gray}${t('app.description')}${dryRun ? c.yellow + ' [DRY-RUN]' + c.reset : ''}\n`);
115
230
 
116
- const parsed = parseUrl(url);
231
+ const parsed = parseUrl(cleanUrl);
117
232
  const isRemote = parsed.type !== 'local';
118
233
 
119
234
  // Step 1: Download/resolve path
@@ -142,35 +257,22 @@ async function installFromUrl(url, dryRun) {
142
257
 
143
258
  log.success(t('msg.found_skills', { count: skills.length }));
144
259
 
145
- // Step 3: Select skills (if multiple)
146
- let selectedSkills;
147
- if (skills.length === 1) {
148
- selectedSkills = [skills[0]];
149
- log.success(`${t('msg.selected')}: ${skills[0].name}`);
150
- } else {
151
- const skillChoices = skills.map(s => ({
152
- name: s.description
153
- ? `${s.name} ${c.gray}(${s.description.slice(0, 40)}${s.description.length > 40 ? '...' : ''})${c.reset}`
154
- : s.name,
155
- value: s
156
- }));
157
-
158
- selectedSkills = await checkbox({
159
- message: t('step.select_skills') + ':',
160
- choices: skillChoices,
161
- pageSize: 10,
162
- loop: false,
163
- validate: (selected) => {
164
- if (selected.length === 0) {
165
- return t('error.no_selection') || 'Please select at least one skill';
166
- }
167
- return true;
168
- }
169
- });
170
-
171
- log.success(`${t('msg.selected_count', { count: selectedSkills.length })}`);
260
+ // Check version if specified
261
+ if (requestedVersion) {
262
+ const matchingSkills = skills.filter(s => s.version === requestedVersion);
263
+ if (matchingSkills.length === 0) {
264
+ const availableVersions = [...new Set(skills.map(s => s.version).filter(Boolean))];
265
+ log.error(`Version ${requestedVersion} not found. Available versions: ${availableVersions.join(', ') || 'none'}`);
266
+ process.exit(1);
267
+ }
268
+ skills.length = 0;
269
+ skills.push(...matchingSkills);
270
+ log.success(`Found ${skills.length} skill(s) matching version ${requestedVersion}`);
172
271
  }
173
272
 
273
+ // Step 3: Select skills
274
+ const selectedSkills = await selectSkills(skills);
275
+
174
276
  // Continue with rest of interactive flow
175
277
  await continueInstallMultiple(selectedSkills, dryRun);
176
278
 
@@ -329,7 +431,13 @@ async function continueInstallMultiple(selectedSkills, dryRun) {
329
431
  let shouldInstall = true;
330
432
  try {
331
433
  await fs.access(targetDir);
332
- log.warn(t('msg.skill_exists'));
434
+ // Get existing skill version
435
+ const existingSkillFile = path.join(targetDir, 'SKILL.md');
436
+ const existingSkill = await parseSkillFile(existingSkillFile);
437
+ const currentVer = existingSkill?.version || '?';
438
+ const newVer = skill.version || '?';
439
+ log.warn(`${skill.name} ${t('msg.already_exists') || 'already exists'}`);
440
+ console.log(` ${c.gray}Current:${c.reset} v${currentVer} ${c.gray}→${c.reset} ${c.gray}Installing:${c.reset} v${newVer}`);
333
441
  const overwrite = await confirm({ message: t('prompt.overwrite') + '?', default: false });
334
442
  if (!overwrite) {
335
443
  log.info(t('msg.skipped') || 'Skipped');
@@ -341,7 +449,12 @@ async function continueInstallMultiple(selectedSkills, dryRun) {
341
449
  }
342
450
 
343
451
  if (shouldInstall) {
344
- await installSkill(skill.path, targetDir);
452
+ await installSkill(skill.path, targetDir, {
453
+ name: skill.name,
454
+ version: skill.version,
455
+ agent: agent.name,
456
+ scope: scope
457
+ });
345
458
  log.success(`${t('msg.target')}: ${targetDir}`);
346
459
  installedCount++;
347
460
  }
@@ -358,9 +471,133 @@ async function continueInstallMultiple(selectedSkills, dryRun) {
358
471
  console.log();
359
472
  }
360
473
 
474
+ // Helper: Select skills from list
475
+ async function selectSkills(skills, message = null) {
476
+ if (skills.length === 1) {
477
+ log.success(`${t('msg.selected')}: ${skills[0].name}`);
478
+ return [skills[0]];
479
+ }
480
+
481
+ const skillChoices = skills.map(s => {
482
+ const versionStr = s.version ? `@${s.version}` : '';
483
+ const descStr = s.description
484
+ ? ` ${c.gray}(${s.description.slice(0, 40)}${s.description.length > 40 ? '...' : ''})${c.reset}`
485
+ : '';
486
+ return {
487
+ name: `${s.name}${versionStr}${descStr}`,
488
+ value: s
489
+ };
490
+ });
491
+
492
+ const selectedSkills = await checkbox({
493
+ message: (message || t('step.select_skills')) + ':',
494
+ choices: skillChoices,
495
+ pageSize: 10,
496
+ loop: false,
497
+ validate: (selected) => {
498
+ if (selected.length === 0) {
499
+ return t('error.no_selection') || 'Please select at least one skill';
500
+ }
501
+ return true;
502
+ }
503
+ });
504
+
505
+ log.success(`${t('msg.selected_count', { count: selectedSkills.length })}`);
506
+ return selectedSkills;
507
+ }
508
+
509
+ // List installed skills
510
+ async function listSkills() {
511
+ const registry = new InstalledSkillRegistry();
512
+ const skills = await registry.load();
513
+
514
+ console.log(`\n${c.cyan}${t('msg.installed_skills')}:${c.reset}\n`);
515
+
516
+ const lines = formatInstalledSkills(skills, t);
517
+ for (const line of lines) {
518
+ console.log(line);
519
+ }
520
+ }
521
+
522
+ // Uninstall a skill
523
+ async function uninstallCommand(skillName, dryRun) {
524
+ if (!skillName) {
525
+ log.error(t('error.no_skill_name') || 'Please specify a skill name');
526
+ process.exit(1);
527
+ }
528
+
529
+ const registry = new InstalledSkillRegistry();
530
+ const skill = await registry.find(skillName);
531
+
532
+ if (!skill) {
533
+ log.error(t('error.skill_not_installed'));
534
+ process.exit(1);
535
+ }
536
+
537
+ if (dryRun) {
538
+ log.dry(`${t('msg.uninstalling') || 'Would uninstall'}: ${skillName}`);
539
+ log.dry(` ${t('msg.target')}: ${skill.targetPath}`);
540
+ return;
541
+ }
542
+
543
+ const confirmed = await confirm({
544
+ message: `${t('prompt.uninstall')} ${skillName}?`,
545
+ default: false
546
+ });
547
+
548
+ if (!confirmed) {
549
+ log.info(t('msg.cancelled') || 'Cancelled');
550
+ return;
551
+ }
552
+
553
+ const success = await uninstallSkill(skillName, registry);
554
+
555
+ if (success) {
556
+ log.success(`${t('msg.uninstalled')}: ${skillName}`);
557
+ } else {
558
+ log.error(t('error.unknown'));
559
+ process.exit(1);
560
+ }
561
+ }
562
+
563
+ // Update a skill
564
+ async function updateCommand(skillName, dryRun) {
565
+ if (!skillName) {
566
+ log.error(t('error.no_skill_name') || 'Please specify a skill name');
567
+ process.exit(1);
568
+ }
569
+
570
+ const registry = new InstalledSkillRegistry();
571
+ const skill = await registry.find(skillName);
572
+
573
+ if (!skill) {
574
+ log.error(t('error.skill_not_installed'));
575
+ process.exit(1);
576
+ }
577
+
578
+ if (dryRun) {
579
+ log.dry(`${t('msg.updating') || 'Would update'}: ${skillName}`);
580
+ log.dry(` ${t('msg.source')}: ${skill.sourcePath}`);
581
+ log.dry(` ${t('msg.target')}: ${skill.targetPath}`);
582
+ return;
583
+ }
584
+
585
+ log.step(`${t('msg.updating') || 'Updating'}: ${skillName}`);
586
+
587
+ const result = await updateSkill(skillName, registry);
588
+
589
+ if (result.success) {
590
+ log.success(`${t('msg.updated')}: ${skillName}`);
591
+ } else {
592
+ log.error(result.message);
593
+ process.exit(1);
594
+ }
595
+ }
596
+
361
597
  // Interactive install flow
362
598
  async function interactiveInstall(dryRun) {
363
- console.log(`${c.green}${t('app.name')}${c.reset} - ${t('app.description')}${dryRun ? c.yellow + ' [DRY-RUN]' + c.reset : ''}\n`);
599
+ console.log(`${c.cyan}${LOGO}${c.reset}`);
600
+ console.log(`${c.gray}${t('app.description')}${dryRun ? c.yellow + ' [DRY-RUN]' + c.reset : ''}\n`);
364
601
 
365
602
  // Step 1: Scan skills
366
603
  log.step(t('step.scan'));
@@ -373,24 +610,11 @@ async function interactiveInstall(dryRun) {
373
610
 
374
611
  log.success(t('msg.found_skills', { count: skills.length }));
375
612
 
376
- // Step 2: Select skill
377
- const skillChoices = skills.map(s => ({
378
- name: s.description
379
- ? `${s.name} ${c.gray}(${s.description.slice(0, 40)}${s.description.length > 40 ? '...' : ''})${c.reset}`
380
- : s.name,
381
- value: s
382
- }));
383
-
384
- const selectedSkill = await select({
385
- message: t('step.select_skill') + ':',
386
- choices: skillChoices,
387
- pageSize: 10
388
- });
389
-
390
- log.success(`${t('msg.selected')}: ${selectedSkill.name}`);
613
+ // Step 2: Select skills
614
+ const selectedSkills = await selectSkills(skills);
391
615
 
392
616
  // Continue with agent selection and installation
393
- await continueInstall(selectedSkill, dryRun);
617
+ await continueInstallMultiple(selectedSkills, dryRun);
394
618
  }
395
619
 
396
620
  // Main CLI function
@@ -413,12 +637,33 @@ export async function cli() {
413
637
  return;
414
638
  }
415
639
 
640
+ if (options.command === 'init') {
641
+ const skillName = options.subcommand || options.positional[0];
642
+ await initSkill(skillName, options);
643
+ return;
644
+ }
645
+
416
646
  if (options.command === 'install' || options.command === 'i') {
417
647
  const url = options.positional[0] || process.cwd();
418
648
  await installFromUrl(url, options.dryRun);
419
649
  return;
420
650
  }
421
651
 
652
+ if (options.command === 'list') {
653
+ await listSkills();
654
+ return;
655
+ }
656
+
657
+ if (options.command === 'uninstall') {
658
+ await uninstallCommand(options.subcommand, options.dryRun);
659
+ return;
660
+ }
661
+
662
+ if (options.command === 'update' || options.command === 'u') {
663
+ await updateCommand(options.subcommand, options.dryRun);
664
+ return;
665
+ }
666
+
422
667
  // Default: interactive install
423
668
  await interactiveInstall(options.dryRun);
424
669
  }
package/src/i18n.js CHANGED
@@ -64,12 +64,25 @@ const translations = {
64
64
  'msg.installing': '正在安装',
65
65
  'msg.skipped': '已跳过',
66
66
  'msg.installed': '已安装',
67
+ 'msg.no_installed_skills': '没有已安装的技能',
68
+ 'msg.installed_skills': '已安装的技能',
69
+ 'msg.skill_not_found': '未找到技能',
70
+ 'msg.uninstalled': '已卸载',
71
+ 'msg.updated': '已更新',
72
+ 'msg.updating': '正在更新',
73
+ 'msg.uninstalling': '正在卸载',
74
+ 'msg.cancelled': '已取消',
75
+ 'msg.already_exists': '已存在',
76
+ 'msg.init_skill': '初始化 Skill 模板',
77
+ 'msg.created': '已创建',
78
+ 'msg.init_hint': '编辑 SKILL.md 来自定义你的 skill',
67
79
 
68
80
  // Prompts
69
81
  'prompt.workspace_path': '输入 Workspace 路径',
70
82
  'prompt.overwrite': '是否覆盖',
71
83
  'prompt.select_workspace': '选择 Workspace 路径',
72
84
  'prompt.new_path': '输入新路径...',
85
+ 'prompt.uninstall': '确认卸载',
73
86
 
74
87
  // Options
75
88
  'option.global': '全局',
@@ -80,13 +93,19 @@ const translations = {
80
93
  'error.empty_path': '路径不能为空',
81
94
  'error.not_implemented': '该命令尚未实现,请使用交互模式',
82
95
  'error.no_selection': '请至少选择一个 skill',
96
+ 'error.skill_not_installed': '技能未安装',
97
+ 'error.no_skill_name': '请指定技能名称',
83
98
 
84
99
  // Help
85
100
  'help.usage': '用法',
86
101
  'help.options': '选项',
87
102
  'help.examples': '示例',
88
103
  'help.cmd.interactive': '交互式安装 (扫描当前目录)',
104
+ 'help.cmd.init': '初始化新的 skill 模板',
89
105
  'help.cmd.install': '从指定路径安装 skill',
106
+ 'help.cmd.list': '列出已安装的技能',
107
+ 'help.cmd.update': '更新技能',
108
+ 'help.cmd.uninstall': '卸载技能',
90
109
  'help.cmd.agents': '列出可用的 agents',
91
110
  'help.opt.dry_run': '预览安装而不执行更改',
92
111
  'help.opt.version': '显示版本号',
@@ -126,18 +145,32 @@ const translations = {
126
145
  'msg.preview_summary': 'Preview Summary',
127
146
  'msg.dry_run_hint': 'Running with --dry-run, no changes made',
128
147
  'msg.skill_exists': 'Skill already exists',
148
+ 'msg.already_exists': 'already exists',
129
149
  'msg.install_cancelled': 'Installation cancelled',
130
150
  'msg.downloading': 'Downloading...',
131
151
  'msg.downloaded': 'Downloaded',
132
152
  'msg.installing': 'Installing',
133
153
  'msg.skipped': 'Skipped',
134
154
  'msg.installed': 'Installed',
155
+ 'msg.no_installed_skills': 'No skills installed',
156
+ 'msg.installed_skills': 'Installed Skills',
157
+ 'msg.skill_not_found': 'Skill not found',
158
+ 'msg.uninstalled': 'Uninstalled',
159
+ 'msg.updated': 'Updated',
160
+ 'msg.updating': 'Updating',
161
+ 'msg.uninstalling': 'Uninstalling',
162
+ 'msg.cancelled': 'Cancelled',
163
+ 'msg.already_exists': 'already exists',
164
+ 'msg.init_skill': 'Initializing skill template',
165
+ 'msg.created': 'Created',
166
+ 'msg.init_hint': 'Edit SKILL.md to customize your skill',
135
167
 
136
168
  // Prompts
137
169
  'prompt.workspace_path': 'Enter workspace path',
138
170
  'prompt.overwrite': 'Overwrite existing',
139
171
  'prompt.select_workspace': 'Select workspace path',
140
172
  'prompt.new_path': 'Enter new path...',
173
+ 'prompt.uninstall': 'Confirm uninstall',
141
174
 
142
175
  // Options
143
176
  'option.global': 'Global',
@@ -148,13 +181,19 @@ const translations = {
148
181
  'error.empty_path': 'Path cannot be empty',
149
182
  'error.not_implemented': 'Command not implemented, use interactive mode',
150
183
  'error.no_selection': 'Please select at least one skill',
184
+ 'error.skill_not_installed': 'Skill is not installed',
185
+ 'error.no_skill_name': 'Please specify a skill name',
151
186
 
152
187
  // Help
153
188
  'help.usage': 'Usage',
154
189
  'help.options': 'Options',
155
190
  'help.examples': 'Examples',
156
191
  'help.cmd.interactive': 'Interactive install (scan current directory)',
192
+ 'help.cmd.init': 'Initialize new skill template',
157
193
  'help.cmd.install': 'Install skill from path',
194
+ 'help.cmd.list': 'List installed skills',
195
+ 'help.cmd.update': 'Update a skill',
196
+ 'help.cmd.uninstall': 'Uninstall a skill',
158
197
  'help.cmd.agents': 'List available agents',
159
198
  'help.opt.dry_run': 'Preview installation without making changes',
160
199
  'help.opt.version': 'Show version number',
package/src/installer.js CHANGED
@@ -5,14 +5,34 @@
5
5
 
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
+ import { InstalledSkillRegistry } from './version.js';
8
9
 
9
10
  /**
10
11
  * Install skill by copying to target directory
11
12
  * @param {string} srcPath - Source skill directory
12
13
  * @param {string} targetDir - Target installation directory
14
+ * @param {Object} metadata - Installation metadata
15
+ * @param {string} metadata.name - Skill name
16
+ * @param {string} metadata.version - Skill version
17
+ * @param {string} metadata.agent - Target agent name
18
+ * @param {string} metadata.scope - Installation scope (global|workspace)
13
19
  */
14
- export async function installSkill(srcPath, targetDir) {
20
+ export async function installSkill(srcPath, targetDir, metadata = {}) {
15
21
  await copyDir(srcPath, targetDir);
22
+
23
+ // Record installation if metadata provided
24
+ if (metadata.name) {
25
+ const registry = new InstalledSkillRegistry();
26
+ await registry.add({
27
+ name: metadata.name,
28
+ version: metadata.version,
29
+ installedAt: new Date().toISOString(),
30
+ agent: metadata.agent,
31
+ scope: metadata.scope,
32
+ sourcePath: srcPath,
33
+ targetPath: targetDir
34
+ });
35
+ }
16
36
  }
17
37
 
18
38
  /**
package/src/scanner.js CHANGED
@@ -10,6 +10,42 @@ import path from 'path';
10
10
  // Common skill container directories
11
11
  const SKILL_CONTAINERS = ['skills', '.agents/skills', '.claude/skills'];
12
12
 
13
+ /**
14
+ * Parse a single SKILL.md file to extract metadata
15
+ * @param {string} skillFile - Path to SKILL.md
16
+ * @returns {Promise<Object|null>} Parsed skill info or null if invalid
17
+ */
18
+ export async function parseSkillFile(skillFile) {
19
+ try {
20
+ const content = await fs.readFile(skillFile, 'utf-8');
21
+ const nameMatch = content.match(/^name:\s*(.+)$/m);
22
+ const descMatch = content.match(/^description:\s*(.+)$/m);
23
+
24
+ // Parse version from metadata block (supports quoted and unquoted)
25
+ const metadataMatch = content.match(/metadata:[\s\S]*?(?=\n\w|$)/);
26
+ let version;
27
+ if (metadataMatch) {
28
+ const versionInMeta = metadataMatch[0].match(/^\s+version:\s*(.+)$/m);
29
+ if (versionInMeta) {
30
+ // Remove quotes if present (both single and double)
31
+ version = versionInMeta[1].trim().replace(/^["']|["']$/g, '');
32
+ }
33
+ }
34
+
35
+ if (nameMatch) {
36
+ return {
37
+ name: nameMatch[1].trim(),
38
+ description: descMatch ? descMatch[1].trim() : '',
39
+ version: version
40
+ };
41
+ }
42
+ } catch {
43
+ // File doesn't exist or can't be read
44
+ }
45
+
46
+ return null;
47
+ }
48
+
13
49
  /**
14
50
  * Scan a single directory for skills
15
51
  * @param {string} dir - Directory to scan
@@ -28,20 +64,12 @@ async function scanSingleDir(dir) {
28
64
  const skillPath = path.join(dir, entry.name);
29
65
  const skillFile = path.join(skillPath, 'SKILL.md');
30
66
 
31
- try {
32
- const content = await fs.readFile(skillFile, 'utf-8');
33
- const nameMatch = content.match(/^name:\s*(.+)$/m);
34
- const descMatch = content.match(/^description:\s*(.+)$/m);
35
-
36
- if (nameMatch) {
37
- skills.push({
38
- name: nameMatch[1].trim(),
39
- path: skillPath,
40
- description: descMatch ? descMatch[1].trim() : ''
41
- });
42
- }
43
- } catch {
44
- // No SKILL.md or parse error, skip
67
+ const skillInfo = await parseSkillFile(skillFile);
68
+ if (skillInfo) {
69
+ skills.push({
70
+ ...skillInfo,
71
+ path: skillPath
72
+ });
45
73
  }
46
74
  }
47
75
  } catch {
package/src/version.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Version Management Module
3
+ * Handles tracking, listing, updating, and uninstalling skills
4
+ */
5
+
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import os from 'os';
9
+
10
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'skillman');
11
+ const INSTALLED_FILE = path.join(CONFIG_DIR, 'installed.json');
12
+
13
+ /**
14
+ * Get the path to the installed skills registry file
15
+ * @returns {string}
16
+ */
17
+ export function getInstalledSkillsPath() {
18
+ return INSTALLED_FILE;
19
+ }
20
+
21
+ /**
22
+ * Registry for tracking installed skills
23
+ */
24
+ export class InstalledSkillRegistry {
25
+ constructor(registryPath = INSTALLED_FILE) {
26
+ this.registryPath = registryPath;
27
+ }
28
+
29
+ /**
30
+ * Load all installed skills from registry
31
+ * @returns {Promise<Array>}
32
+ */
33
+ async load() {
34
+ try {
35
+ const content = await fs.readFile(this.registryPath, 'utf-8');
36
+ const data = JSON.parse(content);
37
+ return data.skills || [];
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Save skills to registry
45
+ * @param {Array} skills
46
+ */
47
+ async save(skills) {
48
+ await fs.mkdir(path.dirname(this.registryPath), { recursive: true });
49
+ const data = {
50
+ version: 1,
51
+ updatedAt: new Date().toISOString(),
52
+ skills
53
+ };
54
+ await fs.writeFile(this.registryPath, JSON.stringify(data, null, 2));
55
+ }
56
+
57
+ /**
58
+ * Add a skill to the registry
59
+ * @param {Object} skill
60
+ */
61
+ async add(skill) {
62
+ const skills = await this.load();
63
+ const existingIndex = skills.findIndex(s => s.name === skill.name);
64
+
65
+ if (existingIndex >= 0) {
66
+ skills[existingIndex] = skill;
67
+ } else {
68
+ skills.push(skill);
69
+ }
70
+
71
+ await this.save(skills);
72
+ }
73
+
74
+ /**
75
+ * Remove a skill from the registry
76
+ * @param {string} skillName
77
+ */
78
+ async remove(skillName) {
79
+ const skills = await this.load();
80
+ const filtered = skills.filter(s => s.name !== skillName);
81
+ await this.save(filtered);
82
+ }
83
+
84
+ /**
85
+ * Find a skill by name
86
+ * @param {string} skillName
87
+ * @returns {Promise<Object|undefined>}
88
+ */
89
+ async find(skillName) {
90
+ const skills = await this.load();
91
+ return skills.find(s => s.name === skillName);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Format installed skills for display
97
+ * @param {Array} skills
98
+ * @param {Function} t - Translation function
99
+ * @returns {Array} Formatted lines
100
+ */
101
+ export function formatInstalledSkills(skills, t = (key) => key) {
102
+ if (skills.length === 0) {
103
+ return [t('msg.no_installed_skills')];
104
+ }
105
+
106
+ const lines = [];
107
+ lines.push('');
108
+
109
+ // Group by agent
110
+ const byAgent = skills.reduce((acc, skill) => {
111
+ const agent = skill.agent || 'unknown';
112
+ if (!acc[agent]) acc[agent] = [];
113
+ acc[agent].push(skill);
114
+ return acc;
115
+ }, {});
116
+
117
+ for (const [agent, agentSkills] of Object.entries(byAgent)) {
118
+ lines.push(`${agent}:`);
119
+ for (const skill of agentSkills) {
120
+ const scope = skill.scope === 'global' ? 'G' : 'W';
121
+ const version = skill.version || 'unknown';
122
+ lines.push(` ${skill.name}@${version} [${scope}]`);
123
+ }
124
+ lines.push('');
125
+ }
126
+
127
+ return lines;
128
+ }
129
+
130
+ /**
131
+ * Uninstall a skill
132
+ * @param {string} skillName
133
+ * @param {InstalledSkillRegistry} registry
134
+ * @returns {Promise<boolean>} - true if uninstalled, false if not found
135
+ */
136
+ export async function uninstallSkill(skillName, registry = new InstalledSkillRegistry()) {
137
+ const skill = await registry.find(skillName);
138
+
139
+ if (!skill) {
140
+ return false;
141
+ }
142
+
143
+ // Remove from filesystem
144
+ if (skill.targetPath) {
145
+ try {
146
+ await fs.rm(skill.targetPath, { recursive: true, force: true });
147
+ } catch (error) {
148
+ // Log but continue - we still want to remove from registry
149
+ console.warn(`Warning: Could not remove skill directory: ${error.message}`);
150
+ }
151
+ }
152
+
153
+ // Remove from registry
154
+ await registry.remove(skillName);
155
+
156
+ return true;
157
+ }
158
+
159
+ /**
160
+ * Update a skill by reinstalling from source
161
+ * @param {string} skillName
162
+ * @param {InstalledSkillRegistry} registry
163
+ * @returns {Promise<{success: boolean, message: string}>}
164
+ */
165
+ export async function updateSkill(skillName, registry = new InstalledSkillRegistry()) {
166
+ const skill = await registry.find(skillName);
167
+
168
+ if (!skill) {
169
+ return { success: false, message: 'Skill not installed' };
170
+ }
171
+
172
+ if (!skill.sourcePath) {
173
+ return { success: false, message: 'Cannot update: source path not recorded' };
174
+ }
175
+
176
+ try {
177
+ await fs.access(skill.sourcePath);
178
+ } catch {
179
+ return { success: false, message: 'Source no longer available' };
180
+ }
181
+
182
+ // Reinstall
183
+ await copyDir(skill.sourcePath, skill.targetPath);
184
+
185
+ // Update registry with new timestamp
186
+ await registry.add({
187
+ ...skill,
188
+ updatedAt: new Date().toISOString()
189
+ });
190
+
191
+ return { success: true, message: 'Updated successfully' };
192
+ }
193
+
194
+ /**
195
+ * Copy directory recursively
196
+ * @param {string} src - Source directory
197
+ * @param {string} dest - Destination directory
198
+ */
199
+ async function copyDir(src, dest) {
200
+ await fs.mkdir(dest, { recursive: true });
201
+ const entries = await fs.readdir(src, { withFileTypes: true });
202
+
203
+ for (const entry of entries) {
204
+ const srcPath = path.join(src, entry.name);
205
+ const destPath = path.join(dest, entry.name);
206
+
207
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
208
+
209
+ if (entry.isDirectory()) {
210
+ await copyDir(srcPath, destPath);
211
+ } else {
212
+ await fs.copyFile(srcPath, destPath);
213
+ }
214
+ }
215
+ }