Skip to content

Commit b99a4ca

Browse files
Maksym DiabinAI/Run CodeMie
andcommitted
feat(cli): add plugin system with marketplace integration
Add a complete plugin system that enables skill sharing via plugins: - Plugin discovery, loading, and registry (PluginRegistry, PluginLoader) - Marketplace integration with Anthropic's claude-plugins-official repo - Plugin CLI commands: list, install, uninstall, update, search, info - Skill discovery from plugins with namespaced pattern support (/plugin:skill) - Compatible with Claude Code's .claude-plugin/plugin.json format The plugin system allows users to: - Search and install plugins from the official Anthropic marketplace - Discover skills from installed plugins automatically - Use namespaced skill invocation (e.g., /example-plugin:example-skill) - Manage plugins via `codemie plugin` CLI commands Generated with AI Co-Authored-By: AI/Run CodeMie <noreply@codemieai.com>
1 parent 19fb94f commit b99a4ca

17 files changed

Lines changed: 3393 additions & 19 deletions

src/agents/codemie-code/skills/core/SkillDiscovery.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ import type {
1717
* Higher priority = loaded first, can override lower priority
1818
*/
1919
const SOURCE_PRIORITY: Record<SkillSource, number> = {
20-
project: 1000, // Highest priority
20+
project: 1000, // Highest priority - project-specific skills
21+
plugin: 750, // High priority - plugins can override global but not project
2122
'mode-specific': 500, // Medium priority
22-
global: 100, // Lowest priority
23+
global: 100, // Lowest priority - user's global skills
2324
};
2425

2526
/**
@@ -47,14 +48,15 @@ export class SkillDiscovery {
4748
}
4849

4950
// Discover from all locations
50-
const [projectSkills, modeSkills, globalSkills] = await Promise.all([
51+
const [projectSkills, pluginSkills, modeSkills, globalSkills] = await Promise.all([
5152
this.discoverProjectSkills(cwd),
53+
this.discoverPluginSkills(),
5254
this.discoverModeSkills(options.mode),
5355
this.discoverGlobalSkills(),
5456
]);
5557

5658
// Combine and deduplicate by name (higher priority wins)
57-
const allSkills = [...projectSkills, ...modeSkills, ...globalSkills];
59+
const allSkills = [...projectSkills, ...pluginSkills, ...modeSkills, ...globalSkills];
5860
const deduplicatedSkills = this.deduplicateSkills(allSkills);
5961

6062
// Filter by agent if specified
@@ -103,6 +105,44 @@ export class SkillDiscovery {
103105
return this.discoverFromDirectory(globalSkillsDir, 'global');
104106
}
105107

108+
/**
109+
* Discover skills from installed plugins
110+
* Path: ~/.codemie/plugins/{name}/skills/
111+
*/
112+
private async discoverPluginSkills(): Promise<Skill[]> {
113+
const skills: Skill[] = [];
114+
115+
try {
116+
// Lazy import to avoid circular dependency
117+
const { PluginRegistry } = await import('../../../../plugins/index.js');
118+
const registry = PluginRegistry.getInstance();
119+
120+
// Get all loaded plugins
121+
const plugins = await registry.getAllPlugins();
122+
123+
for (const plugin of plugins) {
124+
// Discover skills from each plugin's skills directory
125+
const pluginSkillsDir = join(plugin.path, 'skills');
126+
const pluginSkills = await this.discoverFromDirectory(pluginSkillsDir, 'plugin');
127+
128+
// Add plugin info to each skill
129+
for (const skill of pluginSkills) {
130+
skill.pluginInfo = {
131+
pluginName: plugin.name,
132+
fullSkillName: `${plugin.name}:${skill.metadata.name}`,
133+
pluginVersion: plugin.manifest.version,
134+
};
135+
}
136+
137+
skills.push(...pluginSkills);
138+
}
139+
} catch {
140+
// Plugin system not available or error - return empty array
141+
}
142+
143+
return skills;
144+
}
145+
106146
/**
107147
* Discover skills from a specific directory
108148
*

src/agents/codemie-code/skills/core/types.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,21 @@ export type SkillMetadata = z.infer<typeof SkillMetadataSchema>;
2727
/**
2828
* Source type for a skill
2929
*/
30-
export type SkillSource = 'global' | 'project' | 'mode-specific';
30+
export type SkillSource = 'global' | 'project' | 'mode-specific' | 'plugin';
31+
32+
/**
33+
* Plugin info attached to skills from plugins
34+
*/
35+
export interface PluginSkillInfo {
36+
/** Plugin name */
37+
pluginName: string;
38+
39+
/** Full namespaced skill name (plugin-name:skill-name) */
40+
fullSkillName: string;
41+
42+
/** Plugin version */
43+
pluginVersion: string;
44+
}
3145

3246
/**
3347
* Complete skill with metadata, content, and location info
@@ -47,6 +61,9 @@ export interface Skill {
4761

4862
/** Computed priority (source-based + metadata priority) */
4963
computedPriority: number;
64+
65+
/** Plugin info (only present if source is 'plugin') */
66+
pluginInfo?: PluginSkillInfo;
5067
}
5168

5269
/**

src/agents/codemie-code/skills/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type {
1717
SkillDiscoveryOptions,
1818
SkillValidationResult,
1919
SkillsConfig,
20+
PluginSkillInfo,
2021
} from './core/types.js';
2122
export { SkillMetadataSchema } from './core/types.js';
2223

@@ -29,3 +30,12 @@ export {
2930
FrontmatterParseError,
3031
} from './utils/frontmatter.js';
3132
export type { FrontmatterResult } from './utils/frontmatter.js';
33+
34+
// Pattern matcher exports
35+
export {
36+
extractSkillPatterns,
37+
isValidSkillName,
38+
isValidNamespacedSkillName,
39+
parseNamespacedSkillName,
40+
} from './utils/pattern-matcher.js';
41+
export type { SkillPattern, PatternMatchResult } from './utils/pattern-matcher.js';

src/agents/codemie-code/skills/utils/pattern-matcher.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
*/
44

55
import { describe, it, expect } from 'vitest';
6-
import { extractSkillPatterns, isValidSkillName } from './pattern-matcher.js';
6+
import {
7+
extractSkillPatterns,
8+
isValidSkillName,
9+
isValidNamespacedSkillName,
10+
parseNamespacedSkillName,
11+
} from './pattern-matcher.js';
712

813
describe('extractSkillPatterns', () => {
914
it('should detect single pattern at start', () => {
@@ -13,6 +18,8 @@ describe('extractSkillPatterns', () => {
1318
expect(result.patterns).toHaveLength(1);
1419
expect(result.patterns[0]).toEqual({
1520
name: 'mr',
21+
fullName: 'mr',
22+
namespace: undefined,
1623
position: 0,
1724
args: undefined,
1825
raw: '/mr',
@@ -26,6 +33,8 @@ describe('extractSkillPatterns', () => {
2633
expect(result.patterns).toHaveLength(1);
2734
expect(result.patterns[0]).toEqual({
2835
name: 'commit',
36+
fullName: 'commit',
37+
namespace: undefined,
2938
position: 15,
3039
args: 'this',
3140
raw: '/commit this',
@@ -38,7 +47,9 @@ describe('extractSkillPatterns', () => {
3847
expect(result.hasPatterns).toBe(true);
3948
expect(result.patterns).toHaveLength(2);
4049
expect(result.patterns[0].name).toBe('commit');
50+
expect(result.patterns[0].fullName).toBe('commit');
4151
expect(result.patterns[1].name).toBe('mr');
52+
expect(result.patterns[1].fullName).toBe('mr');
4253
});
4354

4455
it('should detect pattern with arguments', () => {
@@ -135,6 +146,7 @@ describe('extractSkillPatterns', () => {
135146

136147
expect(result.hasPatterns).toBe(true);
137148
expect(result.patterns[0].name).toBe('my-skill-name');
149+
expect(result.patterns[0].fullName).toBe('my-skill-name');
138150
});
139151

140152
it('should detect skills with numbers', () => {
@@ -169,6 +181,60 @@ describe('extractSkillPatterns', () => {
169181

170182
expect(result.originalMessage).toBe(originalMessage);
171183
});
184+
185+
// Namespaced skill tests
186+
describe('namespaced skills', () => {
187+
it('should detect namespaced skill pattern', () => {
188+
const result = extractSkillPatterns('/gitlab-tools:mr');
189+
190+
expect(result.hasPatterns).toBe(true);
191+
expect(result.patterns).toHaveLength(1);
192+
expect(result.patterns[0]).toEqual({
193+
name: 'mr',
194+
fullName: 'gitlab-tools:mr',
195+
namespace: 'gitlab-tools',
196+
position: 0,
197+
args: undefined,
198+
raw: '/gitlab-tools:mr',
199+
});
200+
});
201+
202+
it('should detect namespaced skill with arguments', () => {
203+
const result = extractSkillPatterns('/plugin-name:skill-name arg1 arg2');
204+
205+
expect(result.hasPatterns).toBe(true);
206+
expect(result.patterns[0].name).toBe('skill-name');
207+
expect(result.patterns[0].namespace).toBe('plugin-name');
208+
expect(result.patterns[0].fullName).toBe('plugin-name:skill-name');
209+
expect(result.patterns[0].args).toBe('arg1 arg2');
210+
});
211+
212+
it('should detect both simple and namespaced patterns', () => {
213+
const result = extractSkillPatterns('/commit and /gitlab:mr');
214+
215+
expect(result.hasPatterns).toBe(true);
216+
expect(result.patterns).toHaveLength(2);
217+
expect(result.patterns[0].fullName).toBe('commit');
218+
expect(result.patterns[0].namespace).toBeUndefined();
219+
expect(result.patterns[1].fullName).toBe('gitlab:mr');
220+
expect(result.patterns[1].namespace).toBe('gitlab');
221+
});
222+
223+
it('should deduplicate by full name', () => {
224+
const result = extractSkillPatterns('/gitlab:mr and /gitlab:mr again');
225+
226+
expect(result.patterns).toHaveLength(1);
227+
expect(result.patterns[0].fullName).toBe('gitlab:mr');
228+
});
229+
230+
it('should NOT exclude built-in commands when namespaced', () => {
231+
const result = extractSkillPatterns('/my-plugin:help');
232+
233+
expect(result.hasPatterns).toBe(true);
234+
expect(result.patterns[0].name).toBe('help');
235+
expect(result.patterns[0].namespace).toBe('my-plugin');
236+
});
237+
});
172238
});
173239

174240
describe('isValidSkillName', () => {
@@ -220,3 +286,60 @@ describe('isValidSkillName', () => {
220286
expect(isValidSkillName('-skill')).toBe(false);
221287
});
222288
});
289+
290+
describe('isValidNamespacedSkillName', () => {
291+
it('should accept simple skill name', () => {
292+
expect(isValidNamespacedSkillName('commit')).toBe(true);
293+
});
294+
295+
it('should accept namespaced skill name', () => {
296+
expect(isValidNamespacedSkillName('gitlab:mr')).toBe(true);
297+
});
298+
299+
it('should accept complex namespaced name', () => {
300+
expect(isValidNamespacedSkillName('my-plugin-123:skill-name-456')).toBe(true);
301+
});
302+
303+
it('should reject invalid namespace', () => {
304+
expect(isValidNamespacedSkillName('Invalid:skill')).toBe(false);
305+
});
306+
307+
it('should reject invalid skill in namespace', () => {
308+
expect(isValidNamespacedSkillName('plugin:Invalid')).toBe(false);
309+
});
310+
311+
it('should reject multiple colons', () => {
312+
expect(isValidNamespacedSkillName('a:b:c')).toBe(false);
313+
});
314+
315+
it('should reject empty parts', () => {
316+
expect(isValidNamespacedSkillName(':skill')).toBe(false);
317+
expect(isValidNamespacedSkillName('plugin:')).toBe(false);
318+
});
319+
});
320+
321+
describe('parseNamespacedSkillName', () => {
322+
it('should parse simple name', () => {
323+
const result = parseNamespacedSkillName('commit');
324+
expect(result).toEqual({
325+
name: 'commit',
326+
namespace: undefined,
327+
});
328+
});
329+
330+
it('should parse namespaced name', () => {
331+
const result = parseNamespacedSkillName('gitlab:mr');
332+
expect(result).toEqual({
333+
name: 'mr',
334+
namespace: 'gitlab',
335+
});
336+
});
337+
338+
it('should parse complex namespaced name', () => {
339+
const result = parseNamespacedSkillName('my-plugin:my-skill');
340+
expect(result).toEqual({
341+
name: 'my-skill',
342+
namespace: 'my-plugin',
343+
});
344+
});
345+
});

0 commit comments

Comments
 (0)