morpheus-cli 0.7.2 → 0.7.3
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 +119 -0
- package/dist/channels/discord.js +109 -0
- package/dist/channels/telegram.js +94 -0
- package/dist/cli/commands/start.js +12 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/schemas.js +4 -0
- package/dist/http/api.js +3 -0
- package/dist/http/routers/skills.js +127 -0
- package/dist/runtime/__tests__/keymaker.test.js +145 -0
- package/dist/runtime/keymaker.js +162 -0
- package/dist/runtime/oracle.js +7 -2
- package/dist/runtime/scaffold.js +75 -0
- package/dist/runtime/skills/__tests__/loader.test.js +187 -0
- package/dist/runtime/skills/__tests__/registry.test.js +201 -0
- package/dist/runtime/skills/__tests__/tool.test.js +266 -0
- package/dist/runtime/skills/index.js +8 -0
- package/dist/runtime/skills/loader.js +213 -0
- package/dist/runtime/skills/registry.js +141 -0
- package/dist/runtime/skills/schema.js +30 -0
- package/dist/runtime/skills/tool.js +204 -0
- package/dist/runtime/skills/types.js +7 -0
- package/dist/runtime/tasks/worker.js +22 -0
- package/dist/ui/assets/index-CiT3ltw7.css +1 -0
- package/dist/ui/assets/{index-7e8TCoiy.js → index-DfDByABF.js} +21 -21
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-B9ngtbja.css +0 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
// Define test directory as a static string path
|
|
5
|
+
const TEST_DIR = process.cwd() + '/test-skills-registry';
|
|
6
|
+
// Mock PATHS to use test directory
|
|
7
|
+
vi.mock('../../../config/paths.js', () => ({
|
|
8
|
+
PATHS: {
|
|
9
|
+
skills: process.cwd() + '/test-skills-registry',
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
// Import after mock setup
|
|
13
|
+
import { SkillRegistry } from '../registry.js';
|
|
14
|
+
/**
|
|
15
|
+
* Helper to create SKILL.md with YAML frontmatter
|
|
16
|
+
*/
|
|
17
|
+
function createSkillMd(dir, frontmatter, content = '') {
|
|
18
|
+
const lines = [];
|
|
19
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
lines.push(`${key}:`);
|
|
22
|
+
for (const item of value) {
|
|
23
|
+
lines.push(` - ${item}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (typeof value === 'boolean') {
|
|
27
|
+
lines.push(`${key}: ${value ? 'true' : 'false'}`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
lines.push(`${key}: ${value}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const yaml = lines.join('\n');
|
|
34
|
+
const md = `---\n${yaml}\n---\n${content}`;
|
|
35
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), md);
|
|
36
|
+
}
|
|
37
|
+
describe('SkillRegistry', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
SkillRegistry.resetInstance();
|
|
40
|
+
fs.ensureDirSync(TEST_DIR);
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
fs.removeSync(TEST_DIR);
|
|
44
|
+
});
|
|
45
|
+
describe('singleton pattern', () => {
|
|
46
|
+
it('should return the same instance', () => {
|
|
47
|
+
const instance1 = SkillRegistry.getInstance();
|
|
48
|
+
const instance2 = SkillRegistry.getInstance();
|
|
49
|
+
expect(instance1).toBe(instance2);
|
|
50
|
+
});
|
|
51
|
+
it('should reset instance correctly', () => {
|
|
52
|
+
const instance1 = SkillRegistry.getInstance();
|
|
53
|
+
SkillRegistry.resetInstance();
|
|
54
|
+
const instance2 = SkillRegistry.getInstance();
|
|
55
|
+
expect(instance1).not.toBe(instance2);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe('load()', () => {
|
|
59
|
+
it('should load skills from directory', async () => {
|
|
60
|
+
const skillDir = path.join(TEST_DIR, 'test-skill');
|
|
61
|
+
fs.ensureDirSync(skillDir);
|
|
62
|
+
createSkillMd(skillDir, { name: 'test-skill', description: 'Test skill' }, 'Instructions');
|
|
63
|
+
const registry = SkillRegistry.getInstance();
|
|
64
|
+
await registry.load();
|
|
65
|
+
expect(registry.getAll()).toHaveLength(1);
|
|
66
|
+
expect(registry.get('test-skill')).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
it('should clear previous skills on reload', async () => {
|
|
69
|
+
const skillDir = path.join(TEST_DIR, 'skill-a');
|
|
70
|
+
fs.ensureDirSync(skillDir);
|
|
71
|
+
createSkillMd(skillDir, { name: 'skill-a', description: 'Skill A' }, 'Instructions');
|
|
72
|
+
const registry = SkillRegistry.getInstance();
|
|
73
|
+
await registry.load();
|
|
74
|
+
expect(registry.getAll()).toHaveLength(1);
|
|
75
|
+
// Remove the skill and reload
|
|
76
|
+
fs.removeSync(skillDir);
|
|
77
|
+
await registry.reload();
|
|
78
|
+
expect(registry.getAll()).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe('enable() / disable()', () => {
|
|
82
|
+
it('should enable a disabled skill', async () => {
|
|
83
|
+
const skillDir = path.join(TEST_DIR, 'toggle-skill');
|
|
84
|
+
fs.ensureDirSync(skillDir);
|
|
85
|
+
createSkillMd(skillDir, { name: 'toggle-skill', description: 'Toggle test', enabled: false }, 'Instructions');
|
|
86
|
+
const registry = SkillRegistry.getInstance();
|
|
87
|
+
await registry.load();
|
|
88
|
+
expect(registry.get('toggle-skill')?.enabled).toBe(false);
|
|
89
|
+
expect(registry.getEnabled()).toHaveLength(0);
|
|
90
|
+
const result = registry.enable('toggle-skill');
|
|
91
|
+
expect(result).toBe(true);
|
|
92
|
+
expect(registry.get('toggle-skill')?.enabled).toBe(true);
|
|
93
|
+
expect(registry.getEnabled()).toHaveLength(1);
|
|
94
|
+
});
|
|
95
|
+
it('should disable an enabled skill', async () => {
|
|
96
|
+
const skillDir = path.join(TEST_DIR, 'toggle-skill');
|
|
97
|
+
fs.ensureDirSync(skillDir);
|
|
98
|
+
createSkillMd(skillDir, { name: 'toggle-skill', description: 'Toggle test', enabled: true }, 'Instructions');
|
|
99
|
+
const registry = SkillRegistry.getInstance();
|
|
100
|
+
await registry.load();
|
|
101
|
+
expect(registry.get('toggle-skill')?.enabled).toBe(true);
|
|
102
|
+
const result = registry.disable('toggle-skill');
|
|
103
|
+
expect(result).toBe(true);
|
|
104
|
+
expect(registry.get('toggle-skill')?.enabled).toBe(false);
|
|
105
|
+
expect(registry.getEnabled()).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
it('should return false for non-existent skill', async () => {
|
|
108
|
+
const registry = SkillRegistry.getInstance();
|
|
109
|
+
await registry.load();
|
|
110
|
+
expect(registry.enable('non-existent')).toBe(false);
|
|
111
|
+
expect(registry.disable('non-existent')).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('getEnabled()', () => {
|
|
115
|
+
it('should return only enabled skills', async () => {
|
|
116
|
+
// Create enabled skill
|
|
117
|
+
const enabledDir = path.join(TEST_DIR, 'enabled-skill');
|
|
118
|
+
fs.ensureDirSync(enabledDir);
|
|
119
|
+
createSkillMd(enabledDir, { name: 'enabled-skill', description: 'Enabled', enabled: true }, 'Instructions');
|
|
120
|
+
// Create disabled skill
|
|
121
|
+
const disabledDir = path.join(TEST_DIR, 'disabled-skill');
|
|
122
|
+
fs.ensureDirSync(disabledDir);
|
|
123
|
+
createSkillMd(disabledDir, { name: 'disabled-skill', description: 'Disabled', enabled: false }, 'Instructions');
|
|
124
|
+
const registry = SkillRegistry.getInstance();
|
|
125
|
+
await registry.load();
|
|
126
|
+
expect(registry.getAll()).toHaveLength(2);
|
|
127
|
+
expect(registry.getEnabled()).toHaveLength(1);
|
|
128
|
+
expect(registry.getEnabled()[0].name).toBe('enabled-skill');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe('getSystemPromptSection()', () => {
|
|
132
|
+
it('should generate prompt section with sync skills', async () => {
|
|
133
|
+
const skillDir = path.join(TEST_DIR, 'prompt-skill');
|
|
134
|
+
fs.ensureDirSync(skillDir);
|
|
135
|
+
createSkillMd(skillDir, {
|
|
136
|
+
name: 'prompt-skill',
|
|
137
|
+
description: 'A skill for prompts',
|
|
138
|
+
execution_mode: 'sync',
|
|
139
|
+
examples: ['example usage'],
|
|
140
|
+
}, 'Instructions for prompt skill');
|
|
141
|
+
const registry = SkillRegistry.getInstance();
|
|
142
|
+
await registry.load();
|
|
143
|
+
const section = registry.getSystemPromptSection();
|
|
144
|
+
expect(section).toContain('Available Skills');
|
|
145
|
+
expect(section).toContain('prompt-skill');
|
|
146
|
+
expect(section).toContain('A skill for prompts');
|
|
147
|
+
expect(section).toContain('skill_execute');
|
|
148
|
+
});
|
|
149
|
+
it('should generate prompt section with async skills', async () => {
|
|
150
|
+
const skillDir = path.join(TEST_DIR, 'async-skill');
|
|
151
|
+
fs.ensureDirSync(skillDir);
|
|
152
|
+
createSkillMd(skillDir, {
|
|
153
|
+
name: 'async-skill',
|
|
154
|
+
description: 'An async skill',
|
|
155
|
+
execution_mode: 'async',
|
|
156
|
+
}, 'Instructions for async skill');
|
|
157
|
+
const registry = SkillRegistry.getInstance();
|
|
158
|
+
await registry.load();
|
|
159
|
+
const section = registry.getSystemPromptSection();
|
|
160
|
+
expect(section).toContain('Async Skills');
|
|
161
|
+
expect(section).toContain('async-skill');
|
|
162
|
+
expect(section).toContain('skill_delegate');
|
|
163
|
+
});
|
|
164
|
+
it('should return empty string when no skills', async () => {
|
|
165
|
+
const registry = SkillRegistry.getInstance();
|
|
166
|
+
await registry.load();
|
|
167
|
+
const section = registry.getSystemPromptSection();
|
|
168
|
+
expect(section).toBe('');
|
|
169
|
+
});
|
|
170
|
+
it('should not include disabled skills', async () => {
|
|
171
|
+
const skillDir = path.join(TEST_DIR, 'disabled-prompt');
|
|
172
|
+
fs.ensureDirSync(skillDir);
|
|
173
|
+
createSkillMd(skillDir, {
|
|
174
|
+
name: 'disabled-prompt',
|
|
175
|
+
description: 'Disabled skill',
|
|
176
|
+
enabled: false,
|
|
177
|
+
}, 'Instructions');
|
|
178
|
+
const registry = SkillRegistry.getInstance();
|
|
179
|
+
await registry.load();
|
|
180
|
+
const section = registry.getSystemPromptSection();
|
|
181
|
+
expect(section).toBe('');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
describe('getContent()', () => {
|
|
185
|
+
it('should return skill content from loaded skill', async () => {
|
|
186
|
+
const skillDir = path.join(TEST_DIR, 'content-skill');
|
|
187
|
+
fs.ensureDirSync(skillDir);
|
|
188
|
+
createSkillMd(skillDir, { name: 'content-skill', description: 'Test' }, '# Instructions\n\nDo the thing.');
|
|
189
|
+
const registry = SkillRegistry.getInstance();
|
|
190
|
+
await registry.load();
|
|
191
|
+
const content = registry.getContent('content-skill');
|
|
192
|
+
expect(content).toBe('# Instructions\n\nDo the thing.');
|
|
193
|
+
});
|
|
194
|
+
it('should return null for non-existent skill', async () => {
|
|
195
|
+
const registry = SkillRegistry.getInstance();
|
|
196
|
+
await registry.load();
|
|
197
|
+
const content = registry.getContent('non-existent');
|
|
198
|
+
expect(content).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
// Use vi.hoisted to define mocks before they're used in vi.mock calls
|
|
3
|
+
const { mockRegistry, mockRepository, mockDisplay, mockContext, mockKeymaker } = vi.hoisted(() => ({
|
|
4
|
+
mockRegistry: {
|
|
5
|
+
get: vi.fn(),
|
|
6
|
+
getEnabled: vi.fn(() => []),
|
|
7
|
+
getContent: vi.fn(() => null),
|
|
8
|
+
},
|
|
9
|
+
mockRepository: {
|
|
10
|
+
createTask: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
mockDisplay: {
|
|
13
|
+
log: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
mockContext: {
|
|
16
|
+
get: vi.fn(() => ({
|
|
17
|
+
origin_channel: 'telegram',
|
|
18
|
+
session_id: 'test-session',
|
|
19
|
+
origin_message_id: '123',
|
|
20
|
+
origin_user_id: 'user-1',
|
|
21
|
+
})),
|
|
22
|
+
findDuplicateDelegation: vi.fn(() => null),
|
|
23
|
+
canEnqueueDelegation: vi.fn(() => true),
|
|
24
|
+
setDelegationAck: vi.fn(),
|
|
25
|
+
},
|
|
26
|
+
mockKeymaker: {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
28
|
+
executeKeymakerTask: vi.fn((_skill, _obj, _ctx) => Promise.resolve('Keymaker result')),
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('../registry.js', () => ({
|
|
32
|
+
SkillRegistry: {
|
|
33
|
+
getInstance: () => mockRegistry,
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
vi.mock('../../tasks/repository.js', () => ({
|
|
37
|
+
TaskRepository: {
|
|
38
|
+
getInstance: () => mockRepository,
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
vi.mock('../../tasks/context.js', () => ({
|
|
42
|
+
TaskRequestContext: mockContext,
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('../../display.js', () => ({
|
|
45
|
+
DisplayManager: {
|
|
46
|
+
getInstance: () => mockDisplay,
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
vi.mock('../../keymaker.js', () => ({
|
|
50
|
+
executeKeymakerTask: (skillName, objective, context) => mockKeymaker.executeKeymakerTask(skillName, objective, context),
|
|
51
|
+
}));
|
|
52
|
+
// Now import the module under test
|
|
53
|
+
import { SkillExecuteTool, SkillDelegateTool, getSkillExecuteDescription, getSkillDelegateDescription } from '../tool.js';
|
|
54
|
+
describe('SkillExecuteTool (sync)', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
mockRegistry.get.mockReset();
|
|
58
|
+
mockRegistry.getEnabled.mockReset();
|
|
59
|
+
mockKeymaker.executeKeymakerTask.mockReset();
|
|
60
|
+
mockKeymaker.executeKeymakerTask.mockResolvedValue('Keymaker result');
|
|
61
|
+
mockRegistry.getEnabled.mockReturnValue([]);
|
|
62
|
+
});
|
|
63
|
+
describe('getSkillExecuteDescription()', () => {
|
|
64
|
+
it('should include enabled sync skills in description', () => {
|
|
65
|
+
mockRegistry.getEnabled.mockReturnValue([
|
|
66
|
+
{ name: 'code-review', description: 'Review code for issues', execution_mode: 'sync' },
|
|
67
|
+
{ name: 'git-ops', description: 'Git operations helper', execution_mode: 'sync' },
|
|
68
|
+
{ name: 'deploy', description: 'Deploy to prod', execution_mode: 'async' }, // should not appear
|
|
69
|
+
]);
|
|
70
|
+
const description = getSkillExecuteDescription();
|
|
71
|
+
expect(description).toContain('code-review: Review code for issues');
|
|
72
|
+
expect(description).toContain('git-ops: Git operations helper');
|
|
73
|
+
expect(description).not.toContain('deploy');
|
|
74
|
+
});
|
|
75
|
+
it('should show no sync skills message when none enabled', () => {
|
|
76
|
+
mockRegistry.getEnabled.mockReturnValue([]);
|
|
77
|
+
const description = getSkillExecuteDescription();
|
|
78
|
+
expect(description).toContain('(no sync skills enabled)');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe('invoke()', () => {
|
|
82
|
+
it('should execute sync skill via Keymaker', async () => {
|
|
83
|
+
mockRegistry.get.mockReturnValue({
|
|
84
|
+
name: 'test-skill',
|
|
85
|
+
description: 'Test',
|
|
86
|
+
enabled: true,
|
|
87
|
+
execution_mode: 'sync',
|
|
88
|
+
content: 'Instructions here',
|
|
89
|
+
});
|
|
90
|
+
mockRegistry.getEnabled.mockReturnValue([{ name: 'test-skill', execution_mode: 'sync' }]);
|
|
91
|
+
const result = await SkillExecuteTool.invoke({
|
|
92
|
+
skillName: 'test-skill',
|
|
93
|
+
objective: 'do the thing',
|
|
94
|
+
});
|
|
95
|
+
expect(mockKeymaker.executeKeymakerTask).toHaveBeenCalledWith('test-skill', 'do the thing', expect.objectContaining({
|
|
96
|
+
origin_channel: 'telegram',
|
|
97
|
+
session_id: 'test-session',
|
|
98
|
+
}));
|
|
99
|
+
expect(result).toBe('Keymaker result');
|
|
100
|
+
});
|
|
101
|
+
it('should return error for non-existent skill', async () => {
|
|
102
|
+
mockRegistry.get.mockReturnValue(undefined);
|
|
103
|
+
mockRegistry.getEnabled.mockReturnValue([
|
|
104
|
+
{ name: 'other-skill', execution_mode: 'sync' },
|
|
105
|
+
]);
|
|
106
|
+
const result = await SkillExecuteTool.invoke({
|
|
107
|
+
skillName: 'non-existent',
|
|
108
|
+
objective: 'do something',
|
|
109
|
+
});
|
|
110
|
+
expect(result).toContain('Error');
|
|
111
|
+
expect(result).toContain('not found');
|
|
112
|
+
expect(result).toContain('other-skill');
|
|
113
|
+
});
|
|
114
|
+
it('should return error for async skill', async () => {
|
|
115
|
+
mockRegistry.get.mockReturnValue({
|
|
116
|
+
name: 'async-skill',
|
|
117
|
+
description: 'Async only',
|
|
118
|
+
enabled: true,
|
|
119
|
+
execution_mode: 'async',
|
|
120
|
+
});
|
|
121
|
+
const result = await SkillExecuteTool.invoke({
|
|
122
|
+
skillName: 'async-skill',
|
|
123
|
+
objective: 'do something',
|
|
124
|
+
});
|
|
125
|
+
expect(result).toContain('Error');
|
|
126
|
+
expect(result).toContain('async-only');
|
|
127
|
+
expect(result).toContain('skill_delegate');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe('SkillDelegateTool (async)', () => {
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
vi.clearAllMocks();
|
|
134
|
+
mockRegistry.get.mockReset();
|
|
135
|
+
mockRegistry.getEnabled.mockReset();
|
|
136
|
+
mockRepository.createTask.mockReset();
|
|
137
|
+
mockDisplay.log.mockReset();
|
|
138
|
+
mockContext.findDuplicateDelegation.mockReturnValue(null);
|
|
139
|
+
mockContext.canEnqueueDelegation.mockReturnValue(true);
|
|
140
|
+
mockRegistry.getEnabled.mockReturnValue([]);
|
|
141
|
+
});
|
|
142
|
+
describe('getSkillDelegateDescription()', () => {
|
|
143
|
+
it('should include enabled async skills in description', () => {
|
|
144
|
+
mockRegistry.getEnabled.mockReturnValue([
|
|
145
|
+
{ name: 'deploy-staging', description: 'Deploy to staging', execution_mode: 'async' },
|
|
146
|
+
{ name: 'batch-process', description: 'Process batch jobs', execution_mode: 'async' },
|
|
147
|
+
{ name: 'code-review', description: 'Review code', execution_mode: 'sync' }, // should not appear
|
|
148
|
+
]);
|
|
149
|
+
const description = getSkillDelegateDescription();
|
|
150
|
+
expect(description).toContain('deploy-staging: Deploy to staging');
|
|
151
|
+
expect(description).toContain('batch-process: Process batch jobs');
|
|
152
|
+
expect(description).not.toContain('code-review');
|
|
153
|
+
});
|
|
154
|
+
it('should show no async skills message when none enabled', () => {
|
|
155
|
+
mockRegistry.getEnabled.mockReturnValue([]);
|
|
156
|
+
const description = getSkillDelegateDescription();
|
|
157
|
+
expect(description).toContain('(no async skills enabled)');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
describe('invoke()', () => {
|
|
161
|
+
it('should create task for valid async skill', async () => {
|
|
162
|
+
mockRegistry.get.mockReturnValue({
|
|
163
|
+
name: 'deploy-staging',
|
|
164
|
+
description: 'Deploy',
|
|
165
|
+
enabled: true,
|
|
166
|
+
execution_mode: 'async',
|
|
167
|
+
});
|
|
168
|
+
mockRegistry.getEnabled.mockReturnValue([{ name: 'deploy-staging', execution_mode: 'async' }]);
|
|
169
|
+
mockRepository.createTask.mockReturnValue({
|
|
170
|
+
id: 'task-123',
|
|
171
|
+
agent: 'keymaker',
|
|
172
|
+
status: 'pending',
|
|
173
|
+
});
|
|
174
|
+
const result = await SkillDelegateTool.invoke({
|
|
175
|
+
skillName: 'deploy-staging',
|
|
176
|
+
objective: 'deploy to staging',
|
|
177
|
+
});
|
|
178
|
+
expect(mockRepository.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
|
179
|
+
agent: 'keymaker',
|
|
180
|
+
input: 'deploy to staging',
|
|
181
|
+
context: JSON.stringify({ skill: 'deploy-staging' }),
|
|
182
|
+
origin_channel: 'telegram',
|
|
183
|
+
session_id: 'test-session',
|
|
184
|
+
}));
|
|
185
|
+
expect(result).toContain('task-123');
|
|
186
|
+
expect(result).toContain('queued');
|
|
187
|
+
});
|
|
188
|
+
it('should return error for sync skill', async () => {
|
|
189
|
+
mockRegistry.get.mockReturnValue({
|
|
190
|
+
name: 'sync-skill',
|
|
191
|
+
description: 'Sync',
|
|
192
|
+
enabled: true,
|
|
193
|
+
execution_mode: 'sync',
|
|
194
|
+
});
|
|
195
|
+
const result = await SkillDelegateTool.invoke({
|
|
196
|
+
skillName: 'sync-skill',
|
|
197
|
+
objective: 'do something',
|
|
198
|
+
});
|
|
199
|
+
expect(result).toContain('Error');
|
|
200
|
+
expect(result).toContain('sync');
|
|
201
|
+
expect(result).toContain('skill_execute');
|
|
202
|
+
expect(mockRepository.createTask).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
it('should return error for non-existent skill', async () => {
|
|
205
|
+
mockRegistry.get.mockReturnValue(undefined);
|
|
206
|
+
mockRegistry.getEnabled.mockReturnValue([
|
|
207
|
+
{ name: 'other-skill', execution_mode: 'async' },
|
|
208
|
+
]);
|
|
209
|
+
const result = await SkillDelegateTool.invoke({
|
|
210
|
+
skillName: 'non-existent',
|
|
211
|
+
objective: 'do something',
|
|
212
|
+
});
|
|
213
|
+
expect(result).toContain('Error');
|
|
214
|
+
expect(result).toContain('not found');
|
|
215
|
+
expect(mockRepository.createTask).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
it('should return error for disabled skill', async () => {
|
|
218
|
+
mockRegistry.get.mockReturnValue({
|
|
219
|
+
name: 'disabled-skill',
|
|
220
|
+
description: 'Disabled',
|
|
221
|
+
enabled: false,
|
|
222
|
+
execution_mode: 'async',
|
|
223
|
+
});
|
|
224
|
+
const result = await SkillDelegateTool.invoke({
|
|
225
|
+
skillName: 'disabled-skill',
|
|
226
|
+
objective: 'do something',
|
|
227
|
+
});
|
|
228
|
+
expect(result).toContain('Error');
|
|
229
|
+
expect(result).toContain('disabled');
|
|
230
|
+
expect(mockRepository.createTask).not.toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
it('should deduplicate delegation requests', async () => {
|
|
233
|
+
mockRegistry.get.mockReturnValue({
|
|
234
|
+
name: 'dup-skill',
|
|
235
|
+
enabled: true,
|
|
236
|
+
execution_mode: 'async',
|
|
237
|
+
});
|
|
238
|
+
mockContext.findDuplicateDelegation.mockReturnValue({
|
|
239
|
+
task_id: 'existing-task',
|
|
240
|
+
agent: 'keymaker',
|
|
241
|
+
task: 'dup-skill:objective',
|
|
242
|
+
});
|
|
243
|
+
const result = await SkillDelegateTool.invoke({
|
|
244
|
+
skillName: 'dup-skill',
|
|
245
|
+
objective: 'objective',
|
|
246
|
+
});
|
|
247
|
+
expect(result).toContain('existing-task');
|
|
248
|
+
expect(result).toContain('already queued');
|
|
249
|
+
expect(mockRepository.createTask).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
it('should block when delegation limit reached', async () => {
|
|
252
|
+
mockRegistry.get.mockReturnValue({
|
|
253
|
+
name: 'limit-skill',
|
|
254
|
+
enabled: true,
|
|
255
|
+
execution_mode: 'async',
|
|
256
|
+
});
|
|
257
|
+
mockContext.canEnqueueDelegation.mockReturnValue(false);
|
|
258
|
+
const result = await SkillDelegateTool.invoke({
|
|
259
|
+
skillName: 'limit-skill',
|
|
260
|
+
objective: 'objective',
|
|
261
|
+
});
|
|
262
|
+
expect(result).toContain('limit reached');
|
|
263
|
+
expect(mockRepository.createTask).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills System - Public API
|
|
3
|
+
*/
|
|
4
|
+
export { SkillRegistry } from './registry.js';
|
|
5
|
+
export { SkillLoader } from './loader.js';
|
|
6
|
+
export { SkillMetadataSchema } from './schema.js';
|
|
7
|
+
export { SkillExecuteTool, SkillDelegateTool, getSkillExecuteDescription, getSkillDelegateDescription, updateSkillToolDescriptions, updateSkillDelegateDescription, // backwards compat
|
|
8
|
+
} from './tool.js';
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillLoader - Discovers and loads skills from filesystem
|
|
3
|
+
*
|
|
4
|
+
* Skills are SKILL.md files with YAML frontmatter containing metadata.
|
|
5
|
+
* Format:
|
|
6
|
+
* ---
|
|
7
|
+
* name: my-skill
|
|
8
|
+
* description: What this skill does
|
|
9
|
+
* execution_mode: sync
|
|
10
|
+
* ---
|
|
11
|
+
*
|
|
12
|
+
* # Skill Instructions...
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { SkillMetadataSchema } from './schema.js';
|
|
17
|
+
import { DisplayManager } from '../display.js';
|
|
18
|
+
const SKILL_MD = 'SKILL.md';
|
|
19
|
+
const MAX_SKILL_MD_SIZE = 50 * 1024; // 50KB
|
|
20
|
+
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
21
|
+
/**
|
|
22
|
+
* Simple YAML frontmatter parser
|
|
23
|
+
* Handles basic key: value pairs and arrays
|
|
24
|
+
*/
|
|
25
|
+
function parseFrontmatter(yaml) {
|
|
26
|
+
const result = {};
|
|
27
|
+
const lines = yaml.split('\n');
|
|
28
|
+
let currentKey = null;
|
|
29
|
+
let currentArray = null;
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
33
|
+
continue;
|
|
34
|
+
// Check for array item (indented with -)
|
|
35
|
+
if (trimmed.startsWith('- ') && currentKey && currentArray !== null) {
|
|
36
|
+
currentArray.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ''));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Check for key: value
|
|
40
|
+
const colonIndex = line.indexOf(':');
|
|
41
|
+
if (colonIndex > 0) {
|
|
42
|
+
// Save previous array if exists
|
|
43
|
+
if (currentKey && currentArray !== null && currentArray.length > 0) {
|
|
44
|
+
result[currentKey] = currentArray;
|
|
45
|
+
}
|
|
46
|
+
currentArray = null;
|
|
47
|
+
const key = line.slice(0, colonIndex).trim();
|
|
48
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
49
|
+
currentKey = key;
|
|
50
|
+
if (value === '') {
|
|
51
|
+
// Could be start of array
|
|
52
|
+
currentArray = [];
|
|
53
|
+
}
|
|
54
|
+
else if (value === 'true') {
|
|
55
|
+
result[key] = true;
|
|
56
|
+
}
|
|
57
|
+
else if (value === 'false') {
|
|
58
|
+
result[key] = false;
|
|
59
|
+
}
|
|
60
|
+
else if (/^\d+$/.test(value)) {
|
|
61
|
+
result[key] = parseInt(value, 10);
|
|
62
|
+
}
|
|
63
|
+
else if (/^\d+\.\d+$/.test(value)) {
|
|
64
|
+
result[key] = parseFloat(value);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Remove quotes if present
|
|
68
|
+
result[key] = value.replace(/^["']|["']$/g, '');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Save last array if exists
|
|
73
|
+
if (currentKey && currentArray !== null && currentArray.length > 0) {
|
|
74
|
+
result[currentKey] = currentArray;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
export class SkillLoader {
|
|
79
|
+
skillsDir;
|
|
80
|
+
display = DisplayManager.getInstance();
|
|
81
|
+
constructor(skillsDir) {
|
|
82
|
+
this.skillsDir = skillsDir;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Scan skills directory and load all valid skills
|
|
86
|
+
*/
|
|
87
|
+
async scan() {
|
|
88
|
+
const skills = [];
|
|
89
|
+
const errors = [];
|
|
90
|
+
// Check if skills directory exists
|
|
91
|
+
if (!fs.existsSync(this.skillsDir)) {
|
|
92
|
+
this.display.log(`Skills directory does not exist: ${this.skillsDir}`, {
|
|
93
|
+
source: 'SkillLoader',
|
|
94
|
+
level: 'debug',
|
|
95
|
+
});
|
|
96
|
+
return { skills, errors };
|
|
97
|
+
}
|
|
98
|
+
// Read directory contents
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = fs.readdirSync(this.skillsDir, { withFileTypes: true });
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
this.display.log(`Failed to read skills directory: ${err}`, {
|
|
105
|
+
source: 'SkillLoader',
|
|
106
|
+
level: 'error',
|
|
107
|
+
});
|
|
108
|
+
return { skills, errors };
|
|
109
|
+
}
|
|
110
|
+
// Process each subdirectory
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.isDirectory())
|
|
113
|
+
continue;
|
|
114
|
+
const dirPath = path.join(this.skillsDir, entry.name);
|
|
115
|
+
const result = this.loadSkillFromDir(dirPath, entry.name);
|
|
116
|
+
if (result.skill) {
|
|
117
|
+
skills.push(result.skill);
|
|
118
|
+
}
|
|
119
|
+
else if (result.error) {
|
|
120
|
+
errors.push(result.error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { skills, errors };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Load a single skill from a directory
|
|
127
|
+
*/
|
|
128
|
+
loadSkillFromDir(dirPath, dirName) {
|
|
129
|
+
const mdPath = path.join(dirPath, SKILL_MD);
|
|
130
|
+
// Check SKILL.md exists
|
|
131
|
+
if (!fs.existsSync(mdPath)) {
|
|
132
|
+
return {
|
|
133
|
+
error: {
|
|
134
|
+
directory: dirName,
|
|
135
|
+
message: `Missing ${SKILL_MD}`,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Read SKILL.md content
|
|
140
|
+
let rawContent;
|
|
141
|
+
try {
|
|
142
|
+
const stats = fs.statSync(mdPath);
|
|
143
|
+
if (stats.size > MAX_SKILL_MD_SIZE) {
|
|
144
|
+
this.display.log(`SKILL.md for "${dirName}" exceeds ${MAX_SKILL_MD_SIZE / 1024}KB`, { source: 'SkillLoader', level: 'warning' });
|
|
145
|
+
}
|
|
146
|
+
rawContent = fs.readFileSync(mdPath, 'utf-8').slice(0, MAX_SKILL_MD_SIZE);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
return {
|
|
150
|
+
error: {
|
|
151
|
+
directory: dirName,
|
|
152
|
+
message: `Failed to read ${SKILL_MD}: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
|
+
error: err instanceof Error ? err : undefined,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// Parse frontmatter
|
|
158
|
+
const match = rawContent.match(FRONTMATTER_REGEX);
|
|
159
|
+
if (!match) {
|
|
160
|
+
return {
|
|
161
|
+
error: {
|
|
162
|
+
directory: dirName,
|
|
163
|
+
message: `Invalid format: ${SKILL_MD} must start with YAML frontmatter (--- ... ---)`,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const [, frontmatterYaml, content] = match;
|
|
168
|
+
// Parse YAML frontmatter
|
|
169
|
+
let rawMeta;
|
|
170
|
+
try {
|
|
171
|
+
rawMeta = parseFrontmatter(frontmatterYaml);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
return {
|
|
175
|
+
error: {
|
|
176
|
+
directory: dirName,
|
|
177
|
+
message: `Invalid YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
|
|
178
|
+
error: err instanceof Error ? err : undefined,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// Validate against schema
|
|
183
|
+
const parseResult = SkillMetadataSchema.safeParse(rawMeta);
|
|
184
|
+
if (!parseResult.success) {
|
|
185
|
+
const issues = parseResult.error.issues
|
|
186
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
187
|
+
.join('; ');
|
|
188
|
+
return {
|
|
189
|
+
error: {
|
|
190
|
+
directory: dirName,
|
|
191
|
+
message: `Schema validation failed: ${issues}`,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const metadata = parseResult.data;
|
|
196
|
+
// Build Skill object
|
|
197
|
+
const skill = {
|
|
198
|
+
...metadata,
|
|
199
|
+
path: mdPath,
|
|
200
|
+
dirName,
|
|
201
|
+
content: content.trim(),
|
|
202
|
+
enabled: metadata.enabled ?? true,
|
|
203
|
+
execution_mode: metadata.execution_mode ?? 'sync',
|
|
204
|
+
};
|
|
205
|
+
return { skill };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Read SKILL.md content for a skill (returns just the body, no frontmatter)
|
|
209
|
+
*/
|
|
210
|
+
readContent(skill) {
|
|
211
|
+
return skill.content || null;
|
|
212
|
+
}
|
|
213
|
+
}
|