stigmergy 1.2.8 → 1.2.11
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 +40 -6
- package/STIGMERGY.md +10 -0
- package/package.json +19 -5
- package/scripts/preuninstall.js +10 -0
- package/src/adapters/claude/install_claude_integration.js +21 -21
- package/src/adapters/codebuddy/install_codebuddy_integration.js +54 -51
- package/src/adapters/codex/install_codex_integration.js +27 -28
- package/src/adapters/gemini/install_gemini_integration.js +60 -60
- package/src/adapters/iflow/install_iflow_integration.js +72 -72
- package/src/adapters/qoder/install_qoder_integration.js +64 -64
- package/src/adapters/qwen/install_qwen_integration.js +7 -7
- package/src/cli/router.js +581 -175
- package/src/commands/skill-bridge.js +39 -0
- package/src/commands/skill-handler.js +150 -0
- package/src/commands/skill.js +127 -0
- package/src/core/cli_path_detector.js +710 -0
- package/src/core/cli_tools.js +72 -1
- package/src/core/coordination/nodejs/AdapterManager.js +29 -1
- package/src/core/directory_permission_manager.js +568 -0
- package/src/core/enhanced_cli_installer.js +609 -0
- package/src/core/installer.js +232 -88
- package/src/core/multilingual/language-pattern-manager.js +78 -50
- package/src/core/persistent_shell_configurator.js +468 -0
- package/src/core/skills/StigmergySkillManager.js +357 -0
- package/src/core/skills/__tests__/SkillInstaller.test.js +275 -0
- package/src/core/skills/__tests__/SkillParser.test.js +202 -0
- package/src/core/skills/__tests__/SkillReader.test.js +189 -0
- package/src/core/skills/cli-command-test.js +201 -0
- package/src/core/skills/comprehensive-e2e-test.js +473 -0
- package/src/core/skills/e2e-test.js +267 -0
- package/src/core/skills/embedded-openskills/SkillInstaller.js +438 -0
- package/src/core/skills/embedded-openskills/SkillParser.js +123 -0
- package/src/core/skills/embedded-openskills/SkillReader.js +143 -0
- package/src/core/skills/integration-test.js +248 -0
- package/src/core/skills/package.json +6 -0
- package/src/core/skills/regression-test.js +285 -0
- package/src/core/skills/run-all-tests.js +129 -0
- package/src/core/skills/sync-test.js +210 -0
- package/src/core/skills/test-runner.js +242 -0
- package/src/utils/helpers.js +3 -20
- package/src/auth.js +0 -173
- package/src/auth_command.js +0 -208
- package/src/calculator.js +0 -313
- package/src/core/enhanced_installer.js +0 -479
- package/src/core/enhanced_uninstaller.js +0 -638
- package/src/data_encryption.js +0 -143
- package/src/data_structures.js +0 -440
- package/src/deploy.js +0 -55
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StigmergySkillManager - Unified Skill Manager
|
|
3
|
+
*
|
|
4
|
+
* Integrates OpenSkills core functionality + Stigmergy cross-CLI routing
|
|
5
|
+
*
|
|
6
|
+
* License: Apache 2.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SkillReader } from './embedded-openskills/SkillReader.js';
|
|
10
|
+
import { SkillInstaller } from './embedded-openskills/SkillInstaller.js';
|
|
11
|
+
import { SkillParser } from './embedded-openskills/SkillParser.js';
|
|
12
|
+
import fs from 'fs/promises';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
|
|
16
|
+
export class StigmergySkillManager {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.skillsDir = options.skillsDir || path.join(os.homedir(), '.stigmergy/skills');
|
|
19
|
+
|
|
20
|
+
// Create custom search paths (prioritize skillsDir)
|
|
21
|
+
const customSearchPaths = [
|
|
22
|
+
this.skillsDir, // Stigmergy skills dir (highest priority)
|
|
23
|
+
path.join(process.cwd(), '.agent/skills'), // Project universal
|
|
24
|
+
path.join(os.homedir(), '.agent/skills'), // Global universal
|
|
25
|
+
path.join(process.cwd(), '.claude/skills'), // Project Claude
|
|
26
|
+
path.join(os.homedir(), '.claude/skills'), // Global Claude
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
this.reader = new SkillReader(customSearchPaths);
|
|
30
|
+
this.installer = new SkillInstaller(this.skillsDir);
|
|
31
|
+
this.parser = new SkillParser();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Install skills from GitHub repository
|
|
36
|
+
* @param {string} source - GitHub URL or owner/repo
|
|
37
|
+
* @param {Object} options - Installation options
|
|
38
|
+
* @returns {Promise<Array>} List of installed skills
|
|
39
|
+
*/
|
|
40
|
+
async install(source, options = {}) {
|
|
41
|
+
console.log(`[INFO] Installing skills from ${source}...`);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const skills = await this.installer.installFromGitHub(source, options);
|
|
45
|
+
|
|
46
|
+
console.log(`\n[OK] Successfully installed ${skills.length} skill(s)`);
|
|
47
|
+
|
|
48
|
+
// Auto-sync to AGENTS.md (if enabled)
|
|
49
|
+
if (options.autoSync !== false) {
|
|
50
|
+
await this.sync();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return skills;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(`[X] Installation failed: ${err.message}`);
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read skill content (output format compatible with OpenSkills)
|
|
62
|
+
* @param {string} name - Skill name
|
|
63
|
+
* @returns {Promise<Object>} Skill content
|
|
64
|
+
*/
|
|
65
|
+
async read(name) {
|
|
66
|
+
try {
|
|
67
|
+
const skill = await this.reader.readSkill(name);
|
|
68
|
+
|
|
69
|
+
// Output format compatible with OpenSkills
|
|
70
|
+
console.log(`Reading: ${skill.name}`);
|
|
71
|
+
console.log(`Base directory: ${skill.baseDir}`);
|
|
72
|
+
console.log('');
|
|
73
|
+
console.log(skill.content);
|
|
74
|
+
console.log('');
|
|
75
|
+
console.log(`Skill read: ${skill.name}`);
|
|
76
|
+
|
|
77
|
+
return skill;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`[X] Error reading skill '${name}': ${err.message}`);
|
|
80
|
+
console.error('\nSearched paths:');
|
|
81
|
+
this.reader.searchPaths.forEach(p => console.error(` - ${p}`));
|
|
82
|
+
console.error('\nInstall skills: stigmergy skill install <source>');
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* List all installed skills
|
|
89
|
+
* @returns {Promise<Array>} List of skills
|
|
90
|
+
*/
|
|
91
|
+
async list() {
|
|
92
|
+
try {
|
|
93
|
+
const skills = await this.reader.listSkills();
|
|
94
|
+
|
|
95
|
+
if (skills.length === 0) {
|
|
96
|
+
console.log('No skills installed');
|
|
97
|
+
console.log('\nInstall skills: stigmergy skill install <source>');
|
|
98
|
+
console.log('Example: stigmergy skill install anthropics/skills');
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log(`\nInstalled skills (${skills.length}):\n`);
|
|
103
|
+
|
|
104
|
+
// Group by location for display
|
|
105
|
+
const grouped = this.groupByLocation(skills);
|
|
106
|
+
|
|
107
|
+
for (const [location, locationSkills] of Object.entries(grouped)) {
|
|
108
|
+
console.log(`${this.getLocationIcon(location)} ${location}:`);
|
|
109
|
+
locationSkills.forEach(skill => {
|
|
110
|
+
console.log(` • ${skill.name.padEnd(30)} ${skill.description}`);
|
|
111
|
+
});
|
|
112
|
+
console.log('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return skills;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`[X] Error listing skills: ${err.message}`);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sync skills to CLI configuration files
|
|
124
|
+
* @returns {Promise<void>}
|
|
125
|
+
*/
|
|
126
|
+
async sync() {
|
|
127
|
+
console.log('[INFO] Syncing skills to CLI configuration files...');
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const skills = await this.reader.listSkills();
|
|
131
|
+
|
|
132
|
+
if (skills.length === 0) {
|
|
133
|
+
console.log('[INFO] No skills to sync');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Generate <available_skills> XML
|
|
138
|
+
const skillsXml = this.generateSkillsXml(skills);
|
|
139
|
+
|
|
140
|
+
// All CLI configuration files to update
|
|
141
|
+
const cliFiles = [
|
|
142
|
+
'AGENTS.md', // Universal config
|
|
143
|
+
'claude.md', // Claude CLI
|
|
144
|
+
'qwen.md', // Qwen CLI
|
|
145
|
+
'gemini.md', // Gemini CLI
|
|
146
|
+
'iflow.md', // iFlow CLI
|
|
147
|
+
'qodercli.md', // Qoder CLI
|
|
148
|
+
'codebuddy.md', // CodeBuddy CLI
|
|
149
|
+
'copilot.md', // Copilot CLI
|
|
150
|
+
'codex.md' // Codex CLI
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
let syncedCount = 0;
|
|
154
|
+
let createdCount = 0;
|
|
155
|
+
let skippedCount = 0;
|
|
156
|
+
|
|
157
|
+
// Iterate through all CLI configuration files
|
|
158
|
+
for (const fileName of cliFiles) {
|
|
159
|
+
const filePath = path.join(process.cwd(), fileName);
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const result = await this.syncToFile(filePath, fileName, skillsXml);
|
|
163
|
+
if (result === 'synced') {
|
|
164
|
+
syncedCount++;
|
|
165
|
+
} else if (result === 'created') {
|
|
166
|
+
createdCount++;
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.log(`[INFO] Skipped ${fileName}: ${err.message}`);
|
|
170
|
+
skippedCount++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Output sync result summary
|
|
175
|
+
console.log(`\n[OK] Sync completed:`);
|
|
176
|
+
console.log(` - Updated: ${syncedCount} file(s)`);
|
|
177
|
+
if (createdCount > 0) {
|
|
178
|
+
console.log(` - Created: ${createdCount} file(s)`);
|
|
179
|
+
}
|
|
180
|
+
if (skippedCount > 0) {
|
|
181
|
+
console.log(` - Skipped: ${skippedCount} file(s)`);
|
|
182
|
+
}
|
|
183
|
+
console.log(` - Skills: ${skills.length}`);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error(`[X] Sync failed: ${err.message}`);
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Sync skills to a single file
|
|
192
|
+
* @private
|
|
193
|
+
* @param {string} filePath - Full file path
|
|
194
|
+
* @param {string} fileName - File name (for logging)
|
|
195
|
+
* @param {string} skillsXml - Skills XML content
|
|
196
|
+
* @returns {Promise<string>} 'synced' | 'created'
|
|
197
|
+
*/
|
|
198
|
+
async syncToFile(filePath, fileName, skillsXml) {
|
|
199
|
+
try {
|
|
200
|
+
let content = await fs.readFile(filePath, 'utf-8');
|
|
201
|
+
|
|
202
|
+
// Replace or insert skills section
|
|
203
|
+
if (content.includes('<!-- SKILLS_START -->')) {
|
|
204
|
+
content = content.replace(
|
|
205
|
+
/<!-- SKILLS_START -->.*?<!-- SKILLS_END -->/s,
|
|
206
|
+
`<!-- SKILLS_START -->\n${skillsXml}\n<!-- SKILLS_END -->`
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
// Add to end of file
|
|
210
|
+
content += `\n\n<!-- SKILLS_START -->\n${skillsXml}\n<!-- SKILLS_END -->\n`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
214
|
+
console.log(` [OK] ${fileName}`);
|
|
215
|
+
return 'synced';
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (err.code === 'ENOENT') {
|
|
218
|
+
// File does not exist, create new file
|
|
219
|
+
const cliName = fileName.replace('.md', '');
|
|
220
|
+
const content = `# ${cliName.toUpperCase()} Configuration\n\n<!-- SKILLS_START -->\n${skillsXml}\n<!-- SKILLS_END -->\n`;
|
|
221
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
222
|
+
console.log(` [OK] ${fileName} (created)`);
|
|
223
|
+
return 'created';
|
|
224
|
+
} else {
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Generate <available_skills> XML
|
|
232
|
+
* @private
|
|
233
|
+
*/
|
|
234
|
+
generateSkillsXml(skills) {
|
|
235
|
+
let xml = '<skills_system priority="1">\n\n';
|
|
236
|
+
xml += '## Stigmergy Skills\n\n';
|
|
237
|
+
xml += '<usage>\n';
|
|
238
|
+
xml += 'Load skills using Stigmergy skill manager:\n\n';
|
|
239
|
+
xml += 'Direct call (current CLI):\n';
|
|
240
|
+
xml += ' Bash("stigmergy skill read <skill-name>")\n\n';
|
|
241
|
+
xml += 'Cross-CLI call (specify CLI):\n';
|
|
242
|
+
xml += ' Bash("stigmergy use <cli-name> skill <skill-name>")\n\n';
|
|
243
|
+
xml += 'Smart routing (auto-select best CLI):\n';
|
|
244
|
+
xml += ' Bash("stigmergy call skill <skill-name>")\n\n';
|
|
245
|
+
xml += 'The skill content will load with detailed instructions.\n';
|
|
246
|
+
xml += 'Base directory will be provided for resolving bundled resources.\n';
|
|
247
|
+
xml += '</usage>\n\n';
|
|
248
|
+
xml += '<available_skills>\n\n';
|
|
249
|
+
|
|
250
|
+
for (const skill of skills) {
|
|
251
|
+
xml += '<skill>\n';
|
|
252
|
+
xml += `<name>${skill.name}</name>\n`;
|
|
253
|
+
xml += `<description>${this.escapeXml(skill.description)}</description>\n`;
|
|
254
|
+
xml += `<location>${skill.location}</location>\n`;
|
|
255
|
+
xml += '</skill>\n\n';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
xml += '</available_skills>\n\n';
|
|
259
|
+
xml += '</skills_system>';
|
|
260
|
+
|
|
261
|
+
return xml;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Remove skill
|
|
266
|
+
* @param {string} name - Skill name
|
|
267
|
+
* @returns {Promise<void>}
|
|
268
|
+
*/
|
|
269
|
+
async remove(name) {
|
|
270
|
+
try {
|
|
271
|
+
await this.installer.uninstallSkill(name);
|
|
272
|
+
console.log(`[OK] Removed skill: ${name}`);
|
|
273
|
+
|
|
274
|
+
// Auto-sync
|
|
275
|
+
await this.sync();
|
|
276
|
+
} catch (err) {
|
|
277
|
+
console.error(`[X] Failed to remove skill: ${err.message}`);
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Validate skill
|
|
284
|
+
* @param {string} pathOrName - Skill path or name
|
|
285
|
+
* @returns {Promise<Object>} Validation result
|
|
286
|
+
*/
|
|
287
|
+
async validate(pathOrName) {
|
|
288
|
+
try {
|
|
289
|
+
let content;
|
|
290
|
+
|
|
291
|
+
// Determine if path or name
|
|
292
|
+
if (pathOrName.includes('/') || pathOrName.includes('\\')) {
|
|
293
|
+
// Is a path
|
|
294
|
+
content = await fs.readFile(pathOrName, 'utf-8');
|
|
295
|
+
} else {
|
|
296
|
+
// Is a name
|
|
297
|
+
const skill = await this.reader.readSkill(pathOrName);
|
|
298
|
+
content = skill.content;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const validation = this.parser.validateSkill(content);
|
|
302
|
+
|
|
303
|
+
if (validation.valid) {
|
|
304
|
+
console.log('[OK] Skill validation passed');
|
|
305
|
+
} else {
|
|
306
|
+
console.log('[X] Skill validation failed:');
|
|
307
|
+
validation.errors.forEach(err => console.log(` - ${err}`));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return validation;
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(`[X] Validation error: ${err.message}`);
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Group by location
|
|
319
|
+
* @private
|
|
320
|
+
*/
|
|
321
|
+
groupByLocation(skills) {
|
|
322
|
+
return skills.reduce((groups, skill) => {
|
|
323
|
+
const loc = skill.location || 'unknown';
|
|
324
|
+
if (!groups[loc]) groups[loc] = [];
|
|
325
|
+
groups[loc].push(skill);
|
|
326
|
+
return groups;
|
|
327
|
+
}, {});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get location icon
|
|
332
|
+
* @private
|
|
333
|
+
*/
|
|
334
|
+
getLocationIcon(location) {
|
|
335
|
+
const icons = {
|
|
336
|
+
'stigmergy': '[GLOBAL]',
|
|
337
|
+
'project': '[PROJECT]',
|
|
338
|
+
'global': '[HOME]',
|
|
339
|
+
'claude': '[CLAUDE]',
|
|
340
|
+
'universal': '[UNIVERSAL]'
|
|
341
|
+
};
|
|
342
|
+
return icons[location] || '[INFO]';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* XML escape
|
|
347
|
+
* @private
|
|
348
|
+
*/
|
|
349
|
+
escapeXml(str) {
|
|
350
|
+
return str
|
|
351
|
+
.replace(/&/g, '&')
|
|
352
|
+
.replace(/</g, '<')
|
|
353
|
+
.replace(/>/g, '>')
|
|
354
|
+
.replace(/"/g, '"')
|
|
355
|
+
.replace(/'/g, ''');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillInstaller Tests - TDD Approach
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { SkillInstaller } from '../embedded-openskills/SkillInstaller.js';
|
|
10
|
+
|
|
11
|
+
describe('SkillInstaller', () => {
|
|
12
|
+
let installer;
|
|
13
|
+
let testInstallDir;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
testInstallDir = path.join(os.tmpdir(), `test-install-${Date.now()}`);
|
|
17
|
+
await fs.mkdir(testInstallDir, { recursive: true });
|
|
18
|
+
|
|
19
|
+
installer = new SkillInstaller(testInstallDir);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await fs.rm(testInstallDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('parseGitHubUrl', () => {
|
|
27
|
+
it('should parse standard GitHub URL', () => {
|
|
28
|
+
// Arrange
|
|
29
|
+
const url = 'https://github.com/owner/repo';
|
|
30
|
+
|
|
31
|
+
// Act
|
|
32
|
+
const result = installer.parseGitHubUrl(url);
|
|
33
|
+
|
|
34
|
+
// Assert
|
|
35
|
+
expect(result.owner).toBe('owner');
|
|
36
|
+
expect(result.repo).toBe('repo');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should parse GitHub URL with .git suffix', () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const url = 'https://github.com/owner/repo.git';
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
const result = installer.parseGitHubUrl(url);
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(result.owner).toBe('owner');
|
|
48
|
+
expect(result.repo).toBe('repo');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should parse short format owner/repo', () => {
|
|
52
|
+
// Arrange
|
|
53
|
+
const url = 'owner/repo';
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
const result = installer.parseGitHubUrl(url);
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(result.owner).toBe('owner');
|
|
60
|
+
expect(result.repo).toBe('repo');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should throw error for invalid URL', () => {
|
|
64
|
+
// Arrange
|
|
65
|
+
const url = 'not-a-valid-url';
|
|
66
|
+
|
|
67
|
+
// Act & Assert
|
|
68
|
+
expect(() => installer.parseGitHubUrl(url))
|
|
69
|
+
.toThrow('Invalid GitHub URL');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('scanSkills', () => {
|
|
74
|
+
it('should find skills in directory', async () => {
|
|
75
|
+
// Arrange: Create test skill structure
|
|
76
|
+
const skill1Dir = path.join(testInstallDir, 'skill-1');
|
|
77
|
+
const skill2Dir = path.join(testInstallDir, 'skill-2');
|
|
78
|
+
|
|
79
|
+
await fs.mkdir(skill1Dir, { recursive: true });
|
|
80
|
+
await fs.mkdir(skill2Dir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
await fs.writeFile(
|
|
83
|
+
path.join(skill1Dir, 'SKILL.md'),
|
|
84
|
+
'---\nname: skill-1\ndescription: First skill\n---\n'
|
|
85
|
+
);
|
|
86
|
+
await fs.writeFile(
|
|
87
|
+
path.join(skill2Dir, 'SKILL.md'),
|
|
88
|
+
'---\nname: skill-2\ndescription: Second skill\n---\n'
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Act
|
|
92
|
+
const skills = await installer.scanSkills(testInstallDir);
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
95
|
+
expect(skills).toHaveLength(2);
|
|
96
|
+
expect(skills.map(s => s.name)).toContain('skill-1');
|
|
97
|
+
expect(skills.map(s => s.name)).toContain('skill-2');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should find nested skills', async () => {
|
|
101
|
+
// Arrange: Create nested structure
|
|
102
|
+
const nestedDir = path.join(testInstallDir, 'category', 'skill-nested');
|
|
103
|
+
await fs.mkdir(nestedDir, { recursive: true });
|
|
104
|
+
await fs.writeFile(
|
|
105
|
+
path.join(nestedDir, 'SKILL.md'),
|
|
106
|
+
'---\nname: skill-nested\n---\n'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Act
|
|
110
|
+
const skills = await installer.scanSkills(testInstallDir);
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
113
|
+
expect(skills).toHaveLength(1);
|
|
114
|
+
expect(skills[0].name).toBe('skill-nested');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should ignore directories without SKILL.md', async () => {
|
|
118
|
+
// Arrange
|
|
119
|
+
await fs.mkdir(path.join(testInstallDir, 'not-a-skill'));
|
|
120
|
+
const validDir = path.join(testInstallDir, 'valid-skill');
|
|
121
|
+
await fs.mkdir(validDir);
|
|
122
|
+
await fs.writeFile(
|
|
123
|
+
path.join(validDir, 'SKILL.md'),
|
|
124
|
+
'---\nname: valid-skill\n---\n'
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
const skills = await installer.scanSkills(testInstallDir);
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(skills).toHaveLength(1);
|
|
132
|
+
expect(skills[0].name).toBe('valid-skill');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should include skill metadata', async () => {
|
|
136
|
+
// Arrange
|
|
137
|
+
const skillDir = path.join(testInstallDir, 'meta-skill');
|
|
138
|
+
await fs.mkdir(skillDir);
|
|
139
|
+
await fs.writeFile(
|
|
140
|
+
path.join(skillDir, 'SKILL.md'),
|
|
141
|
+
'---\nname: meta-skill\ndescription: Has metadata\nversion: 2.0.0\n---\n'
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Act
|
|
145
|
+
const skills = await installer.scanSkills(testInstallDir);
|
|
146
|
+
|
|
147
|
+
// Assert
|
|
148
|
+
expect(skills[0].description).toBe('Has metadata');
|
|
149
|
+
expect(skills[0].version).toBe('2.0.0');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('installSkill', () => {
|
|
154
|
+
it('should copy skill to target directory', async () => {
|
|
155
|
+
// Arrange
|
|
156
|
+
const sourceDir = path.join(testInstallDir, 'source');
|
|
157
|
+
const skillDir = path.join(sourceDir, 'test-skill');
|
|
158
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
159
|
+
await fs.writeFile(
|
|
160
|
+
path.join(skillDir, 'SKILL.md'),
|
|
161
|
+
'---\nname: test-skill\n---\n'
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const targetDir = path.join(testInstallDir, 'target');
|
|
165
|
+
installer = new SkillInstaller(targetDir);
|
|
166
|
+
|
|
167
|
+
const skill = {
|
|
168
|
+
name: 'test-skill',
|
|
169
|
+
path: skillDir
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Act
|
|
173
|
+
await installer.installSkill(skill);
|
|
174
|
+
|
|
175
|
+
// Assert
|
|
176
|
+
const installed = await fs.access(
|
|
177
|
+
path.join(targetDir, 'test-skill', 'SKILL.md')
|
|
178
|
+
).then(() => true).catch(() => false);
|
|
179
|
+
expect(installed).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should copy bundled resources', async () => {
|
|
183
|
+
// Arrange
|
|
184
|
+
const sourceDir = path.join(testInstallDir, 'source');
|
|
185
|
+
const skillDir = path.join(sourceDir, 'skill-with-resources');
|
|
186
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
187
|
+
await fs.mkdir(path.join(skillDir, 'scripts'));
|
|
188
|
+
await fs.mkdir(path.join(skillDir, 'references'));
|
|
189
|
+
|
|
190
|
+
await fs.writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: skill\n---\n');
|
|
191
|
+
await fs.writeFile(path.join(skillDir, 'scripts', 'helper.py'), 'print("hi")');
|
|
192
|
+
await fs.writeFile(path.join(skillDir, 'references', 'guide.md'), '# Guide');
|
|
193
|
+
|
|
194
|
+
const targetDir = path.join(testInstallDir, 'target');
|
|
195
|
+
installer = new SkillInstaller(targetDir);
|
|
196
|
+
|
|
197
|
+
const skill = {
|
|
198
|
+
name: 'skill-with-resources',
|
|
199
|
+
path: skillDir
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Act
|
|
203
|
+
await installer.installSkill(skill);
|
|
204
|
+
|
|
205
|
+
// Assert
|
|
206
|
+
const scriptExists = await fs.access(
|
|
207
|
+
path.join(targetDir, 'skill-with-resources', 'scripts', 'helper.py')
|
|
208
|
+
).then(() => true).catch(() => false);
|
|
209
|
+
const refExists = await fs.access(
|
|
210
|
+
path.join(targetDir, 'skill-with-resources', 'references', 'guide.md')
|
|
211
|
+
).then(() => true).catch(() => false);
|
|
212
|
+
|
|
213
|
+
expect(scriptExists).toBe(true);
|
|
214
|
+
expect(refExists).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should not overwrite existing skill by default', async () => {
|
|
218
|
+
// Arrange
|
|
219
|
+
const targetDir = path.join(testInstallDir, 'target');
|
|
220
|
+
const existingSkillDir = path.join(targetDir, 'existing-skill');
|
|
221
|
+
await fs.mkdir(existingSkillDir, { recursive: true });
|
|
222
|
+
await fs.writeFile(
|
|
223
|
+
path.join(existingSkillDir, 'SKILL.md'),
|
|
224
|
+
'original content'
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const sourceDir = path.join(testInstallDir, 'source', 'existing-skill');
|
|
228
|
+
await fs.mkdir(sourceDir, { recursive: true });
|
|
229
|
+
await fs.writeFile(
|
|
230
|
+
path.join(sourceDir, 'SKILL.md'),
|
|
231
|
+
'new content'
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
installer = new SkillInstaller(targetDir);
|
|
235
|
+
const skill = { name: 'existing-skill', path: sourceDir };
|
|
236
|
+
|
|
237
|
+
// Act & Assert
|
|
238
|
+
await expect(installer.installSkill(skill))
|
|
239
|
+
.rejects
|
|
240
|
+
.toThrow('already exists');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('calculateSize', () => {
|
|
245
|
+
it('should calculate directory size', async () => {
|
|
246
|
+
// Arrange
|
|
247
|
+
const dir = path.join(testInstallDir, 'size-test');
|
|
248
|
+
await fs.mkdir(dir);
|
|
249
|
+
await fs.writeFile(path.join(dir, 'file1.txt'), 'x'.repeat(100));
|
|
250
|
+
await fs.writeFile(path.join(dir, 'file2.txt'), 'y'.repeat(200));
|
|
251
|
+
|
|
252
|
+
// Act
|
|
253
|
+
const size = await installer.calculateSize(dir);
|
|
254
|
+
|
|
255
|
+
// Assert
|
|
256
|
+
expect(size).toBeGreaterThan(0);
|
|
257
|
+
expect(size).toBe(300); // 100 + 200 bytes
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should include subdirectories in size', async () => {
|
|
261
|
+
// Arrange
|
|
262
|
+
const dir = path.join(testInstallDir, 'nested-size-test');
|
|
263
|
+
const subdir = path.join(dir, 'subdir');
|
|
264
|
+
await fs.mkdir(subdir, { recursive: true });
|
|
265
|
+
await fs.writeFile(path.join(dir, 'file1.txt'), 'x'.repeat(100));
|
|
266
|
+
await fs.writeFile(path.join(subdir, 'file2.txt'), 'y'.repeat(150));
|
|
267
|
+
|
|
268
|
+
// Act
|
|
269
|
+
const size = await installer.calculateSize(dir);
|
|
270
|
+
|
|
271
|
+
// Assert
|
|
272
|
+
expect(size).toBe(250); // 100 + 150 bytes
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|