stigmergy 1.2.8 → 1.2.10

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.
Files changed (48) hide show
  1. package/README.md +40 -6
  2. package/STIGMERGY.md +10 -0
  3. package/package.json +19 -5
  4. package/scripts/preuninstall.js +10 -0
  5. package/src/adapters/claude/install_claude_integration.js +21 -21
  6. package/src/adapters/codebuddy/install_codebuddy_integration.js +54 -51
  7. package/src/adapters/codex/install_codex_integration.js +27 -28
  8. package/src/adapters/gemini/install_gemini_integration.js +60 -60
  9. package/src/adapters/iflow/install_iflow_integration.js +72 -72
  10. package/src/adapters/qoder/install_qoder_integration.js +64 -64
  11. package/src/adapters/qwen/install_qwen_integration.js +7 -7
  12. package/src/cli/router.js +581 -175
  13. package/src/commands/skill-bridge.js +39 -0
  14. package/src/commands/skill-handler.js +150 -0
  15. package/src/commands/skill.js +127 -0
  16. package/src/core/cli_path_detector.js +573 -0
  17. package/src/core/cli_tools.js +72 -1
  18. package/src/core/coordination/nodejs/AdapterManager.js +29 -1
  19. package/src/core/directory_permission_manager.js +568 -0
  20. package/src/core/enhanced_cli_installer.js +609 -0
  21. package/src/core/installer.js +232 -88
  22. package/src/core/multilingual/language-pattern-manager.js +78 -50
  23. package/src/core/persistent_shell_configurator.js +468 -0
  24. package/src/core/skills/StigmergySkillManager.js +357 -0
  25. package/src/core/skills/__tests__/SkillInstaller.test.js +275 -0
  26. package/src/core/skills/__tests__/SkillParser.test.js +202 -0
  27. package/src/core/skills/__tests__/SkillReader.test.js +189 -0
  28. package/src/core/skills/cli-command-test.js +201 -0
  29. package/src/core/skills/comprehensive-e2e-test.js +473 -0
  30. package/src/core/skills/e2e-test.js +267 -0
  31. package/src/core/skills/embedded-openskills/SkillInstaller.js +438 -0
  32. package/src/core/skills/embedded-openskills/SkillParser.js +123 -0
  33. package/src/core/skills/embedded-openskills/SkillReader.js +143 -0
  34. package/src/core/skills/integration-test.js +248 -0
  35. package/src/core/skills/package.json +6 -0
  36. package/src/core/skills/regression-test.js +285 -0
  37. package/src/core/skills/run-all-tests.js +129 -0
  38. package/src/core/skills/sync-test.js +210 -0
  39. package/src/core/skills/test-runner.js +242 -0
  40. package/src/utils/helpers.js +3 -20
  41. package/src/auth.js +0 -173
  42. package/src/auth_command.js +0 -208
  43. package/src/calculator.js +0 -313
  44. package/src/core/enhanced_installer.js +0 -479
  45. package/src/core/enhanced_uninstaller.js +0 -638
  46. package/src/data_encryption.js +0 -143
  47. package/src/data_structures.js +0 -440
  48. 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, '&amp;')
352
+ .replace(/</g, '&lt;')
353
+ .replace(/>/g, '&gt;')
354
+ .replace(/"/g, '&quot;')
355
+ .replace(/'/g, '&apos;');
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
+ });