@grunnverk/kilde 0.1.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.
Files changed (75) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +31 -0
  3. package/.github/pull_request_template.md +48 -0
  4. package/.github/workflows/deploy-docs.yml +59 -0
  5. package/.github/workflows/npm-publish.yml +48 -0
  6. package/.github/workflows/test.yml +48 -0
  7. package/CHANGELOG.md +92 -0
  8. package/CONTRIBUTING.md +438 -0
  9. package/LICENSE +190 -0
  10. package/PROJECT_SUMMARY.md +318 -0
  11. package/README.md +444 -0
  12. package/RELEASE_CHECKLIST.md +182 -0
  13. package/dist/application.js +166 -0
  14. package/dist/application.js.map +1 -0
  15. package/dist/commands/release.js +326 -0
  16. package/dist/commands/release.js.map +1 -0
  17. package/dist/constants.js +122 -0
  18. package/dist/constants.js.map +1 -0
  19. package/dist/logging.js +176 -0
  20. package/dist/logging.js.map +1 -0
  21. package/dist/main.js +24 -0
  22. package/dist/main.js.map +1 -0
  23. package/dist/mcp-server.js +17467 -0
  24. package/dist/mcp-server.js.map +7 -0
  25. package/dist/utils/config.js +89 -0
  26. package/dist/utils/config.js.map +1 -0
  27. package/docs/AI_GUIDE.md +618 -0
  28. package/eslint.config.mjs +85 -0
  29. package/guide/architecture.md +776 -0
  30. package/guide/commands.md +580 -0
  31. package/guide/configuration.md +779 -0
  32. package/guide/mcp-integration.md +708 -0
  33. package/guide/overview.md +225 -0
  34. package/package.json +91 -0
  35. package/scripts/build-mcp.js +115 -0
  36. package/scripts/test-mcp-compliance.js +254 -0
  37. package/src/application.ts +246 -0
  38. package/src/commands/release.ts +450 -0
  39. package/src/constants.ts +162 -0
  40. package/src/logging.ts +210 -0
  41. package/src/main.ts +25 -0
  42. package/src/mcp/prompts/index.ts +98 -0
  43. package/src/mcp/resources.ts +121 -0
  44. package/src/mcp/server.ts +195 -0
  45. package/src/mcp/tools.ts +219 -0
  46. package/src/types.ts +131 -0
  47. package/src/utils/config.ts +181 -0
  48. package/tests/application.test.ts +114 -0
  49. package/tests/commands/commit.test.ts +248 -0
  50. package/tests/commands/release.test.ts +325 -0
  51. package/tests/constants.test.ts +118 -0
  52. package/tests/logging.test.ts +142 -0
  53. package/tests/mcp/prompts/index.test.ts +202 -0
  54. package/tests/mcp/resources.test.ts +166 -0
  55. package/tests/mcp/tools.test.ts +211 -0
  56. package/tests/utils/config.test.ts +212 -0
  57. package/tsconfig.json +32 -0
  58. package/vite.config.ts +107 -0
  59. package/vitest.config.ts +40 -0
  60. package/website/index.html +14 -0
  61. package/website/src/App.css +142 -0
  62. package/website/src/App.tsx +34 -0
  63. package/website/src/components/Commands.tsx +182 -0
  64. package/website/src/components/Configuration.tsx +214 -0
  65. package/website/src/components/Examples.tsx +234 -0
  66. package/website/src/components/Footer.css +99 -0
  67. package/website/src/components/Footer.tsx +93 -0
  68. package/website/src/components/GettingStarted.tsx +94 -0
  69. package/website/src/components/Hero.css +95 -0
  70. package/website/src/components/Hero.tsx +50 -0
  71. package/website/src/components/Navigation.css +102 -0
  72. package/website/src/components/Navigation.tsx +57 -0
  73. package/website/src/index.css +36 -0
  74. package/website/src/main.tsx +10 -0
  75. package/website/vite.config.ts +12 -0
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getPrompts, getPrompt } from '../../../src/mcp/prompts/index';
3
+
4
+ describe('MCP prompts', () => {
5
+ describe('getPrompts', () => {
6
+ it('should return array of prompt definitions', () => {
7
+ const prompts = getPrompts();
8
+ expect(Array.isArray(prompts)).toBe(true);
9
+ expect(prompts.length).toBe(2);
10
+ });
11
+
12
+ it('should include commit-workflow prompt', () => {
13
+ const prompts = getPrompts();
14
+ const commitPrompt = prompts.find(p => p.name === 'commit-workflow');
15
+
16
+ expect(commitPrompt).toBeDefined();
17
+ expect(commitPrompt?.description).toContain('commit');
18
+ expect(commitPrompt?.arguments).toBeDefined();
19
+ expect(Array.isArray(commitPrompt?.arguments)).toBe(true);
20
+ });
21
+
22
+ it('should include release-workflow prompt', () => {
23
+ const prompts = getPrompts();
24
+ const releasePrompt = prompts.find(p => p.name === 'release-workflow');
25
+
26
+ expect(releasePrompt).toBeDefined();
27
+ expect(releasePrompt?.description).toContain('release');
28
+ expect(releasePrompt?.arguments).toBeDefined();
29
+ expect(Array.isArray(releasePrompt?.arguments)).toBe(true);
30
+ });
31
+
32
+ it('should define optional context argument for commit-workflow', () => {
33
+ const prompts = getPrompts();
34
+ const commitPrompt = prompts.find(p => p.name === 'commit-workflow');
35
+ const contextArg = commitPrompt?.arguments?.find(a => a.name === 'context');
36
+
37
+ expect(contextArg).toBeDefined();
38
+ expect(contextArg?.required).toBe(false);
39
+ expect(contextArg?.description).toContain('context');
40
+ });
41
+
42
+ it('should define optional arguments for release-workflow', () => {
43
+ const prompts = getPrompts();
44
+ const releasePrompt = prompts.find(p => p.name === 'release-workflow');
45
+
46
+ const versionArg = releasePrompt?.arguments?.find(a => a.name === 'version');
47
+ const fromTagArg = releasePrompt?.arguments?.find(a => a.name === 'fromTag');
48
+
49
+ expect(versionArg).toBeDefined();
50
+ expect(versionArg?.required).toBe(false);
51
+ expect(fromTagArg).toBeDefined();
52
+ expect(fromTagArg?.required).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe('getPrompt', () => {
57
+ describe('commit-workflow', () => {
58
+ it('should return commit workflow prompt', async () => {
59
+ const result = await getPrompt('commit-workflow');
60
+
61
+ expect(result.description).toContain('commit');
62
+ expect(result.messages).toBeDefined();
63
+ expect(Array.isArray(result.messages)).toBe(true);
64
+ expect(result.messages.length).toBeGreaterThan(0);
65
+ });
66
+
67
+ it('should have user role message', async () => {
68
+ const result = await getPrompt('commit-workflow');
69
+ const userMessage = result.messages.find(m => m.role === 'user');
70
+
71
+ expect(userMessage).toBeDefined();
72
+ expect(userMessage?.content.type).toBe('text');
73
+ expect(userMessage?.content.text).toContain('commit');
74
+ });
75
+
76
+ it('should include workflow steps', async () => {
77
+ const result = await getPrompt('commit-workflow');
78
+ const userMessage = result.messages.find(m => m.role === 'user');
79
+
80
+ expect(userMessage?.content.text).toContain('Steps:');
81
+ expect(userMessage?.content.text).toContain('kilde_commit');
82
+ expect(userMessage?.content.text).toContain('dryRun=true');
83
+ });
84
+
85
+ it('should include context when provided', async () => {
86
+ const result = await getPrompt('commit-workflow', { context: 'Bug fix for issue #123' });
87
+ const userMessage = result.messages.find(m => m.role === 'user');
88
+
89
+ expect(userMessage?.content.text).toContain('Bug fix for issue #123');
90
+ expect(userMessage?.content.text).toContain('Additional context:');
91
+ });
92
+
93
+ it('should not include context section when not provided', async () => {
94
+ const result = await getPrompt('commit-workflow');
95
+ const userMessage = result.messages.find(m => m.role === 'user');
96
+
97
+ expect(userMessage?.content.text).not.toContain('Additional context:');
98
+ });
99
+ });
100
+
101
+ describe('release-workflow', () => {
102
+ it('should return release workflow prompt', async () => {
103
+ const result = await getPrompt('release-workflow');
104
+
105
+ expect(result.description).toContain('release');
106
+ expect(result.messages).toBeDefined();
107
+ expect(Array.isArray(result.messages)).toBe(true);
108
+ expect(result.messages.length).toBeGreaterThan(0);
109
+ });
110
+
111
+ it('should have user role message', async () => {
112
+ const result = await getPrompt('release-workflow');
113
+ const userMessage = result.messages.find(m => m.role === 'user');
114
+
115
+ expect(userMessage).toBeDefined();
116
+ expect(userMessage?.content.type).toBe('text');
117
+ expect(userMessage?.content.text).toContain('release');
118
+ });
119
+
120
+ it('should include workflow steps', async () => {
121
+ const result = await getPrompt('release-workflow');
122
+ const userMessage = result.messages.find(m => m.role === 'user');
123
+
124
+ expect(userMessage?.content.text).toContain('Steps:');
125
+ expect(userMessage?.content.text).toContain('kilde_release');
126
+ expect(userMessage?.content.text).toContain('dryRun=true');
127
+ });
128
+
129
+ it('should include version when provided', async () => {
130
+ const result = await getPrompt('release-workflow', { version: 'v2.0.0' });
131
+ const userMessage = result.messages.find(m => m.role === 'user');
132
+
133
+ expect(userMessage?.content.text).toContain('v2.0.0');
134
+ expect(userMessage?.content.text).toContain('version:');
135
+ });
136
+
137
+ it('should include fromTag when provided', async () => {
138
+ const result = await getPrompt('release-workflow', { fromTag: 'v1.0.0' });
139
+ const userMessage = result.messages.find(m => m.role === 'user');
140
+
141
+ expect(userMessage?.content.text).toContain('v1.0.0');
142
+ expect(userMessage?.content.text).toContain('fromTag:');
143
+ });
144
+
145
+ it('should include both version and fromTag when provided', async () => {
146
+ const result = await getPrompt('release-workflow', {
147
+ version: 'v2.0.0',
148
+ fromTag: 'v1.0.0'
149
+ });
150
+ const userMessage = result.messages.find(m => m.role === 'user');
151
+
152
+ expect(userMessage?.content.text).toContain('v2.0.0');
153
+ expect(userMessage?.content.text).toContain('v1.0.0');
154
+ });
155
+
156
+ it('should not include version section when not provided', async () => {
157
+ const result = await getPrompt('release-workflow');
158
+ const userMessage = result.messages.find(m => m.role === 'user');
159
+
160
+ expect(userMessage?.content.text).not.toContain('version:');
161
+ });
162
+ });
163
+
164
+ describe('unknown prompt', () => {
165
+ it('should throw error for unknown prompt name', async () => {
166
+ await expect(getPrompt('unknown-prompt')).rejects.toThrow('Unknown prompt: unknown-prompt');
167
+ });
168
+
169
+ it('should throw error for empty prompt name', async () => {
170
+ await expect(getPrompt('')).rejects.toThrow('Unknown prompt:');
171
+ });
172
+ });
173
+
174
+ describe('type safety', () => {
175
+ it('should return messages with correct type structure', async () => {
176
+ const result = await getPrompt('commit-workflow');
177
+
178
+ for (const message of result.messages) {
179
+ expect(['user', 'assistant']).toContain(message.role);
180
+ expect(message.content.type).toBe('text');
181
+ expect(typeof message.content.text).toBe('string');
182
+ }
183
+ });
184
+
185
+ it('should use literal types for role', async () => {
186
+ const result = await getPrompt('commit-workflow');
187
+ const message = result.messages[0];
188
+
189
+ // TypeScript should enforce this at compile time
190
+ // This test verifies runtime behavior matches
191
+ expect(message.role === 'user' || message.role === 'assistant').toBe(true);
192
+ });
193
+
194
+ it('should use literal type for content type', async () => {
195
+ const result = await getPrompt('commit-workflow');
196
+ const message = result.messages[0];
197
+
198
+ expect(message.content.type).toBe('text');
199
+ });
200
+ });
201
+ });
202
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { getResources, readResource } from '../../src/mcp/resources';
3
+ import { execSync } from 'child_process';
4
+
5
+ // Mock dependencies
6
+ vi.mock('child_process', () => ({
7
+ execSync: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('@grunnverk/shared', () => ({
11
+ createStorage: vi.fn(() => ({
12
+ exists: vi.fn(),
13
+ readFile: vi.fn(),
14
+ writeFile: vi.fn(),
15
+ })),
16
+ }));
17
+
18
+ vi.mock('../../src/utils/config', () => ({
19
+ getEffectiveConfig: vi.fn(() => Promise.resolve({
20
+ verbose: false,
21
+ debug: false,
22
+ model: 'gpt-4o-mini',
23
+ })),
24
+ }));
25
+
26
+ describe('MCP resources', () => {
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ describe('getResources', () => {
32
+ it('should return array of resource definitions', () => {
33
+ const resources = getResources();
34
+ expect(Array.isArray(resources)).toBe(true);
35
+ expect(resources.length).toBe(3);
36
+ });
37
+
38
+ it('should include config resource', () => {
39
+ const resources = getResources();
40
+ const configResource = resources.find(r => r.uri === 'kilde://config');
41
+
42
+ expect(configResource).toBeDefined();
43
+ expect(configResource?.name).toContain('configuration');
44
+ expect(configResource?.mimeType).toBe('application/json');
45
+ });
46
+
47
+ it('should include status resource', () => {
48
+ const resources = getResources();
49
+ const statusResource = resources.find(r => r.uri === 'kilde://status');
50
+
51
+ expect(statusResource).toBeDefined();
52
+ expect(statusResource?.name).toContain('status');
53
+ expect(statusResource?.mimeType).toBe('text/plain');
54
+ });
55
+
56
+ it('should include workspace resource', () => {
57
+ const resources = getResources();
58
+ const workspaceResource = resources.find(r => r.uri === 'kilde://workspace');
59
+
60
+ expect(workspaceResource).toBeDefined();
61
+ expect(workspaceResource?.name).toContain('Workspace');
62
+ expect(workspaceResource?.mimeType).toBe('application/json');
63
+ });
64
+ });
65
+
66
+ describe('readResource', () => {
67
+ describe('kilde://config', () => {
68
+ it('should return current configuration as JSON', async () => {
69
+ const result = await readResource('kilde://config');
70
+
71
+ expect(result.contents).toHaveLength(1);
72
+ expect(result.contents[0].uri).toBe('kilde://config');
73
+ expect(result.contents[0].mimeType).toBe('application/json');
74
+
75
+ const config = JSON.parse(result.contents[0].text);
76
+ expect(config.verbose).toBeDefined();
77
+ expect(config.debug).toBeDefined();
78
+ expect(config.model).toBeDefined();
79
+ });
80
+
81
+ it('should return valid JSON format', async () => {
82
+ const result = await readResource('kilde://config');
83
+ expect(() => JSON.parse(result.contents[0].text)).not.toThrow();
84
+ });
85
+ });
86
+
87
+ describe('kilde://status', () => {
88
+ it('should return git status information', async () => {
89
+ vi.mocked(execSync)
90
+ .mockReturnValueOnce('On branch main\nnothing to commit' as any)
91
+ .mockReturnValueOnce('main' as any)
92
+ .mockReturnValueOnce('abc1234 Latest commit' as any);
93
+
94
+ const result = await readResource('kilde://status');
95
+
96
+ expect(result.contents).toHaveLength(1);
97
+ expect(result.contents[0].uri).toBe('kilde://status');
98
+ expect(result.contents[0].mimeType).toBe('text/plain');
99
+ expect(result.contents[0].text).toContain('Current Branch: main');
100
+ expect(result.contents[0].text).toContain('Last Commit: abc1234 Latest commit');
101
+ });
102
+
103
+ it('should handle git command errors gracefully', async () => {
104
+ vi.mocked(execSync).mockImplementation(() => {
105
+ throw new Error('Not a git repository');
106
+ });
107
+
108
+ const result = await readResource('kilde://status');
109
+
110
+ expect(result.contents).toHaveLength(1);
111
+ expect(result.contents[0].text).toContain('Error reading git status');
112
+ expect(result.contents[0].text).toContain('Not a git repository');
113
+ });
114
+ });
115
+
116
+ describe('kilde://workspace', () => {
117
+ it('should return workspace information', async () => {
118
+ vi.mocked(execSync)
119
+ .mockReturnValueOnce('true' as any)
120
+ .mockReturnValueOnce('/path/to/repo' as any);
121
+
122
+ const result = await readResource('kilde://workspace');
123
+
124
+ expect(result.contents).toHaveLength(1);
125
+ expect(result.contents[0].uri).toBe('kilde://workspace');
126
+ expect(result.contents[0].mimeType).toBe('application/json');
127
+
128
+ const workspace = JSON.parse(result.contents[0].text);
129
+ expect(workspace.workingDirectory).toBeDefined();
130
+ expect(workspace.isGitRepository).toBe(true);
131
+ expect(workspace.gitRoot).toBe('/path/to/repo');
132
+ });
133
+
134
+ it('should handle non-git directory', async () => {
135
+ vi.mocked(execSync).mockImplementation(() => {
136
+ throw new Error('Not a git repository');
137
+ });
138
+
139
+ const result = await readResource('kilde://workspace');
140
+
141
+ expect(result.contents).toHaveLength(1);
142
+ const workspace = JSON.parse(result.contents[0].text);
143
+ expect(workspace.error).toContain('Not a git repository');
144
+ });
145
+
146
+ it('should return valid JSON format', async () => {
147
+ vi.mocked(execSync)
148
+ .mockReturnValueOnce('true' as any)
149
+ .mockReturnValueOnce('/path/to/repo' as any);
150
+
151
+ const result = await readResource('kilde://workspace');
152
+ expect(() => JSON.parse(result.contents[0].text)).not.toThrow();
153
+ });
154
+ });
155
+
156
+ describe('unknown URI', () => {
157
+ it('should throw error for unknown resource URI', async () => {
158
+ await expect(readResource('kilde://unknown')).rejects.toThrow('Unknown resource URI');
159
+ });
160
+
161
+ it('should throw error for invalid URI format', async () => {
162
+ await expect(readResource('invalid-uri')).rejects.toThrow('Unknown resource URI');
163
+ });
164
+ });
165
+ });
166
+ });
@@ -0,0 +1,211 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { executeTool, getTools } from '../../src/mcp/tools';
3
+ import * as CommandsGit from '@grunnverk/commands-git';
4
+ import * as ReleaseCommand from '../../src/commands/release';
5
+
6
+ // Mock the dependencies
7
+ vi.mock('@grunnverk/commands-git', () => ({
8
+ commit: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('../../src/commands/release', () => ({
12
+ execute: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('@grunnverk/shared', () => ({
16
+ createStorage: vi.fn(() => ({
17
+ writeFile: vi.fn(),
18
+ readFile: vi.fn(),
19
+ })),
20
+ }));
21
+
22
+ describe('MCP tools', () => {
23
+ const mockLogger = {
24
+ info: vi.fn(),
25
+ error: vi.fn(),
26
+ debug: vi.fn(),
27
+ warn: vi.fn(),
28
+ verbose: vi.fn(),
29
+ };
30
+
31
+ const mockContext = {
32
+ workingDirectory: '/test/dir',
33
+ config: undefined,
34
+ logger: mockLogger,
35
+ };
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ });
40
+
41
+ describe('executeTool', () => {
42
+ describe('kilde_commit', () => {
43
+ it('should execute commit command with default args', async () => {
44
+ const mockSummary = 'Commit created successfully';
45
+ vi.mocked(CommandsGit.commit).mockResolvedValue(mockSummary);
46
+
47
+ const result = await executeTool('kilde_commit', {}, mockContext);
48
+
49
+ expect(CommandsGit.commit).toHaveBeenCalled();
50
+ expect(result.content).toHaveLength(1);
51
+ expect(result.content[0].type).toBe('text');
52
+ expect(result.content[0].text).toBe(mockSummary);
53
+ });
54
+
55
+ it('should pass commit options to config', async () => {
56
+ vi.mocked(CommandsGit.commit).mockResolvedValue('Success');
57
+
58
+ await executeTool('kilde_commit', {
59
+ add: true,
60
+ cached: true,
61
+ sendit: true,
62
+ interactive: false,
63
+ amend: true,
64
+ context: 'Test context',
65
+ contextFiles: ['file1.ts', 'file2.ts'],
66
+ dryRun: true,
67
+ verbose: true,
68
+ debug: false,
69
+ }, mockContext);
70
+
71
+ const callArgs = vi.mocked(CommandsGit.commit).mock.calls[0][0];
72
+ expect(callArgs.commit?.add).toBe(true);
73
+ expect(callArgs.commit?.cached).toBe(true);
74
+ expect(callArgs.commit?.sendit).toBe(true);
75
+ expect(callArgs.commit?.interactive).toBe(false);
76
+ expect(callArgs.commit?.amend).toBe(true);
77
+ expect(callArgs.commit?.context).toBe('Test context');
78
+ expect(callArgs.commit?.contextFiles).toEqual(['file1.ts', 'file2.ts']);
79
+ expect(callArgs.dryRun).toBe(true);
80
+ expect(callArgs.verbose).toBe(true);
81
+ expect(callArgs.debug).toBe(false);
82
+ });
83
+
84
+ it('should handle errors gracefully', async () => {
85
+ const error = new Error('Commit failed');
86
+ vi.mocked(CommandsGit.commit).mockRejectedValue(error);
87
+
88
+ await expect(executeTool('kilde_commit', {}, mockContext)).rejects.toThrow('Commit failed');
89
+ expect(mockLogger.error).toHaveBeenCalled();
90
+ });
91
+ });
92
+
93
+ describe('kilde_release', () => {
94
+ it('should execute release command with default args', async () => {
95
+ const mockRelease = {
96
+ title: 'Release v1.0.0',
97
+ body: 'Release notes body',
98
+ };
99
+ vi.mocked(ReleaseCommand.execute).mockResolvedValue(mockRelease);
100
+
101
+ const result = await executeTool('kilde_release', {}, mockContext);
102
+
103
+ expect(ReleaseCommand.execute).toHaveBeenCalled();
104
+ expect(result.content).toHaveLength(1);
105
+ expect(result.content[0].type).toBe('text');
106
+ expect(result.content[0].text).toContain('Release v1.0.0');
107
+ expect(result.content[0].text).toContain('Release notes body');
108
+ });
109
+
110
+ it('should pass release options to config', async () => {
111
+ vi.mocked(ReleaseCommand.execute).mockResolvedValue({
112
+ title: 'Release',
113
+ body: 'Notes',
114
+ });
115
+
116
+ await executeTool('kilde_release', {
117
+ fromTag: 'v1.0.0',
118
+ toTag: 'v2.0.0',
119
+ version: 'v2.0.0',
120
+ output: 'RELEASE.md',
121
+ interactive: true,
122
+ focus: 'breaking changes',
123
+ context: 'Migration guide',
124
+ contextFiles: ['CHANGELOG.md'],
125
+ dryRun: false,
126
+ verbose: true,
127
+ debug: true,
128
+ }, mockContext);
129
+
130
+ const callArgs = vi.mocked(ReleaseCommand.execute).mock.calls[0][0];
131
+ expect(callArgs.release?.from).toBe('v1.0.0');
132
+ expect(callArgs.release?.to).toBe('v2.0.0');
133
+ expect(callArgs.release?.interactive).toBe(true);
134
+ expect(callArgs.release?.focus).toBe('breaking changes');
135
+ expect(callArgs.release?.context).toBe('Migration guide');
136
+ expect(callArgs.release?.contextFiles).toEqual(['CHANGELOG.md']);
137
+ expect((callArgs.release as any)?.version).toBe('v2.0.0');
138
+ expect((callArgs.release as any)?.output).toBe('RELEASE.md');
139
+ expect(callArgs.dryRun).toBe(false);
140
+ expect(callArgs.verbose).toBe(true);
141
+ expect(callArgs.debug).toBe(true);
142
+ });
143
+
144
+ it('should handle errors gracefully', async () => {
145
+ const error = new Error('Release failed');
146
+ vi.mocked(ReleaseCommand.execute).mockRejectedValue(error);
147
+
148
+ await expect(executeTool('kilde_release', {}, mockContext)).rejects.toThrow('Release failed');
149
+ expect(mockLogger.error).toHaveBeenCalled();
150
+ });
151
+
152
+ it('should use HEAD as default toTag', async () => {
153
+ vi.mocked(ReleaseCommand.execute).mockResolvedValue({
154
+ title: 'Release',
155
+ body: 'Notes',
156
+ });
157
+
158
+ await executeTool('kilde_release', { fromTag: 'v1.0.0' }, mockContext);
159
+
160
+ const callArgs = vi.mocked(ReleaseCommand.execute).mock.calls[0][0];
161
+ expect(callArgs.release?.to).toBe('HEAD');
162
+ });
163
+ });
164
+
165
+ describe('unknown tool', () => {
166
+ it('should throw error for unknown tool name', async () => {
167
+ await expect(executeTool('unknown_tool', {}, mockContext)).rejects.toThrow('Unknown tool: unknown_tool');
168
+ });
169
+ });
170
+ });
171
+
172
+ describe('getTools', () => {
173
+ it('should return array of tool definitions', () => {
174
+ const tools = getTools();
175
+ expect(Array.isArray(tools)).toBe(true);
176
+ expect(tools.length).toBe(2);
177
+ });
178
+
179
+ it('should include kilde_commit tool', () => {
180
+ const tools = getTools();
181
+ const commitTool = tools.find(t => t.name === 'kilde_commit');
182
+
183
+ expect(commitTool).toBeDefined();
184
+ expect(commitTool?.description).toContain('commit');
185
+ expect(commitTool?.inputSchema.properties.add).toBeDefined();
186
+ expect(commitTool?.inputSchema.properties.sendit).toBeDefined();
187
+ expect(commitTool?.inputSchema.properties.cached).toBeDefined();
188
+ });
189
+
190
+ it('should include kilde_release tool', () => {
191
+ const tools = getTools();
192
+ const releaseTool = tools.find(t => t.name === 'kilde_release');
193
+
194
+ expect(releaseTool).toBeDefined();
195
+ expect(releaseTool?.description).toContain('release');
196
+ expect(releaseTool?.inputSchema.properties.fromTag).toBeDefined();
197
+ expect(releaseTool?.inputSchema.properties.toTag).toBeDefined();
198
+ expect(releaseTool?.inputSchema.properties.version).toBeDefined();
199
+ });
200
+
201
+ it('should have correct input schema types', () => {
202
+ const tools = getTools();
203
+ const commitTool = tools.find(t => t.name === 'kilde_commit');
204
+
205
+ expect(commitTool?.inputSchema.type).toBe('object');
206
+ expect(commitTool?.inputSchema.properties?.add?.type).toBe('boolean');
207
+ expect(commitTool?.inputSchema.properties?.context?.type).toBe('string');
208
+ expect(commitTool?.inputSchema.properties?.contextFiles?.type).toBe('array');
209
+ });
210
+ });
211
+ });