joyskills-cli 0.2.10 → 0.3.0

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,7 +1,7 @@
1
1
  {
2
2
  "name": "joyskills-cli",
3
- "version": "0.2.10",
4
- "description": "Team-level skill governance compatible with open skill / Claude Skills",
3
+ "version": "0.3.0",
4
+ "description": "JoySkills CLI v2.0 - Multi-agent skill management with JoyCode native support",
5
5
  "main": "src/index.js",
6
6
  "bin": {
7
7
  "joySkills": "src/index.js"
@@ -9,9 +9,16 @@
9
9
  "scripts": {
10
10
  "build": "echo 'No build needed for JavaScript version'",
11
11
  "start": "node src/index.js",
12
- "test": "jest",
12
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest",
13
13
  "dev": "node src/index.js"
14
14
  },
15
+ "jest": {
16
+ "testEnvironment": "node",
17
+ "transform": {},
18
+ "moduleNameMapper": {
19
+ "^(\\.{1,2}/.*)\\.js$": "$1"
20
+ }
21
+ },
15
22
  "keywords": [
16
23
  "claude",
17
24
  "skills",
package/src/agents.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Agent 配置系统 - 支持多编辑器
3
+ * 定义各编辑器的项目级和用户级 Skill 目录
4
+ */
5
+
6
+ import * as os from 'os';
7
+ import * as path from 'path';
8
+ import * as fs from 'fs';
9
+
10
+ /**
11
+ * Agent 定义
12
+ * id: 唯一标识
13
+ * name: 显示名称
14
+ * projectPath: 项目级目录(相对路径)
15
+ * globalPath: 用户级目录(绝对路径,使用 ~ 表示 home)
16
+ * detectFiles: 用于自动检测的文件/目录
17
+ */
18
+ export const AGENTS = [
19
+ {
20
+ id: 'joycode',
21
+ name: 'JoyCode',
22
+ projectPath: '.joycode/skills',
23
+ globalPath: '~/.joycode/skills',
24
+ detectFiles: ['.joycode'],
25
+ priority: 100, // 最高优先级,默认使用
26
+ },
27
+ {
28
+ id: 'claude-code',
29
+ name: 'Claude Code',
30
+ projectPath: '.claude/skills',
31
+ globalPath: '~/.claude/skills',
32
+ detectFiles: ['.claude', '.claude-plugin'],
33
+ priority: 90,
34
+ },
35
+ {
36
+ id: 'cursor',
37
+ name: 'Cursor',
38
+ projectPath: '.agents/skills',
39
+ globalPath: '~/.cursor/skills',
40
+ detectFiles: ['.cursor', '.agents'],
41
+ priority: 80,
42
+ },
43
+ {
44
+ id: 'codex',
45
+ name: 'Codex',
46
+ projectPath: '.agents/skills',
47
+ globalPath: '~/.codex/skills',
48
+ detectFiles: ['.codex'],
49
+ priority: 70,
50
+ },
51
+ {
52
+ id: 'qoder',
53
+ name: 'Qoder',
54
+ projectPath: '.qoder/skills',
55
+ globalPath: '~/.qoder/skills',
56
+ detectFiles: ['.qoder'],
57
+ priority: 60,
58
+ },
59
+ {
60
+ id: 'opencode',
61
+ name: 'OpenCode',
62
+ projectPath: '.agents/skills',
63
+ globalPath: '~/.config/opencode/skills',
64
+ detectFiles: ['.opencode'],
65
+ priority: 50,
66
+ },
67
+ {
68
+ id: 'universal',
69
+ name: 'Universal',
70
+ projectPath: '.agents/skills',
71
+ globalPath: '~/.config/agents/skills',
72
+ detectFiles: ['.agents'],
73
+ priority: 10,
74
+ },
75
+ ];
76
+
77
+ /**
78
+ * 获取 Agent 配置
79
+ */
80
+ export function getAgent(agentId) {
81
+ return AGENTS.find(a => a.id === agentId) || AGENTS[0]; // 默认返回 joycode
82
+ }
83
+
84
+ /**
85
+ * 获取所有 Agent
86
+ */
87
+ export function getAllAgents() {
88
+ return AGENTS;
89
+ }
90
+
91
+ /**
92
+ * 解析路径中的 ~ 为 home 目录
93
+ */
94
+ export function resolvePath(pathWithTilde) {
95
+ if (pathWithTilde.startsWith('~/')) {
96
+ return path.join(os.homedir(), pathWithTilde.slice(2));
97
+ }
98
+ return pathWithTilde;
99
+ }
100
+
101
+ /**
102
+ * 获取 Agent 的项目级目录(绝对路径)
103
+ */
104
+ export function getAgentProjectPath(agentId, projectRoot) {
105
+ const agent = getAgent(agentId);
106
+ return path.join(projectRoot, agent.projectPath);
107
+ }
108
+
109
+ /**
110
+ * 获取 Agent 的用户级目录(绝对路径)
111
+ */
112
+ export function getAgentGlobalPath(agentId) {
113
+ const agent = getAgent(agentId);
114
+ return resolvePath(agent.globalPath);
115
+ }
116
+
117
+ /**
118
+ * 检测项目中存在的 Agent
119
+ * 返回按优先级排序的 Agent ID 列表
120
+ */
121
+ export function detectAgents(projectRoot) {
122
+ const detected = [];
123
+
124
+ for (const agent of AGENTS) {
125
+ // 检查项目级目录是否存在
126
+ const projectPath = getAgentProjectPath(agent.id, projectRoot);
127
+ if (fs.existsSync(projectPath)) {
128
+ detected.push(agent.id);
129
+ continue;
130
+ }
131
+
132
+ // 检查检测文件是否存在
133
+ for (const detectFile of agent.detectFiles) {
134
+ const detectPath = path.join(projectRoot, detectFile);
135
+ if (fs.existsSync(detectPath)) {
136
+ detected.push(agent.id);
137
+ break;
138
+ }
139
+ }
140
+ }
141
+
142
+ // 按优先级排序
143
+ detected.sort((a, b) => {
144
+ const agentA = getAgent(a);
145
+ const agentB = getAgent(b);
146
+ return agentB.priority - agentA.priority;
147
+ });
148
+
149
+ return detected;
150
+ }
151
+
152
+ /**
153
+ * 获取默认 Agent
154
+ * 始终返回 joycode 作为默认,不管检测到什么其他 Agent
155
+ * 这是 JoyCode 的品牌策略
156
+ */
157
+ export function getDefaultAgent(projectRoot) {
158
+ // 始终返回 joycode 作为默认
159
+ return getAgent('joycode');
160
+ }
161
+
162
+ /**
163
+ * 获取所有 Skill 目录(项目级 + 用户级)
164
+ * 用于 SkillLoader 加载
165
+ */
166
+ export function getAllSkillPaths(projectRoot, agentId = null) {
167
+ const paths = [];
168
+ const agents = agentId ? [getAgent(agentId)] : AGENTS;
169
+
170
+ for (const agent of agents) {
171
+ // 项目级目录
172
+ const projectPath = getAgentProjectPath(agent.id, projectRoot);
173
+ if (fs.existsSync(projectPath)) {
174
+ paths.push({
175
+ path: projectPath,
176
+ scope: 'project',
177
+ agent: agent.id,
178
+ priority: agent.priority,
179
+ });
180
+ }
181
+
182
+ // 用户级目录
183
+ const globalPath = getAgentGlobalPath(agent.id);
184
+ if (fs.existsSync(globalPath)) {
185
+ paths.push({
186
+ path: globalPath,
187
+ scope: 'global',
188
+ agent: agent.id,
189
+ priority: agent.priority - 1, // 用户级优先级略低
190
+ });
191
+ }
192
+ }
193
+
194
+ // 按优先级排序
195
+ paths.sort((a, b) => b.priority - a.priority);
196
+
197
+ return paths;
198
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Check Command - 检查技能更新
3
+ */
4
+
5
+ import { Command } from 'commander';
6
+ import { checkAllSkills, getUpdatableSkills } from '../version-checker.js';
7
+ import chalk from 'chalk';
8
+
9
+ export function checkCommand(program) {
10
+ program
11
+ .command('check')
12
+ .description('Check for available skill updates')
13
+ .option('-u, --updatable', 'Show only skills with updates')
14
+ .action(async (options) => {
15
+ try {
16
+ const projectRoot = process.cwd();
17
+
18
+ console.log(chalk.blue('Checking for skill updates...'));
19
+ console.log(chalk.gray('==============================\n'));
20
+
21
+ const results = options.updatable
22
+ ? await getUpdatableSkills(projectRoot)
23
+ : await checkAllSkills(projectRoot);
24
+
25
+ if (results.length === 0) {
26
+ if (options.updatable) {
27
+ console.log(chalk.green('✅ All skills are up to date!'));
28
+ } else {
29
+ console.log(chalk.yellow('No skills found.'));
30
+ }
31
+ return;
32
+ }
33
+
34
+ let updatableCount = 0;
35
+
36
+ for (const result of results) {
37
+ if (result.hasUpdate) {
38
+ updatableCount++;
39
+ console.log(chalk.yellow(`📦 ${result.name}`));
40
+ console.log(` Current: v${result.currentVersion}`);
41
+ console.log(chalk.green(` ${result.commitsBehind} commit(s) behind`));
42
+ if (result.latestCommit) {
43
+ console.log(chalk.gray(` Latest: ${result.latestCommit.message.substring(0, 50)}...`));
44
+ }
45
+ console.log();
46
+ } else if (!options.updatable) {
47
+ console.log(chalk.green(`✅ ${result.name} v${result.currentVersion}`));
48
+ if (result.error) {
49
+ console.log(chalk.gray(` ${result.error}`));
50
+ } else {
51
+ console.log(chalk.gray(' Up to date'));
52
+ }
53
+ console.log();
54
+ }
55
+ }
56
+
57
+ if (updatableCount > 0) {
58
+ console.log(chalk.blue(`\nFound ${updatableCount} skill(s) with updates.`));
59
+ console.log(chalk.gray('Run `joySkills update` to update all skills.'));
60
+ } else if (!options.updatable) {
61
+ console.log(chalk.green('\n✅ All skills are up to date!'));
62
+ }
63
+
64
+ } catch (error) {
65
+ console.error(chalk.red('❌ Failed to check updates:'), error.message);
66
+ process.exit(1);
67
+ }
68
+ });
69
+ }
@@ -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)`));