joyskills-cli 0.2.10 → 0.3.1

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.
@@ -2,6 +2,8 @@ import { Command } from 'commander';
2
2
  import { LocalManager } from '../local.js';
3
3
  import { LockfileManager } from '../lockfile.js';
4
4
  import { RegistryManager } from '../registry.js';
5
+ import { getAgent, getAgentProjectPath, getAgentGlobalPath, detectAgents } from '../agents.js';
6
+ import { installSkill, InstallMethod } from '../installer.js';
5
7
  import simpleGit from 'simple-git';
6
8
  import * as fs from 'fs';
7
9
  import * as path from 'path';
@@ -15,25 +17,22 @@ const CACHE_DIR = process.env.JOYSKILL_CACHE_DIR || path.join(os.homedir(), '.jo
15
17
  export function installCommand(program) {
16
18
  program
17
19
  .command('install [skill]')
20
+ .alias('add')
18
21
  .description('Install a skill from registry or GitHub')
19
22
  .option('-v, --version <version>', 'Specify version to install')
20
23
  .option('-r, --registry <name>', 'Install from specific registry')
21
- .option('-g, --global', 'Install globally to ~/.claude/skills')
22
- .option('--universal', 'Install to .agent/skills/ (multi-agent compatible)')
24
+ .option('-g, --global', 'Install to user-level directory (~/.joycode/skills/)')
25
+ .option('-a, --agent <agent>', 'Target specific agent (joycode, claude-code, cursor, etc.)')
26
+ .option('-d, --dir <path>', 'Install to custom directory')
27
+ .option('--copy', 'Copy files instead of symlinking')
23
28
  .option('--force', 'Force installation even if version is not recommended')
24
29
  .option('-y, --yes', 'Skip all prompts (CI mode)')
25
30
  .action(async (skillInput, options) => {
26
31
  try {
27
32
  const projectRoot = process.cwd();
28
- // Directory resolution (matches openskill priority)
29
- // --global → ~/.claude/skills
30
- // --universal .agent/skills/
31
- // default → .claude/skills/
32
- const targetDir = options.global
33
- ? path.join(os.homedir(), '.claude', 'skills')
34
- : options.universal
35
- ? path.join(projectRoot, '.agent', 'skills')
36
- : path.join(projectRoot, '.claude', 'skills');
33
+
34
+ // 解析目标目录
35
+ const targetDir = resolveTargetDir(projectRoot, options);
37
36
 
38
37
  if (!fs.existsSync(targetDir)) {
39
38
  fs.mkdirSync(targetDir, { recursive: true });
@@ -54,6 +53,35 @@ export function installCommand(program) {
54
53
  });
55
54
  }
56
55
 
56
+ /**
57
+ * 解析目标目录
58
+ * 优先级: --dir > --agent > --global > 默认(joycode)
59
+ */
60
+ function resolveTargetDir(projectRoot, options) {
61
+ // 1. 自定义目录最高优先级
62
+ if (options.dir) {
63
+ return path.resolve(options.dir);
64
+ }
65
+
66
+ // 2. 指定 Agent
67
+ if (options.agent) {
68
+ const agent = getAgent(options.agent);
69
+ if (options.global) {
70
+ return getAgentGlobalPath(agent.id);
71
+ }
72
+ return getAgentProjectPath(agent.id, projectRoot);
73
+ }
74
+
75
+ // 3. 仅 --global,使用 joycode 用户级目录
76
+ if (options.global) {
77
+ return getAgentGlobalPath('joycode');
78
+ }
79
+
80
+ // 4. 默认: 始终使用 joycode 项目级目录
81
+ // 这是 JoyCode 的品牌策略,不随检测到的其他 Agent 改变
82
+ return getAgentProjectPath('joycode', projectRoot);
83
+ }
84
+
57
85
  /**
58
86
  * Priority resolution chain:
59
87
  * 1. Local absolute/relative path
@@ -65,10 +93,24 @@ async function resolveAndInstall(skillInput, targetDir, projectRoot, options) {
65
93
  const JOYSKILL_CONFIG_DIR = process.env.JOYSKILL_CONFIG_DIR || path.join(os.homedir(), '.joyskill');
66
94
 
67
95
  // ── Step 0: Direct Git URL (git@xxx or https://github.com/...) ─────────
96
+ // Support: git@host:org/repo.git or git@host:org/repo.git/subpath
68
97
  if (skillInput.startsWith('git@') || skillInput.startsWith('https://') || skillInput.startsWith('http://')) {
69
- // Normalize: ensure URL ends with .git for cloning
70
- const gitUrl = skillInput.endsWith('.git') ? skillInput : skillInput + '.git';
71
- await installFromGitUrl(gitUrl, targetDir, options);
98
+ // Parse URL and optional subpath
99
+ // Format: git@host:org/repo.git/subpath/to/skill
100
+ let gitUrl = skillInput;
101
+ let subPath = '';
102
+
103
+ // Check for subpath after .git
104
+ const gitExtMatch = skillInput.match(/^(.+?\.git)(\/.*)$/);
105
+ if (gitExtMatch) {
106
+ gitUrl = gitExtMatch[1];
107
+ subPath = gitExtMatch[2].replace(/^\//, ''); // Remove leading slash
108
+ } else if (!skillInput.endsWith('.git')) {
109
+ // No .git extension, treat entire input as URL
110
+ gitUrl = skillInput;
111
+ }
112
+
113
+ await installFromGitUrl(gitUrl, targetDir, options, subPath);
72
114
  return;
73
115
  }
74
116
 
@@ -151,13 +193,26 @@ async function resolveAndInstall(skillInput, targetDir, projectRoot, options) {
151
193
 
152
194
  // ── Fallback: create template ────────────────────────────────────
153
195
  console.log(chalk.yellow(`⚠️ "${skillInput}" not found in any registry, creating template...`));
154
- const localManager = new LocalManager(projectRoot);
196
+
197
+ // 使用 targetDir 安装模板
198
+ const skillPath = path.join(targetDir, skillInput);
199
+ if (!fs.existsSync(skillPath)) {
200
+ fs.mkdirSync(skillPath, { recursive: true });
201
+ }
202
+
203
+ const skillMdPath = path.join(skillPath, 'SKILL.md');
204
+ fs.writeFileSync(skillMdPath, generateSkillTemplate(skillInput));
205
+
155
206
  const lockfileManager = new LockfileManager(projectRoot);
156
- localManager.installSkill(skillInput, generateSkillTemplate(skillInput));
157
207
  await lockfileManager.load();
158
- lockfileManager.updateSkill(skillInput, { version: '1.0.0', source: 'template', installedAt: new Date().toISOString() });
208
+ lockfileManager.updateSkill(skillInput, {
209
+ version: '1.0.0',
210
+ source: 'template',
211
+ path: skillPath,
212
+ installedAt: new Date().toISOString()
213
+ });
159
214
  await lockfileManager.save();
160
- console.log(chalk.green(`✅ Created template for ${skillInput}`));
215
+ console.log(chalk.green(`✅ Created template for ${skillInput} in ${targetDir}`));
161
216
  }
162
217
 
163
218
  /**
@@ -188,7 +243,14 @@ async function tryInstallFromRegistryDir(skillName, registryDirPath, targetDir,
188
243
  if (!fs.existsSync(sourcePath)) return false;
189
244
 
190
245
  const targetPath = path.join(targetDir, skillName);
191
- copyRecursive(sourcePath, targetPath);
246
+
247
+ // 使用新的 installer 模块,支持 symlink/copy
248
+ const method = options.copy ? InstallMethod.COPY : InstallMethod.SYMLINK;
249
+ installSkill(sourcePath, targetPath, method);
250
+
251
+ // 获取安装方式信息
252
+ const installInfo = fs.lstatSync(targetPath);
253
+ const installMethod = installInfo.isSymbolicLink() ? 'symlink' : 'copy';
192
254
 
193
255
  const lockfileManager = new LockfileManager(projectRoot);
194
256
  await lockfileManager.load();
@@ -196,11 +258,13 @@ async function tryInstallFromRegistryDir(skillName, registryDirPath, targetDir,
196
258
  version,
197
259
  source: sourceLabel,
198
260
  registry: registryManager.getRegistryInfo().registryId,
261
+ installMethod,
199
262
  installedAt: new Date().toISOString()
200
263
  });
201
264
  await lockfileManager.save();
202
265
 
203
- console.log(chalk.green(`✅ Installed ${skillName} v${version} from ${sourceLabel}`));
266
+ const methodLabel = installMethod === 'symlink' ? chalk.gray('(symlink)') : chalk.gray('(copy)');
267
+ console.log(chalk.green(`✅ Installed ${skillName} v${version} from ${sourceLabel}`) + ' ' + methodLabel);
204
268
  return true;
205
269
  } catch (e) {
206
270
  return false;
@@ -346,7 +410,7 @@ async function installFromLocalPath(localPath, targetDir, options) {
346
410
  }
347
411
  }
348
412
 
349
- async function installFromGitUrl(gitUrl, targetDir, options) {
413
+ async function installFromGitUrl(gitUrl, targetDir, options, subPath = '') {
350
414
  // Derive cache key from URL
351
415
  const cacheKey = gitUrl
352
416
  .replace(/^git@/, '')
@@ -355,8 +419,12 @@ async function installFromGitUrl(gitUrl, targetDir, options) {
355
419
  .replace(/[:/]/g, '-');
356
420
 
357
421
  const cachePath = path.join(CACHE_DIR, cacheKey);
422
+ const sourcePath = subPath ? path.join(cachePath, subPath) : cachePath;
358
423
 
359
424
  console.log(chalk.blue(`📦 Installing from: ${gitUrl}`));
425
+ if (subPath) {
426
+ console.log(chalk.gray(` Subpath: ${subPath}`));
427
+ }
360
428
 
361
429
  // Check if cache is valid (non-empty)
362
430
  const cacheValid = fs.existsSync(cachePath) && fs.readdirSync(cachePath).length > 0;
@@ -387,7 +455,7 @@ async function installFromGitUrl(gitUrl, targetDir, options) {
387
455
  }
388
456
  }
389
457
 
390
- const skills = await findSkills(cachePath);
458
+ const skills = await findSkills(sourcePath);
391
459
  if (skills.length === 0) throw new Error('No skills found in repository');
392
460
 
393
461
  console.log(chalk.green(` Found ${skills.length} skill(s)`));
@@ -620,7 +688,7 @@ async function installFromLockfile(targetDir, projectRoot) {
620
688
  console.log(chalk.green(`\n✅ All skills installed`));
621
689
  }
622
690
 
623
- async function findSkills(basePath) {
691
+ export async function findSkills(basePath) {
624
692
  const skills = [];
625
693
 
626
694
  async function scan(dir, relativePath = '') {
@@ -1,149 +1,91 @@
1
- import { RegistryManager } from '../registry.js';
2
- import { LockfileManager } from '../lockfile.js';
3
- import * as path from 'path';
4
- import * as fs from 'fs';
5
- import * as os from 'os';
1
+ import { SkillLoader } from '../skill-loader.js';
6
2
  import chalk from 'chalk';
7
3
 
8
- /** Scan all standard skill directories and return unique skill names */
9
- function scanAllSkillDirs(projectRoot) {
10
- const dirs = [
11
- path.join(projectRoot, '.agent', 'skills'),
12
- path.join(projectRoot, '.claude', 'skills'),
13
- path.join(os.homedir(), '.agent', 'skills'),
14
- path.join(os.homedir(), '.claude', 'skills'),
15
- path.join(projectRoot, 'skills'), // legacy
16
- ];
17
- const seen = new Set();
18
- const skills = [];
19
- for (const dir of dirs) {
20
- if (!fs.existsSync(dir)) continue;
21
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
22
- if (entry.isDirectory() && !seen.has(entry.name)) {
23
- seen.add(entry.name);
24
- const skillMd = path.join(dir, entry.name, 'SKILL.md');
25
- let description = '', version = '';
26
- if (fs.existsSync(skillMd)) {
27
- const content = fs.readFileSync(skillMd, 'utf-8');
28
- description = (content.match(/description:\s*(.+)/) || [])[1]?.trim() || '';
29
- version = (content.match(/version:\s*(.+)/) || [])[1]?.trim() || '';
30
- }
31
- skills.push({ name: entry.name, dir, description, version });
32
- }
33
- }
34
- }
35
- return skills;
36
- }
37
-
38
4
  export function listCommand(program) {
39
5
  program
40
6
  .command('list')
41
- .description('List available skills')
42
- .option('-a, --all-versions', 'Show all versions of each skill')
43
- .option('-c, --category <category>', 'Filter by category')
7
+ .alias('ls')
8
+ .description('List installed skills')
9
+ .option('-a, --agent <agent>', 'Filter by agent (joycode, claude-code, cursor, etc.)')
10
+ .option('-g, --global', 'Show only global (user-level) skills')
11
+ .option('-p, --project', 'Show only project-level skills')
44
12
  .option('-s, --search <query>', 'Search skills by name or description')
45
- .option('-i, --installed', 'Show only installed skills')
46
- .option('-l, --local', 'Show only local skills')
47
- .option('-r, --registry <name>', 'Show skills from specific registry')
13
+ .option('--stats', 'Show statistics')
48
14
  .action(async (options) => {
49
15
  const projectRoot = process.cwd();
50
- const lockfileManager = new LockfileManager(projectRoot);
51
-
52
- console.log(chalk.blue('Available Skills:'));
53
- console.log(chalk.gray('================='));
16
+ const loader = new SkillLoader(projectRoot);
54
17
 
55
18
  try {
56
- await lockfileManager.load();
57
- const installedSkills = lockfileManager.lockData.skills;
58
-
59
- // Scan all skill directories
60
- const localSkills = scanAllSkillDirs(projectRoot);
61
-
62
- if (!options.registry) {
63
- if (localSkills.length > 0) {
64
- console.log(chalk.blue('\nInstalled Skills:'));
65
- let filtered = localSkills;
66
- if (options.search) {
67
- const q = options.search.toLowerCase();
68
- filtered = filtered.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
69
- }
70
- if (filtered.length > 0) {
71
- filtered.forEach(skill => {
72
- const lock = installedSkills[skill.name];
73
- const ver = lock ? chalk.gray(`v${lock.version}`) : (skill.version ? chalk.gray(`v${skill.version}`) : '');
74
- const src = lock ? chalk.gray(`[${lock.source}]`) : '';
75
- console.log(` ✅ ${chalk.bold(skill.name)} ${ver} ${src}`);
76
- if (skill.description) console.log(` ${chalk.gray(skill.description)}`);
77
- });
78
- } else {
79
- console.log(' No matching skills found.');
80
- }
81
- } else {
82
- console.log(chalk.yellow('\n No skills installed.'));
83
- console.log(chalk.gray(' Run: joySkills install <skill>'));
84
- }
19
+ // 加载 skills
20
+ let skills = [];
21
+ if (options.global) {
22
+ skills = loader.loadGlobalSkills(options.agent);
23
+ } else if (options.project) {
24
+ skills = loader.loadProjectSkills(options.agent);
25
+ } else {
26
+ skills = loader.loadAllSkills(options.agent);
85
27
  }
86
28
 
87
- // Check for registry
88
- const registryPath = path.join(projectRoot, '.joyskill/registry');
89
- if (fs.existsSync(registryPath) && !options.local) {
90
- try {
91
- const registryManager = new RegistryManager(registryPath);
92
- await registryManager.load();
29
+ // 搜索过滤
30
+ if (options.search) {
31
+ const q = options.search.toLowerCase();
32
+ skills = skills.filter(s =>
33
+ s.name.toLowerCase().includes(q) ||
34
+ s.description.toLowerCase().includes(q)
35
+ );
36
+ }
93
37
 
94
- console.log('\nRegistry Skills:');
38
+ // 统计模式
39
+ if (options.stats) {
40
+ const stats = loader.getStats(options.agent);
41
+ console.log(chalk.blue('\nSkill Statistics:'));
42
+ console.log(chalk.gray('=================='));
43
+ console.log(` Total: ${stats.total}`);
44
+ console.log(` Project-level: ${stats.project}`);
45
+ console.log(` Global: ${stats.global}`);
46
+ console.log(` Agents: ${stats.agents.join(', ')}`);
47
+ return;
48
+ }
95
49
 
96
- let skills = registryManager.getAllSkills();
50
+ // 显示 skills
51
+ console.log(chalk.blue('\nInstalled Skills:'));
52
+ console.log(chalk.gray('=================='));
97
53
 
98
- // Apply filters
99
- if (options.category) {
100
- skills = registryManager.getSkillsByCategory(options.category);
101
- }
54
+ if (skills.length === 0) {
55
+ console.log(chalk.yellow(' No skills found.'));
56
+ console.log(chalk.gray(' Run: joySkills install <skill>'));
57
+ return;
58
+ }
102
59
 
103
- if (options.search) {
104
- skills = registryManager.searchSkills(options.search);
105
- }
60
+ // scope 分组显示
61
+ const projectSkills = skills.filter(s => s.scope === 'project');
62
+ const globalSkills = skills.filter(s => s.scope === 'global');
106
63
 
107
- if (options.installed) {
108
- skills = skills.filter(skill => installedSkills[skill.id]);
64
+ if (projectSkills.length > 0 && !options.global) {
65
+ console.log(chalk.cyan('\n 📁 Project-level:'));
66
+ projectSkills.forEach(skill => {
67
+ const scopeLabel = skill.agent === 'joycode' ? '' : chalk.gray(`[${skill.agent}]`);
68
+ console.log(` ✅ ${chalk.bold(skill.name)} ${chalk.gray(`v${skill.version}`)} ${scopeLabel}`);
69
+ if (skill.description) {
70
+ console.log(` ${chalk.gray(skill.description)}`);
109
71
  }
72
+ });
73
+ }
110
74
 
111
- if (skills.length > 0) {
112
- skills.forEach(skill => {
113
- const isInstalled = installedSkills[skill.id] ? '✅' : ' ';
114
- const installedVersion = installedSkills[skill.id]?.version;
115
-
116
- // Get recommended version
117
- const recommended = registryManager.getRecommendedVersion(skill.id);
118
- const recommendedVersion = recommended ? recommended.version : 'N/A';
119
-
120
- console.log(` ${isInstalled} ${skill.name || skill.id} (${skill.id})`);
121
- if (options.allVersions) {
122
- skill.versions.forEach(v => {
123
- const marker = v.recommended ? '⭐' : ' ';
124
- const state = v.state === 'approved' ? '✓' : v.state;
125
- console.log(` ${marker} v${v.version} [${state}]`);
126
- });
127
- } else {
128
- console.log(` Recommended: v${recommendedVersion}`);
129
- if (installedVersion) {
130
- console.log(` Installed: v${installedVersion}`);
131
- }
132
- }
133
- if (skill.description) {
134
- console.log(` ${skill.description}`);
135
- }
136
- console.log('');
137
- });
138
- } else {
139
- console.log(' No matching registry skills found.');
75
+ if (globalSkills.length > 0 && !options.project) {
76
+ console.log(chalk.cyan('\n 🏠 Global (user-level):'));
77
+ globalSkills.forEach(skill => {
78
+ const scopeLabel = skill.agent === 'joycode' ? '' : chalk.gray(`[${skill.agent}]`);
79
+ console.log(` ✅ ${chalk.bold(skill.name)} ${chalk.gray(`v${skill.version}`)} ${scopeLabel}`);
80
+ if (skill.description) {
81
+ console.log(` ${chalk.gray(skill.description)}`);
140
82
  }
141
- } catch (error) {
142
- console.error('Error loading registry:', error.message);
143
- }
83
+ });
144
84
  }
85
+
86
+ console.log(chalk.gray(`\n Total: ${skills.length} skill(s)`));
145
87
  } catch (error) {
146
- console.error('Error listing skills:', error);
88
+ console.error(chalk.red('Error listing skills:'), error.message);
147
89
  }
148
90
  });
149
91
  }
@@ -5,12 +5,21 @@ import * as os from 'os';
5
5
  import chalk from 'chalk';
6
6
 
7
7
  function findSkillOnDisk(skillName, projectRoot) {
8
+ // v2.0: Support all agent directories, joycode first
8
9
  const dirs = [
10
+ // Project-level
11
+ path.join(projectRoot, '.joycode', 'skills'),
9
12
  path.join(projectRoot, '.agent', 'skills'),
10
13
  path.join(projectRoot, '.claude', 'skills'),
14
+ path.join(projectRoot, '.cursor', 'skills'),
15
+ path.join(projectRoot, '.qoder', 'skills'),
16
+ path.join(projectRoot, 'skills'),
17
+ // Global
18
+ path.join(os.homedir(), '.joycode', 'skills'),
11
19
  path.join(os.homedir(), '.agent', 'skills'),
12
20
  path.join(os.homedir(), '.claude', 'skills'),
13
- path.join(projectRoot, 'skills'),
21
+ path.join(os.homedir(), '.cursor', 'skills'),
22
+ path.join(os.homedir(), '.qoder', 'skills'),
14
23
  ];
15
24
  for (const d of dirs) {
16
25
  const p = path.join(d, skillName);
@@ -20,12 +20,18 @@ export function syncCommand(program) {
20
20
 
21
21
  console.log(chalk.blue('🔍 Scanning skills...'));
22
22
 
23
- // Scan all skill directories (openskill priority order)
23
+ // Scan project-level skill directories only (v2.0)
24
+ // AGENTS.md should only reference project-level skills
24
25
  const skillDirs = [
26
+ // JoyCode (default, highest priority)
27
+ { path: path.join(projectRoot, '.joycode', 'skills'), label: 'joycode' },
28
+ // Universal (multi-agent compatible)
25
29
  { path: path.join(projectRoot, '.agent', 'skills'), label: 'universal' },
26
- { path: path.join(projectRoot, '.claude', 'skills'), label: 'project' },
27
- { path: path.join(os.homedir(), '.agent', 'skills'), label: 'global-universal' },
28
- { path: path.join(os.homedir(), '.claude', 'skills'), label: 'global' },
30
+ // Claude Code
31
+ { path: path.join(projectRoot, '.claude', 'skills'), label: 'claude' },
32
+ // Other agents
33
+ { path: path.join(projectRoot, '.cursor', 'skills'), label: 'cursor' },
34
+ { path: path.join(projectRoot, '.qoder', 'skills'), label: 'qoder' },
29
35
  // legacy
30
36
  { path: path.join(projectRoot, 'skills'), label: 'legacy' },
31
37
  ];