joyskills-cli 0.3.3 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "joyskills-cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "JoySkills CLI v2.0 - Multi-agent skill management with JoyCode native support",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -3,7 +3,8 @@
3
3
  */
4
4
 
5
5
  import { Command } from 'commander';
6
- import { checkAllSkills, getUpdatableSkills } from '../version-checker.js';
6
+ import { checkAllSkills, getUpdatableSkills, checkSkill } from '../version-checker.js';
7
+ import { SkillLoader } from '../skill-loader.js';
7
8
  import chalk from 'chalk';
8
9
 
9
10
  export function checkCommand(program) {
@@ -11,6 +12,8 @@ export function checkCommand(program) {
11
12
  .command('check')
12
13
  .description('Check for available skill updates')
13
14
  .option('-u, --updatable', 'Show only skills with updates')
15
+ .option('-g, --global', 'Check global (user-level) skills only')
16
+ .option('-p, --project', 'Check project-level skills only')
14
17
  .action(async (options) => {
15
18
  try {
16
19
  const projectRoot = process.cwd();
@@ -19,8 +22,8 @@ export function checkCommand(program) {
19
22
  console.log(chalk.gray('==============================\n'));
20
23
 
21
24
  const results = options.updatable
22
- ? await getUpdatableSkills(projectRoot)
23
- : await checkAllSkills(projectRoot);
25
+ ? await getUpdatableSkills(projectRoot, options.global, options.project)
26
+ : await checkAllSkills(projectRoot, options.global, options.project);
24
27
 
25
28
  if (results.length === 0) {
26
29
  if (options.updatable) {
@@ -8,12 +8,123 @@ import simpleGit from 'simple-git';
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import * as os from 'os';
11
- import { select, confirm } from '@inquirer/prompts';
11
+ import { select, confirm, input } from '@inquirer/prompts';
12
12
  import chalk from 'chalk';
13
13
 
14
+ const JOYSKILL_DIR = process.env.JOYSKILL_CONFIG_DIR || path.join(os.homedir(), '.joyskill');
15
+ const REGISTRY_CACHE_DIR = path.join(JOYSKILL_DIR, 'registries');
16
+
14
17
  // Cache directory for cloned repos
15
18
  const CACHE_DIR = process.env.JOYSKILL_CACHE_DIR || path.join(os.homedir(), '.joyskill', 'cache');
16
19
 
20
+ /**
21
+ * Check if a registry exists in global config
22
+ */
23
+ function registryExists(registryName) {
24
+ const configPath = path.join(JOYSKILL_DIR, 'config.json');
25
+ if (!fs.existsSync(configPath)) return false;
26
+
27
+ try {
28
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
29
+ return config.registries && config.registries[registryName];
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Add a registry to global config (used by auto-prompt)
37
+ */
38
+ async function addRegistryInteractive(registryName, suggestedUrl = null) {
39
+ console.log(chalk.yellow(`\n⚠️ Registry '${registryName}' not found in local config.`));
40
+ console.log(chalk.gray(` This skill is from a team registry. Please add it first.`));
41
+
42
+ let gitUrl = suggestedUrl;
43
+
44
+ if (!gitUrl) {
45
+ gitUrl = await input({
46
+ message: `Enter the Git URL for registry '${registryName}':`,
47
+ validate: (value) => {
48
+ if (!value) return 'Git URL is required';
49
+ if (!value.includes('@') && !value.startsWith('http')) {
50
+ return 'Please enter a valid Git URL (SSH or HTTPS)';
51
+ }
52
+ return true;
53
+ }
54
+ });
55
+ }
56
+
57
+ console.log(chalk.blue(`\n📦 Adding team registry: ${registryName}`));
58
+ console.log(chalk.gray(` URL: ${gitUrl}`));
59
+
60
+ // Clone registry to cache
61
+ const registryCachePath = path.join(REGISTRY_CACHE_DIR, registryName);
62
+
63
+ if (fs.existsSync(registryCachePath)) {
64
+ fs.rmSync(registryCachePath, { recursive: true, force: true });
65
+ }
66
+
67
+ fs.mkdirSync(registryCachePath, { recursive: true });
68
+
69
+ // Clone the registry
70
+ const git = simpleGit();
71
+ await git.clone(gitUrl, registryCachePath, ['--depth', '1']);
72
+
73
+ // Save to global config
74
+ const configPath = path.join(JOYSKILL_DIR, 'config.json');
75
+ let config = { registries: {} };
76
+ if (fs.existsSync(configPath)) {
77
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
78
+ }
79
+
80
+ config.registries[registryName] = {
81
+ url: gitUrl,
82
+ path: registryCachePath,
83
+ addedAt: new Date().toISOString()
84
+ };
85
+
86
+ fs.mkdirSync(JOYSKILL_DIR, { recursive: true });
87
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
88
+
89
+ console.log(chalk.green(` ✓ Added registry '${registryName}'`));
90
+
91
+ return registryCachePath;
92
+ }
93
+
94
+ /**
95
+ * Get registry path with auto-prompt for missing registries
96
+ */
97
+ async function getRegistryPathWithPrompt(registryName, options = {}) {
98
+ if (!registryExists(registryName)) {
99
+ if (options.yes || process.env.CI) {
100
+ throw new Error(`Registry '${registryName}' not found. Run: joySkills team add ${registryName} <git-url>`);
101
+ }
102
+
103
+ // Auto-prompt to add registry
104
+ await addRegistryInteractive(registryName);
105
+ }
106
+
107
+ const configPath = path.join(JOYSKILL_DIR, 'config.json');
108
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
109
+ return config.registries[registryName].path;
110
+ }
111
+
112
+ /**
113
+ * Get git commit hash for a directory
114
+ */
115
+ async function getGitCommit(dirPath) {
116
+ try {
117
+ const git = simpleGit(dirPath);
118
+ const isRepo = await git.checkIsRepo();
119
+ if (!isRepo) return null;
120
+
121
+ const log = await git.log({ maxCount: 1 });
122
+ return log.latest?.hash || null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
17
128
  export function installCommand(program) {
18
129
  program
19
130
  .command('install [skill]')
@@ -131,21 +242,10 @@ async function resolveAndInstall(skillInput, targetDir, projectRoot, options) {
131
242
  throw new Error(`Invalid team:// URL format: ${skillInput}. Expected: team://<registry>/<skill>`);
132
243
  }
133
244
 
134
- // Load registry config
135
- const configPath = path.join(JOYSKILL_CONFIG_DIR, 'config.json');
136
- if (!fs.existsSync(configPath)) {
137
- throw new Error(`No registries configured. Run: joySkills team add <name> <git-url>`);
138
- }
139
-
140
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
141
- const registries = config.registries || {};
142
- const reg = registries[registryName];
245
+ // Get registry path with auto-prompt for missing registries
246
+ const registryPath = await getRegistryPathWithPrompt(registryName, options);
143
247
 
144
- if (!reg?.path || !fs.existsSync(reg.path)) {
145
- throw new Error(`Registry "${registryName}" not found. Run: joySkills team list`);
146
- }
147
-
148
- const hit = await tryInstallFromRegistryDir(skillName, reg.path, targetDir, projectRoot, options, `team:${registryName}`);
248
+ const hit = await tryInstallFromRegistryDir(skillName, registryPath, targetDir, projectRoot, options, `team:${registryName}`);
149
249
  if (!hit) {
150
250
  throw new Error(`Skill "${skillName}" not found in registry "${registryName}"`);
151
251
  }
@@ -202,16 +302,19 @@ async function resolveAndInstall(skillInput, targetDir, projectRoot, options) {
202
302
 
203
303
  const skillMdPath = path.join(skillPath, 'SKILL.md');
204
304
  fs.writeFileSync(skillMdPath, generateSkillTemplate(skillInput));
205
-
206
- const lockfileManager = new LockfileManager(projectRoot);
207
- await lockfileManager.load();
208
- lockfileManager.updateSkill(skillInput, {
209
- version: '1.0.0',
210
- source: 'template',
211
- path: skillPath,
212
- installedAt: new Date().toISOString()
213
- });
214
- await lockfileManager.save();
305
+
306
+ // Update lockfile only for project-level installation (npm-like behavior)
307
+ if (!options.global) {
308
+ const lockfileManager = new LockfileManager(projectRoot);
309
+ await lockfileManager.load();
310
+ lockfileManager.updateSkill(skillInput, {
311
+ version: '1.0.0',
312
+ source: 'template',
313
+ path: skillPath,
314
+ installedAt: new Date().toISOString()
315
+ });
316
+ await lockfileManager.save();
317
+ }
215
318
  console.log(chalk.green(`✅ Created template for ${skillInput} in ${targetDir}`));
216
319
  }
217
320
 
@@ -252,16 +355,23 @@ async function tryInstallFromRegistryDir(skillName, registryDirPath, targetDir,
252
355
  const installInfo = fs.lstatSync(targetPath);
253
356
  const installMethod = installInfo.isSymbolicLink() ? 'symlink' : 'copy';
254
357
 
255
- const lockfileManager = new LockfileManager(projectRoot);
256
- await lockfileManager.load();
257
- lockfileManager.updateSkill(skillName, {
258
- version,
259
- source: sourceLabel,
260
- registry: registryManager.getRegistryInfo().registryId,
261
- installMethod,
262
- installedAt: new Date().toISOString()
263
- });
264
- await lockfileManager.save();
358
+ // Get git commit for the skill source
359
+ const commitHash = await getGitCommit(sourcePath);
360
+
361
+ // Update lockfile only for project-level installation (npm-like behavior)
362
+ if (!options.global) {
363
+ const lockfileManager = new LockfileManager(projectRoot);
364
+ await lockfileManager.load();
365
+ lockfileManager.updateSkill(skillName, {
366
+ version,
367
+ source: sourceLabel,
368
+ registry: registryManager.getRegistryInfo().registryId,
369
+ installMethod,
370
+ commit: commitHash,
371
+ installedAt: new Date().toISOString()
372
+ });
373
+ await lockfileManager.save();
374
+ }
265
375
 
266
376
  const methodLabel = installMethod === 'symlink' ? chalk.gray('(symlink)') : chalk.gray('(copy)');
267
377
  console.log(chalk.green(`✅ Installed ${skillName} v${version} from ${sourceLabel}`) + ' ' + methodLabel);
@@ -279,14 +389,21 @@ async function tryInstallFromRegistryDir(skillName, registryDirPath, targetDir,
279
389
  const targetPath = path.join(targetDir, skillName);
280
390
  copyRecursive(skill.path, targetPath);
281
391
 
282
- const lockfileManager = new LockfileManager(projectRoot);
283
- await lockfileManager.load();
284
- lockfileManager.updateSkill(skillName, {
285
- version: skill.version || '1.0.0',
286
- source: sourceLabel,
287
- installedAt: new Date().toISOString()
288
- });
289
- await lockfileManager.save();
392
+ // Get git commit for the skill source
393
+ const commitHash = await getGitCommit(skill.path);
394
+
395
+ // Update lockfile only for project-level installation (npm-like behavior)
396
+ if (!options.global) {
397
+ const lockfileManager = new LockfileManager(projectRoot);
398
+ await lockfileManager.load();
399
+ lockfileManager.updateSkill(skillName, {
400
+ version: skill.version || '1.0.0',
401
+ source: sourceLabel,
402
+ commit: commitHash,
403
+ installedAt: new Date().toISOString()
404
+ });
405
+ await lockfileManager.save();
406
+ }
290
407
 
291
408
  console.log(chalk.green(`✅ Installed ${skillName} v${skill.version || '1.0.0'} from ${sourceLabel}`));
292
409
  return true;
@@ -353,15 +470,18 @@ async function installFromLocalPath(localPath, targetDir, options) {
353
470
  const versionMatch = content.match(/version:\s*(.+)/);
354
471
  const version = versionMatch?.[1]?.trim() || '1.0.0';
355
472
 
356
- const lockfileManager = new LockfileManager(process.cwd());
357
- await lockfileManager.load();
358
- lockfileManager.updateSkill(skillName, {
359
- version,
360
- source: 'local',
361
- path: absPath,
362
- installedAt: new Date().toISOString()
363
- });
364
- await lockfileManager.save();
473
+ // Update lockfile only for project-level installation (npm-like behavior)
474
+ if (!options.global) {
475
+ const lockfileManager = new LockfileManager(process.cwd());
476
+ await lockfileManager.load();
477
+ lockfileManager.updateSkill(skillName, {
478
+ version,
479
+ source: 'local',
480
+ path: absPath,
481
+ installedAt: new Date().toISOString()
482
+ });
483
+ await lockfileManager.save();
484
+ }
365
485
 
366
486
  console.log(chalk.green(`✅ Installed ${skillName} v${version} from local path`));
367
487
  } else {
@@ -389,20 +509,23 @@ async function installFromLocalPath(localPath, targetDir, options) {
389
509
  });
390
510
  }
391
511
 
392
- const lockfileManager = new LockfileManager(process.cwd());
393
- await lockfileManager.load();
512
+ // Update lockfile only for project-level installation (npm-like behavior)
513
+ const lockfileManager = options.global ? null : new LockfileManager(process.cwd());
514
+ if (lockfileManager) await lockfileManager.load();
394
515
 
395
516
  for (const skill of selectedSkills) {
396
517
  await installSkillFiles(skill, targetDir);
397
- lockfileManager.updateSkill(skill.name, {
398
- version: skill.version || '1.0.0',
399
- source: 'local',
400
- path: skill.path,
401
- installedAt: new Date().toISOString()
402
- });
518
+ if (lockfileManager) {
519
+ lockfileManager.updateSkill(skill.name, {
520
+ version: skill.version || '1.0.0',
521
+ source: 'local',
522
+ path: skill.path,
523
+ installedAt: new Date().toISOString()
524
+ });
525
+ }
403
526
  }
404
527
 
405
- await lockfileManager.save();
528
+ if (lockfileManager) await lockfileManager.save();
406
529
  console.log(chalk.green(`\n✅ Installed ${selectedSkills.length} skill(s)`));
407
530
  }
408
531
  } else {
@@ -475,17 +598,20 @@ async function installFromGitUrl(gitUrl, targetDir, options, subPath = '') {
475
598
  await installSkillFiles(skill, targetDir);
476
599
  }
477
600
 
478
- const lockfileManager = new LockfileManager(process.cwd());
479
- await lockfileManager.load();
480
- for (const skill of selectedSkills) {
481
- lockfileManager.updateSkill(skill.name, {
482
- version: skill.version || '1.0.0',
483
- source: 'git',
484
- repository: gitUrl,
485
- installedAt: new Date().toISOString()
486
- });
601
+ // Update lockfile only for project-level installation (npm-like behavior)
602
+ if (!options.global) {
603
+ const lockfileManager = new LockfileManager(process.cwd());
604
+ await lockfileManager.load();
605
+ for (const skill of selectedSkills) {
606
+ lockfileManager.updateSkill(skill.name, {
607
+ version: skill.version || '1.0.0',
608
+ source: 'git',
609
+ repository: gitUrl,
610
+ installedAt: new Date().toISOString()
611
+ });
612
+ }
613
+ await lockfileManager.save();
487
614
  }
488
- await lockfileManager.save();
489
615
 
490
616
  console.log(chalk.green(`\n✅ Successfully installed ${selectedSkills.length} skill(s)`));
491
617
  }
@@ -566,18 +692,20 @@ async function installFromGitHub(owner, repo, skillPath, targetDir, options) {
566
692
  await installSkillFiles(skill, targetDir);
567
693
  }
568
694
 
569
- // Update lockfile
570
- const lockfileManager = new LockfileManager(process.cwd());
571
- await lockfileManager.load();
572
- for (const skill of selectedSkills) {
573
- lockfileManager.updateSkill(skill.name, {
574
- version: skill.version || '1.0.0',
575
- source: 'github',
576
- repository: `${owner}/${repo}`,
577
- installedAt: new Date().toISOString()
578
- });
695
+ // Update lockfile only for project-level installation (npm-like behavior)
696
+ if (!options.global) {
697
+ const lockfileManager = new LockfileManager(process.cwd());
698
+ await lockfileManager.load();
699
+ for (const skill of selectedSkills) {
700
+ lockfileManager.updateSkill(skill.name, {
701
+ version: skill.version || '1.0.0',
702
+ source: 'github',
703
+ repository: `${owner}/${repo}`,
704
+ installedAt: new Date().toISOString()
705
+ });
706
+ }
707
+ await lockfileManager.save();
579
708
  }
580
- await lockfileManager.save();
581
709
 
582
710
  console.log(chalk.green(`\n✅ Successfully installed ${selectedSkills.length} skill(s)`));
583
711
  }
@@ -623,16 +751,19 @@ async function installFromRegistry(skillName, targetDir, projectRoot, options) {
623
751
  const sourcePath = path.join(registryPath, skill.location || skillName);
624
752
  if (fs.existsSync(sourcePath)) {
625
753
  copyRecursive(sourcePath, skillTargetPath);
626
-
627
- await lockfileManager.load();
628
- lockfileManager.updateSkill(skillName, {
629
- version,
630
- source: 'registry',
631
- registry: registryManager.getRegistryInfo().registryId,
632
- installedAt: new Date().toISOString()
633
- });
634
- await lockfileManager.save();
635
-
754
+
755
+ // Update lockfile only for project-level installation (npm-like behavior)
756
+ if (!options.global) {
757
+ await lockfileManager.load();
758
+ lockfileManager.updateSkill(skillName, {
759
+ version,
760
+ source: 'registry',
761
+ registry: registryManager.getRegistryInfo().registryId,
762
+ installedAt: new Date().toISOString()
763
+ });
764
+ await lockfileManager.save();
765
+ }
766
+
636
767
  console.log(chalk.green(`✅ Installed ${skillName} v${version} from registry`));
637
768
  return;
638
769
  }
@@ -643,14 +774,17 @@ async function installFromRegistry(skillName, targetDir, projectRoot, options) {
643
774
  console.log(chalk.yellow(`⚠️ Skill not found in registry, creating template...`));
644
775
  const templateContent = generateSkillTemplate(skillName);
645
776
  localManager.installSkill(skillName, templateContent);
646
-
647
- await lockfileManager.load();
648
- lockfileManager.updateSkill(skillName, {
649
- version: '1.0.0',
650
- source: 'template',
651
- installedAt: new Date().toISOString()
652
- });
653
- await lockfileManager.save();
777
+
778
+ // Update lockfile only for project-level installation (npm-like behavior)
779
+ if (!options.global) {
780
+ await lockfileManager.load();
781
+ lockfileManager.updateSkill(skillName, {
782
+ version: '1.0.0',
783
+ source: 'template',
784
+ installedAt: new Date().toISOString()
785
+ });
786
+ await lockfileManager.save();
787
+ }
654
788
 
655
789
  console.log(chalk.green(`✅ Created template for ${skillName}`));
656
790
  }
@@ -679,6 +813,15 @@ async function installFromLockfile(targetDir, projectRoot) {
679
813
  } else if (skillInfo.source === 'github' && skillInfo.repository) {
680
814
  const [owner, repo] = skillInfo.repository.split('/');
681
815
  await installFromGitHub(owner, repo, skillName, targetDir, {});
816
+ } else if (skillInfo.source?.startsWith('team:')) {
817
+ // Re-install from team registry with auto-prompt
818
+ const registryName = skillInfo.source.replace('team:', '');
819
+ const registryPath = await getRegistryPathWithPrompt(registryName, {});
820
+ const hit = await tryInstallFromRegistryDir(skillName, registryPath, targetDir, projectRoot, {}, skillInfo.source);
821
+ if (!hit) {
822
+ console.log(chalk.yellow(` ⚠️ Skill "${skillName}" not found in registry "${registryName}", trying resolve chain...`));
823
+ await resolveAndInstall(skillName, targetDir, projectRoot, {});
824
+ }
682
825
  } else {
683
826
  // Try full resolve chain
684
827
  await resolveAndInstall(skillName, targetDir, projectRoot, {});
@@ -68,12 +68,28 @@ export function syncCommand(program) {
68
68
  await lockfileManager.load();
69
69
  for (const skill of allSkills) {
70
70
  const existing = lockfileManager.getSkill(skill.name);
71
- lockfileManager.updateSkill(skill.name, {
71
+
72
+ // Merge existing data with updates, preserving all original fields
73
+ const updatedEntry = {
74
+ ...existing, // Keep all existing fields (registry, commit, etc.)
72
75
  version: skill.version || existing?.version || '1.0.0',
73
- source: existing?.source || 'local',
74
- installedAt: existing?.installedAt || new Date().toISOString(),
75
- path: skill.path
76
- });
76
+ path: skill.path, // Update path (may change if skill moved)
77
+ };
78
+
79
+ // Set source if not exists
80
+ if (!updatedEntry.source) {
81
+ updatedEntry.source = 'local';
82
+ }
83
+
84
+ // Set installedAt if not exists
85
+ if (!updatedEntry.installedAt) {
86
+ updatedEntry.installedAt = new Date().toISOString();
87
+ }
88
+
89
+ // Add syncedAt timestamp
90
+ updatedEntry.syncedAt = new Date().toISOString();
91
+
92
+ lockfileManager.updateSkill(skill.name, updatedEntry);
77
93
  }
78
94
  await lockfileManager.save();
79
95
  console.log(chalk.green(` ✓ Updated joySkills.lock`));
@@ -19,6 +19,8 @@ export function updateCommand(program) {
19
19
  .description('Update installed skills to latest versions')
20
20
  .option('-y, --yes', 'Skip confirmation prompts')
21
21
  .option('-a, --all', 'Update all skills')
22
+ .option('-g, --global', 'Update global (user-level) skills only')
23
+ .option('-p, --project', 'Update project-level skills only')
22
24
  .action(async (skillNames, options) => {
23
25
  try {
24
26
  const projectRoot = process.cwd();
@@ -31,10 +33,10 @@ export function updateCommand(program) {
31
33
 
32
34
  if (options.all || skillNames.length === 0) {
33
35
  // 更新所有有更新的 skills(Git + team://)
34
- skillsToUpdate = await getAllUpdatableSkills(projectRoot);
36
+ skillsToUpdate = await getAllUpdatableSkills(projectRoot, options.global, options.project);
35
37
  } else {
36
38
  // 更新指定的 skills
37
- skillsToUpdate = await getSpecifiedSkills(projectRoot, skillNames);
39
+ skillsToUpdate = await getSpecifiedSkills(projectRoot, skillNames, options.global, options.project);
38
40
  }
39
41
 
40
42
  if (skillsToUpdate.length === 0) {
@@ -104,21 +106,39 @@ export function updateCommand(program) {
104
106
 
105
107
  /**
106
108
  * 获取所有可更新的 Skills(Git + team://)
109
+ * @param {boolean} globalOnly - 只检查全局 skills
110
+ * @param {boolean} projectOnly - 只检查项目级 skills
107
111
  */
108
- async function getAllUpdatableSkills(projectRoot) {
112
+ async function getAllUpdatableSkills(projectRoot, globalOnly = false, projectOnly = false) {
109
113
  const updatable = [];
110
114
 
111
115
  // 1. 检查 Git 仓库类型的 Skills
112
- const gitSkills = await getUpdatableSkills(projectRoot);
116
+ const gitSkills = await getUpdatableSkills(projectRoot, globalOnly, projectOnly);
113
117
  updatable.push(...gitSkills);
114
118
 
115
119
  // 2. 检查 team:// 类型的 Skills
116
- const teamSkills = await getUpdatableTeamSkills(projectRoot);
120
+ const teamSkills = await getUpdatableTeamSkills(projectRoot, globalOnly, projectOnly);
117
121
  updatable.push(...teamSkills);
118
122
 
119
123
  return updatable;
120
124
  }
121
125
 
126
+ /**
127
+ * 获取目录的 git commit hash
128
+ */
129
+ async function getDirCommit(dirPath) {
130
+ try {
131
+ const git = simpleGit(dirPath);
132
+ const isRepo = await git.checkIsRepo();
133
+ if (!isRepo) return null;
134
+
135
+ const log = await git.log({ maxCount: 1 });
136
+ return log.latest?.hash || null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
122
142
  /**
123
143
  * 更新 team:// 类型的 Skill
124
144
  */
@@ -126,7 +146,10 @@ async function updateTeamSkill(skill, projectRoot) {
126
146
  const lockfile = new LockfileManager(projectRoot);
127
147
  await lockfile.load();
128
148
 
129
- const updateInfo = await checkTeamSkillUpdate(skill, skill.source);
149
+ const lockData = lockfile.getSkill(skill.name);
150
+ const localCommit = lockData?.commit;
151
+
152
+ const updateInfo = await checkTeamSkillUpdate(skill, skill.source, localCommit);
130
153
 
131
154
  if (!updateInfo.hasUpdate) {
132
155
  throw new Error('No update available');
@@ -140,10 +163,14 @@ async function updateTeamSkill(skill, projectRoot) {
140
163
  // 复制新版本
141
164
  copyRecursive(updateInfo.registrySkillPath, skill.path);
142
165
 
166
+ // 获取新版本的 commit
167
+ const newCommit = await getDirCommit(updateInfo.registrySkillPath);
168
+
143
169
  // 更新 lockfile
144
170
  lockfile.updateSkill(skill.name, {
145
171
  version: updateInfo.latestVersion,
146
172
  source: skill.source,
173
+ commit: newCommit,
147
174
  updatedAt: new Date().toISOString(),
148
175
  });
149
176
  await lockfile.save();
@@ -173,15 +200,27 @@ function copyRecursive(src, dest) {
173
200
 
174
201
  /**
175
202
  * 获取指定的 Skills
203
+ * @param {boolean} globalOnly - 只检查全局 skills
204
+ * @param {boolean} projectOnly - 只检查项目级 skills
176
205
  */
177
- async function getSpecifiedSkills(projectRoot, skillNames) {
206
+ async function getSpecifiedSkills(projectRoot, skillNames, globalOnly = false, projectOnly = false) {
178
207
  const skills = [];
179
208
  const loader = new SkillLoader(projectRoot);
180
209
  const lockfile = new LockfileManager(projectRoot);
181
210
  await lockfile.load();
182
211
 
212
+ // 根据选项选择加载方式
213
+ let allSkills = [];
214
+ if (globalOnly) {
215
+ allSkills = loader.loadGlobalSkills();
216
+ } else if (projectOnly) {
217
+ allSkills = loader.loadProjectSkills();
218
+ } else {
219
+ allSkills = loader.loadAllSkills();
220
+ }
221
+
183
222
  for (const name of skillNames) {
184
- const skill = loader.getSkill(name);
223
+ const skill = allSkills.find(s => s.name === name);
185
224
  if (!skill) {
186
225
  console.log(chalk.red(`❌ Skill not found: ${name}`));
187
226
  continue;
@@ -199,7 +238,8 @@ async function getSpecifiedSkills(projectRoot, skillNames) {
199
238
 
200
239
  if (teamSource) {
201
240
  // team:// 类型
202
- const updateInfo = await checkTeamSkillUpdate(skill, teamSource);
241
+ const localCommit = lockData?.commit;
242
+ const updateInfo = await checkTeamSkillUpdate(skill, teamSource, localCommit);
203
243
  if (updateInfo.hasUpdate) {
204
244
  skills.push({
205
245
  name: skill.name,
@@ -238,7 +278,7 @@ async function getSpecifiedSkills(projectRoot, skillNames) {
238
278
  /**
239
279
  * 检查 team:// 类型的 Skill 是否有更新
240
280
  */
241
- async function checkTeamSkillUpdate(skill, source) {
281
+ async function checkTeamSkillUpdate(skill, source, localCommit = null) {
242
282
  const registryName = source.replace('team:', '');
243
283
  const JOYSKILL_CONFIG_DIR = process.env.JOYSKILL_CONFIG_DIR || path.join(os.homedir(), '.joyskill');
244
284
  const configPath = path.join(JOYSKILL_CONFIG_DIR, 'config.json');
@@ -254,7 +294,7 @@ async function checkTeamSkillUpdate(skill, source) {
254
294
  return { hasUpdate: false, error: `Registry ${registryName} not found` };
255
295
  }
256
296
 
257
- // 从 registry 查找最新版本
297
+ // 从 registry 查找 skill
258
298
  const { findSkills } = await import('./install.js');
259
299
  const registrySkills = await findSkills(reg.path);
260
300
  const registrySkill = registrySkills.find(s => s.name === skill.name);
@@ -263,11 +303,22 @@ async function checkTeamSkillUpdate(skill, source) {
263
303
  return { hasUpdate: false, error: 'Skill not found in registry' };
264
304
  }
265
305
 
266
- const hasUpdate = registrySkill.version && registrySkill.version !== skill.version;
306
+ // 获取 registry skill commit
307
+ const remoteCommit = await getDirCommit(registrySkill.path);
308
+
309
+ // 使用 commit 对比,如果没有 commit 则 fallback 到 version 对比
310
+ let hasUpdate = false;
311
+ if (localCommit && remoteCommit) {
312
+ hasUpdate = localCommit !== remoteCommit;
313
+ } else {
314
+ hasUpdate = registrySkill.version && registrySkill.version !== skill.version;
315
+ }
267
316
 
268
317
  return {
269
318
  hasUpdate,
270
319
  latestVersion: registrySkill.version,
320
+ localCommit,
321
+ remoteCommit,
271
322
  registryPath: reg.path,
272
323
  registrySkillPath: registrySkill.path,
273
324
  };
@@ -336,19 +387,29 @@ async function checkGitSkillUpdate(skill) {
336
387
 
337
388
  /**
338
389
  * 获取所有可更新的 team:// Skills
390
+ * @param {boolean} globalOnly - 只检查全局 skills
391
+ * @param {boolean} projectOnly - 只检查项目级 skills
339
392
  */
340
- async function getUpdatableTeamSkills(projectRoot) {
393
+ async function getUpdatableTeamSkills(projectRoot, globalOnly = false, projectOnly = false) {
341
394
  const updatable = [];
342
395
  const loader = new SkillLoader(projectRoot);
343
396
  const lockfile = new LockfileManager(projectRoot);
344
397
  await lockfile.load();
345
398
 
346
- const allSkills = loader.loadAllSkills();
399
+ let allSkills = [];
400
+ if (globalOnly) {
401
+ allSkills = loader.loadGlobalSkills();
402
+ } else if (projectOnly) {
403
+ allSkills = loader.loadProjectSkills();
404
+ } else {
405
+ allSkills = loader.loadAllSkills();
406
+ }
347
407
 
348
408
  for (const skill of allSkills) {
349
409
  const lockData = lockfile.getSkill(skill.name);
350
410
  if (lockData?.source?.startsWith('team:')) {
351
- const updateInfo = await checkTeamSkillUpdate(skill, lockData.source);
411
+ const localCommit = lockData?.commit;
412
+ const updateInfo = await checkTeamSkillUpdate(skill, lockData.source, localCommit);
352
413
  if (updateInfo.hasUpdate) {
353
414
  updatable.push({
354
415
  name: skill.name,
@@ -129,16 +129,32 @@ export class SkillLoader {
129
129
  * 仅加载项目级 Skills
130
130
  */
131
131
  loadProjectSkills(agentId = null) {
132
- const allSkills = this.loadAllSkills(agentId);
133
- return allSkills.filter(s => s.scope === 'project');
132
+ const paths = getAllSkillPaths(this.projectRoot, agentId);
133
+ const skills = [];
134
+
135
+ for (const { path: dirPath, scope, agent } of paths) {
136
+ if (scope !== 'project') continue;
137
+ const dirSkills = this.scanDirectory(dirPath, scope, agent);
138
+ skills.push(...dirSkills);
139
+ }
140
+
141
+ return skills;
134
142
  }
135
143
 
136
144
  /**
137
145
  * 仅加载用户级 Skills
138
146
  */
139
147
  loadGlobalSkills(agentId = null) {
140
- const allSkills = this.loadAllSkills(agentId);
141
- return allSkills.filter(s => s.scope === 'global');
148
+ const paths = getAllSkillPaths(this.projectRoot, agentId);
149
+ const skills = [];
150
+
151
+ for (const { path: dirPath, scope, agent } of paths) {
152
+ if (scope !== 'global') continue;
153
+ const dirSkills = this.scanDirectory(dirPath, scope, agent);
154
+ skills.push(...dirSkills);
155
+ }
156
+
157
+ return skills;
142
158
  }
143
159
 
144
160
  /**
@@ -80,8 +80,9 @@ export async function checkRemoteUpdate(skillPath, currentCommit) {
80
80
  * 检查单个 Skill
81
81
  */
82
82
  export async function checkSkill(skill, projectRoot) {
83
- // 首先尝试从 lockfile 获取 source
83
+ // 首先尝试从 lockfile 获取 source 和 commit
84
84
  let teamSource = null;
85
+ let localCommit = null;
85
86
 
86
87
  if (projectRoot) {
87
88
  const lockfile = new LockfileManager(projectRoot);
@@ -89,6 +90,7 @@ export async function checkSkill(skill, projectRoot) {
89
90
  const lockData = lockfile.getSkill(skill.name);
90
91
  if (lockData?.source?.startsWith('team:')) {
91
92
  teamSource = lockData.source;
93
+ localCommit = lockData.commit;
92
94
  }
93
95
  }
94
96
 
@@ -97,9 +99,9 @@ export async function checkSkill(skill, projectRoot) {
97
99
  teamSource = await findSkillInRegistries(skill.name);
98
100
  }
99
101
 
100
- // 如果是 team:// skill,对比 registry 版本
102
+ // 如果是 team:// skill,对比 registry commit
101
103
  if (teamSource) {
102
- return await checkTeamSkill(skill, teamSource);
104
+ return await checkTeamSkill(skill, teamSource, localCommit);
103
105
  }
104
106
 
105
107
  // Git skill: 对比 commit
@@ -157,10 +159,26 @@ async function findSkillInRegistries(skillName) {
157
159
  return null;
158
160
  }
159
161
 
162
+ /**
163
+ * 获取目录的 git commit hash
164
+ */
165
+ async function getDirCommit(dirPath) {
166
+ try {
167
+ const git = simpleGit(dirPath);
168
+ const isRepo = await git.checkIsRepo();
169
+ if (!isRepo) return null;
170
+
171
+ const log = await git.log({ maxCount: 1 });
172
+ return log.latest?.hash || null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
160
178
  /**
161
179
  * 检查 team:// skill 更新
162
180
  */
163
- async function checkTeamSkill(skill, source) {
181
+ async function checkTeamSkill(skill, source, localCommit) {
164
182
  const registryName = source.replace('team:', '');
165
183
  const JOYSKILL_CONFIG_DIR = process.env.JOYSKILL_CONFIG_DIR || path.join(os.homedir(), '.joyskill');
166
184
  const configPath = path.join(JOYSKILL_CONFIG_DIR, 'config.json');
@@ -188,7 +206,7 @@ async function checkTeamSkill(skill, source) {
188
206
  };
189
207
  }
190
208
 
191
- // 从 registry 查找最新版本
209
+ // 从 registry 查找 skill
192
210
  const { findSkills } = await import('./commands/install.js');
193
211
  const registrySkills = await findSkills(reg.path);
194
212
  const registrySkill = registrySkills.find(s => s.name === skill.name);
@@ -203,24 +221,44 @@ async function checkTeamSkill(skill, source) {
203
221
  };
204
222
  }
205
223
 
206
- const hasUpdate = registrySkill.version && registrySkill.version !== skill.version;
224
+ // 获取 registry skill 的最新 commit
225
+ const remoteCommit = await getDirCommit(registrySkill.path);
226
+
227
+ // 如果没有 localCommit(旧版本安装的),使用 version 对比作为 fallback
228
+ let hasUpdate = false;
229
+ if (localCommit && remoteCommit) {
230
+ hasUpdate = localCommit !== remoteCommit;
231
+ } else {
232
+ hasUpdate = registrySkill.version && registrySkill.version !== skill.version;
233
+ }
207
234
 
208
235
  return {
209
236
  name: skill.name,
210
237
  hasUpdate,
211
238
  currentVersion: skill.version,
212
- latestVersion: registrySkill.version,
213
- commitsBehind: hasUpdate ? 1 : 0, // 版本不同即视为有更新
239
+ localCommit: typeof localCommit === 'string' ? localCommit.slice(0, 7) : 'unknown',
240
+ remoteCommit: typeof remoteCommit === 'string' ? remoteCommit.slice(0, 7) : 'unknown',
241
+ commitsBehind: hasUpdate ? 1 : 0,
214
242
  source: `team:${registryName}`,
215
243
  };
216
244
  }
217
245
 
218
246
  /**
219
247
  * 检查所有 Skills
248
+ * @param {boolean} globalOnly - 只检查全局 skills
249
+ * @param {boolean} projectOnly - 只检查项目级 skills
220
250
  */
221
- export async function checkAllSkills(projectRoot) {
251
+ export async function checkAllSkills(projectRoot, globalOnly = false, projectOnly = false) {
222
252
  const loader = new SkillLoader(projectRoot);
223
- const skills = loader.loadAllSkills();
253
+ let skills = [];
254
+
255
+ if (globalOnly) {
256
+ skills = loader.loadGlobalSkills();
257
+ } else if (projectOnly) {
258
+ skills = loader.loadProjectSkills();
259
+ } else {
260
+ skills = loader.loadAllSkills();
261
+ }
224
262
 
225
263
  const results = [];
226
264
 
@@ -234,8 +272,10 @@ export async function checkAllSkills(projectRoot) {
234
272
 
235
273
  /**
236
274
  * 获取可更新的 Skills
275
+ * @param {boolean} globalOnly - 只检查全局 skills
276
+ * @param {boolean} projectOnly - 只检查项目级 skills
237
277
  */
238
- export async function getUpdatableSkills(projectRoot) {
239
- const results = await checkAllSkills(projectRoot);
278
+ export async function getUpdatableSkills(projectRoot, globalOnly = false, projectOnly = false) {
279
+ const results = await checkAllSkills(projectRoot, globalOnly, projectOnly);
240
280
  return results.filter(r => r.hasUpdate);
241
281
  }