@plaited/acp-harness 0.2.5

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 (45) hide show
  1. package/.claude/rules/accuracy.md +43 -0
  2. package/.claude/rules/bun-apis.md +80 -0
  3. package/.claude/rules/code-review.md +254 -0
  4. package/.claude/rules/git-workflow.md +37 -0
  5. package/.claude/rules/github.md +154 -0
  6. package/.claude/rules/testing.md +172 -0
  7. package/.claude/skills/acp-harness/SKILL.md +310 -0
  8. package/.claude/skills/acp-harness/assets/Dockerfile.acp +25 -0
  9. package/.claude/skills/acp-harness/assets/docker-compose.acp.yml +19 -0
  10. package/.claude/skills/acp-harness/references/downstream.md +288 -0
  11. package/.claude/skills/acp-harness/references/output-formats.md +221 -0
  12. package/.claude-plugin/marketplace.json +15 -0
  13. package/.claude-plugin/plugin.json +16 -0
  14. package/.github/CODEOWNERS +6 -0
  15. package/.github/workflows/ci.yml +63 -0
  16. package/.github/workflows/publish.yml +146 -0
  17. package/.mcp.json +20 -0
  18. package/CLAUDE.md +92 -0
  19. package/Dockerfile.test +23 -0
  20. package/LICENSE +15 -0
  21. package/README.md +94 -0
  22. package/bin/cli.ts +670 -0
  23. package/bin/tests/cli.spec.ts +362 -0
  24. package/biome.json +96 -0
  25. package/bun.lock +513 -0
  26. package/docker-compose.test.yml +21 -0
  27. package/package.json +57 -0
  28. package/scripts/bun-test-wrapper.sh +46 -0
  29. package/src/acp-client.ts +503 -0
  30. package/src/acp-helpers.ts +121 -0
  31. package/src/acp-transport.ts +455 -0
  32. package/src/acp-utils.ts +341 -0
  33. package/src/acp.constants.ts +56 -0
  34. package/src/acp.schemas.ts +161 -0
  35. package/src/acp.ts +27 -0
  36. package/src/acp.types.ts +28 -0
  37. package/src/tests/acp-client.spec.ts +205 -0
  38. package/src/tests/acp-helpers.spec.ts +105 -0
  39. package/src/tests/acp-integration.docker.ts +214 -0
  40. package/src/tests/acp-transport.spec.ts +153 -0
  41. package/src/tests/acp-utils.spec.ts +394 -0
  42. package/src/tests/fixtures/.claude/settings.local.json +8 -0
  43. package/src/tests/fixtures/.claude/skills/greeting/SKILL.md +17 -0
  44. package/src/tests/fixtures/calculator-mcp.ts +215 -0
  45. package/tsconfig.json +32 -0
@@ -0,0 +1,394 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { ContentBlock, PlanEntry, SessionNotification, ToolCall } from '@agentclientprotocol/sdk'
3
+ import {
4
+ createAudioContent,
5
+ createBlobResource,
6
+ createImageContent,
7
+ createResourceLink,
8
+ createTextContent,
9
+ createTextResource,
10
+ extractLatestToolCalls,
11
+ extractPlan,
12
+ extractText,
13
+ extractTextFromUpdates,
14
+ extractToolCalls,
15
+ filterPlanByStatus,
16
+ filterToolCallsByStatus,
17
+ filterToolCallsByTitle,
18
+ getCompletedToolCallsWithContent,
19
+ getPlanProgress,
20
+ hasToolCallErrors,
21
+ } from '../acp-utils.ts'
22
+
23
+ // ============================================================================
24
+ // Content Block Builders
25
+ // ============================================================================
26
+
27
+ describe('createTextContent', () => {
28
+ test('creates text content block', () => {
29
+ const content = createTextContent('Hello world')
30
+ expect(content.type).toBe('text')
31
+ // Type narrowing to access text property
32
+ if (content.type === 'text') {
33
+ expect(content.text).toBe('Hello world')
34
+ }
35
+ })
36
+ })
37
+
38
+ describe('createImageContent', () => {
39
+ test('creates image content with required fields', () => {
40
+ const content = createImageContent('base64data', 'image/png')
41
+ expect(content.type).toBe('image')
42
+ if (content.type === 'image') {
43
+ expect(content.data).toBe('base64data')
44
+ expect(content.mimeType).toBe('image/png')
45
+ }
46
+ })
47
+ })
48
+
49
+ describe('createAudioContent', () => {
50
+ test('creates audio content block', () => {
51
+ const content = createAudioContent('audiodata', 'audio/wav')
52
+ expect(content.type).toBe('audio')
53
+ if (content.type === 'audio') {
54
+ expect(content.data).toBe('audiodata')
55
+ expect(content.mimeType).toBe('audio/wav')
56
+ }
57
+ })
58
+ })
59
+
60
+ describe('createResourceLink', () => {
61
+ test('creates resource link with uri and name', () => {
62
+ const content = createResourceLink({ uri: 'file:///path/to/file.ts', name: 'file.ts' })
63
+ expect(content.type).toBe('resource_link')
64
+ if (content.type === 'resource_link') {
65
+ expect(content.uri).toBe('file:///path/to/file.ts')
66
+ expect(content.name).toBe('file.ts')
67
+ }
68
+ })
69
+
70
+ test('includes optional mimeType', () => {
71
+ const content = createResourceLink({ uri: 'file:///path/to/file.ts', name: 'file.ts', mimeType: 'text/typescript' })
72
+ if (content.type === 'resource_link') {
73
+ expect(content.mimeType).toBe('text/typescript')
74
+ }
75
+ })
76
+ })
77
+
78
+ describe('createTextResource', () => {
79
+ test('creates embedded text resource', () => {
80
+ const content = createTextResource({ uri: 'file:///src/main.ts', text: 'const x = 1;' })
81
+ expect(content.type).toBe('resource')
82
+ if (content.type === 'resource') {
83
+ expect(content.resource.uri).toBe('file:///src/main.ts')
84
+ expect('text' in content.resource && content.resource.text).toBe('const x = 1;')
85
+ }
86
+ })
87
+
88
+ test('includes optional mimeType', () => {
89
+ const content = createTextResource({
90
+ uri: 'file:///src/main.ts',
91
+ text: 'const x = 1;',
92
+ mimeType: 'text/typescript',
93
+ })
94
+ if (content.type === 'resource' && 'text' in content.resource) {
95
+ expect(content.resource.mimeType).toBe('text/typescript')
96
+ }
97
+ })
98
+ })
99
+
100
+ describe('createBlobResource', () => {
101
+ test('creates embedded blob resource', () => {
102
+ const content = createBlobResource({ uri: 'file:///image.png', blob: 'base64blobdata' })
103
+ expect(content.type).toBe('resource')
104
+ if (content.type === 'resource' && 'blob' in content.resource) {
105
+ expect(content.resource.uri).toBe('file:///image.png')
106
+ expect(content.resource.blob).toBe('base64blobdata')
107
+ }
108
+ })
109
+ })
110
+
111
+ // ============================================================================
112
+ // Content Extraction
113
+ // ============================================================================
114
+
115
+ describe('extractText', () => {
116
+ test('extracts text from text content blocks', () => {
117
+ const content: ContentBlock[] = [
118
+ { type: 'text', text: 'Hello' },
119
+ { type: 'text', text: 'World' },
120
+ ]
121
+ expect(extractText(content)).toBe('Hello\nWorld')
122
+ })
123
+
124
+ test('ignores non-text content blocks', () => {
125
+ const content: ContentBlock[] = [
126
+ { type: 'text', text: 'Hello' },
127
+ { type: 'image', data: 'base64', mimeType: 'image/png' },
128
+ { type: 'text', text: 'World' },
129
+ ]
130
+ expect(extractText(content)).toBe('Hello\nWorld')
131
+ })
132
+
133
+ test('returns empty string for no text blocks', () => {
134
+ const content: ContentBlock[] = [{ type: 'image', data: 'base64', mimeType: 'image/png' }]
135
+ expect(extractText(content)).toBe('')
136
+ })
137
+
138
+ test('handles empty array', () => {
139
+ expect(extractText([])).toBe('')
140
+ })
141
+ })
142
+
143
+ describe('extractTextFromUpdates', () => {
144
+ test('extracts text from agent message chunks', () => {
145
+ const notifications: SessionNotification[] = [
146
+ {
147
+ sessionId: 's1',
148
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'First' } },
149
+ },
150
+ {
151
+ sessionId: 's1',
152
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Second' } },
153
+ },
154
+ ]
155
+ expect(extractTextFromUpdates(notifications)).toBe('FirstSecond')
156
+ })
157
+
158
+ test('skips non-text content updates', () => {
159
+ const notifications: SessionNotification[] = [
160
+ {
161
+ sessionId: 's1',
162
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } },
163
+ },
164
+ {
165
+ sessionId: 's1',
166
+ update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'pending' },
167
+ },
168
+ {
169
+ sessionId: 's1',
170
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'World' } },
171
+ },
172
+ ]
173
+ expect(extractTextFromUpdates(notifications)).toBe('HelloWorld')
174
+ })
175
+ })
176
+
177
+ describe('extractToolCalls', () => {
178
+ test('extracts all tool calls from notifications', () => {
179
+ const notifications: SessionNotification[] = [
180
+ {
181
+ sessionId: 's1',
182
+ update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'completed' },
183
+ },
184
+ {
185
+ sessionId: 's1',
186
+ update: { sessionUpdate: 'tool_call', toolCallId: 't2', title: 'write_file', status: 'in_progress' },
187
+ },
188
+ ]
189
+ const calls = extractToolCalls(notifications)
190
+ expect(calls).toHaveLength(2)
191
+ expect(calls[0]?.title).toBe('read_file')
192
+ expect(calls[1]?.title).toBe('write_file')
193
+ })
194
+
195
+ test('returns empty array when no tool calls', () => {
196
+ const notifications: SessionNotification[] = [
197
+ {
198
+ sessionId: 's1',
199
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } },
200
+ },
201
+ ]
202
+ expect(extractToolCalls(notifications)).toEqual([])
203
+ })
204
+ })
205
+
206
+ describe('extractLatestToolCalls', () => {
207
+ test('returns latest state of each tool call', () => {
208
+ const notifications: SessionNotification[] = [
209
+ {
210
+ sessionId: 's1',
211
+ update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'pending' },
212
+ },
213
+ {
214
+ sessionId: 's1',
215
+ update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'in_progress' },
216
+ },
217
+ {
218
+ sessionId: 's1',
219
+ update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'completed' },
220
+ },
221
+ ]
222
+ const latest = extractLatestToolCalls(notifications)
223
+ expect(latest.size).toBe(1)
224
+ expect(latest.get('t1')?.status).toBe('completed')
225
+ })
226
+
227
+ test('tracks multiple tool calls independently', () => {
228
+ const notifications: SessionNotification[] = [
229
+ {
230
+ sessionId: 's1',
231
+ update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'completed' },
232
+ },
233
+ {
234
+ sessionId: 's1',
235
+ update: { sessionUpdate: 'tool_call', toolCallId: 't2', title: 'write_file', status: 'in_progress' },
236
+ },
237
+ ]
238
+ const latest = extractLatestToolCalls(notifications)
239
+ expect(latest.size).toBe(2)
240
+ expect(latest.get('t1')?.status).toBe('completed')
241
+ expect(latest.get('t2')?.status).toBe('in_progress')
242
+ })
243
+ })
244
+
245
+ describe('extractPlan', () => {
246
+ test('returns latest plan from notifications', () => {
247
+ const notifications: SessionNotification[] = [
248
+ {
249
+ sessionId: 's1',
250
+ update: {
251
+ sessionUpdate: 'plan',
252
+ entries: [{ content: 'Step 1', status: 'pending', priority: 'medium' }],
253
+ },
254
+ },
255
+ {
256
+ sessionId: 's1',
257
+ update: {
258
+ sessionUpdate: 'plan',
259
+ entries: [
260
+ { content: 'Step 1', status: 'completed', priority: 'medium' },
261
+ { content: 'Step 2', status: 'in_progress', priority: 'medium' },
262
+ ],
263
+ },
264
+ },
265
+ ]
266
+ const plan = extractPlan(notifications)
267
+ expect(plan).toHaveLength(2)
268
+ expect(plan?.[0]?.status).toBe('completed')
269
+ })
270
+
271
+ test('returns undefined when no plan in updates', () => {
272
+ const notifications: SessionNotification[] = [
273
+ {
274
+ sessionId: 's1',
275
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hi' } },
276
+ },
277
+ ]
278
+ expect(extractPlan(notifications)).toBeUndefined()
279
+ })
280
+ })
281
+
282
+ // ============================================================================
283
+ // Tool Call Utilities
284
+ // ============================================================================
285
+
286
+ describe('filterToolCallsByStatus', () => {
287
+ const toolCalls: ToolCall[] = [
288
+ { toolCallId: 't1', title: 'a', status: 'completed' },
289
+ { toolCallId: 't2', title: 'b', status: 'failed' },
290
+ { toolCallId: 't3', title: 'c', status: 'completed' },
291
+ ]
292
+
293
+ test('filters by completed status', () => {
294
+ const result = filterToolCallsByStatus(toolCalls, 'completed')
295
+ expect(result).toHaveLength(2)
296
+ expect(result.every((c) => c.status === 'completed')).toBe(true)
297
+ })
298
+
299
+ test('filters by failed status', () => {
300
+ const result = filterToolCallsByStatus(toolCalls, 'failed')
301
+ expect(result).toHaveLength(1)
302
+ expect(result[0]?.title).toBe('b')
303
+ })
304
+ })
305
+
306
+ describe('filterToolCallsByTitle', () => {
307
+ const toolCalls: ToolCall[] = [
308
+ { toolCallId: 't1', title: 'read_file', status: 'completed' },
309
+ { toolCallId: 't2', title: 'write_file', status: 'completed' },
310
+ { toolCallId: 't3', title: 'read_file', status: 'completed' },
311
+ ]
312
+
313
+ test('filters by tool title', () => {
314
+ const result = filterToolCallsByTitle(toolCalls, 'read_file')
315
+ expect(result).toHaveLength(2)
316
+ })
317
+ })
318
+
319
+ describe('hasToolCallErrors', () => {
320
+ test('returns true when failed tool calls exist', () => {
321
+ const toolCalls: ToolCall[] = [
322
+ { toolCallId: 't1', title: 'a', status: 'completed' },
323
+ { toolCallId: 't2', title: 'b', status: 'failed' },
324
+ ]
325
+ expect(hasToolCallErrors(toolCalls)).toBe(true)
326
+ })
327
+
328
+ test('returns false when no failed tool calls', () => {
329
+ const toolCalls: ToolCall[] = [
330
+ { toolCallId: 't1', title: 'a', status: 'completed' },
331
+ { toolCallId: 't2', title: 'b', status: 'completed' },
332
+ ]
333
+ expect(hasToolCallErrors(toolCalls)).toBe(false)
334
+ })
335
+ })
336
+
337
+ describe('getCompletedToolCallsWithContent', () => {
338
+ test('returns completed calls with content', () => {
339
+ const toolCalls: ToolCall[] = [
340
+ {
341
+ toolCallId: 't1',
342
+ title: 'read',
343
+ status: 'completed',
344
+ content: [{ type: 'content', content: { type: 'text', text: 'file content' } }],
345
+ },
346
+ { toolCallId: 't2', title: 'write', status: 'completed' },
347
+ { toolCallId: 't3', title: 'fetch', status: 'in_progress' },
348
+ ]
349
+ const result = getCompletedToolCallsWithContent(toolCalls)
350
+ expect(result).toHaveLength(1)
351
+ expect(result[0]?.title).toBe('read')
352
+ })
353
+ })
354
+
355
+ // ============================================================================
356
+ // Plan Utilities
357
+ // ============================================================================
358
+
359
+ describe('filterPlanByStatus', () => {
360
+ const plan: PlanEntry[] = [
361
+ { content: 'Step 1', status: 'completed', priority: 'high' },
362
+ { content: 'Step 2', status: 'in_progress', priority: 'medium' },
363
+ { content: 'Step 3', status: 'pending', priority: 'low' },
364
+ ]
365
+
366
+ test('filters by status', () => {
367
+ expect(filterPlanByStatus(plan, 'completed')).toHaveLength(1)
368
+ expect(filterPlanByStatus(plan, 'pending')).toHaveLength(1)
369
+ })
370
+ })
371
+
372
+ describe('getPlanProgress', () => {
373
+ test('calculates completion percentage', () => {
374
+ const plan: PlanEntry[] = [
375
+ { content: 'Step 1', status: 'completed', priority: 'high' },
376
+ { content: 'Step 2', status: 'completed', priority: 'high' },
377
+ { content: 'Step 3', status: 'pending', priority: 'medium' },
378
+ { content: 'Step 4', status: 'pending', priority: 'low' },
379
+ ]
380
+ expect(getPlanProgress(plan)).toBe(50)
381
+ })
382
+
383
+ test('returns 100 for empty plan', () => {
384
+ expect(getPlanProgress([])).toBe(100)
385
+ })
386
+
387
+ test('returns 100 for all completed', () => {
388
+ const plan: PlanEntry[] = [
389
+ { content: 'Step 1', status: 'completed', priority: 'high' },
390
+ { content: 'Step 2', status: 'completed', priority: 'medium' },
391
+ ]
392
+ expect(getPlanProgress(plan)).toBe(100)
393
+ })
394
+ })
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Skill(greeting)",
5
+ "Skill(greeting:*)"
6
+ ]
7
+ }
8
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: greeting
3
+ description: Use this skill when asked to greet someone or say hello
4
+ ---
5
+
6
+ # Greeting Skill
7
+
8
+ When the user asks you to greet someone, follow these rules:
9
+
10
+ 1. Always start with "Hello from the greeting skill!"
11
+ 2. Include the phrase "skill-test-marker" somewhere in your response
12
+ 3. Be friendly and welcoming
13
+
14
+ ## Example Responses
15
+
16
+ - "Hello from the greeting skill! skill-test-marker Welcome to our test!"
17
+ - "Hello from the greeting skill! I hope you're having a great day. skill-test-marker"
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Simple calculator MCP server for testing.
4
+ *
5
+ * @remarks
6
+ * A minimal stdio-based MCP server that provides add/subtract/multiply/divide tools.
7
+ * Used to verify ACP client works with MCP servers.
8
+ */
9
+
10
+ type JsonRpcRequest = {
11
+ jsonrpc: '2.0'
12
+ id: string | number
13
+ method: string
14
+ params?: unknown
15
+ }
16
+
17
+ type JsonRpcResponse = {
18
+ jsonrpc: '2.0'
19
+ id: string | number
20
+ result?: unknown
21
+ error?: { code: number; message: string }
22
+ }
23
+
24
+ type Tool = {
25
+ name: string
26
+ description: string
27
+ inputSchema: {
28
+ type: 'object'
29
+ properties: Record<string, { type: string; description: string }>
30
+ required: string[]
31
+ }
32
+ }
33
+
34
+ const tools: Tool[] = [
35
+ {
36
+ name: 'add',
37
+ description: 'Add two numbers',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ a: { type: 'number', description: 'First number' },
42
+ b: { type: 'number', description: 'Second number' },
43
+ },
44
+ required: ['a', 'b'],
45
+ },
46
+ },
47
+ {
48
+ name: 'subtract',
49
+ description: 'Subtract two numbers',
50
+ inputSchema: {
51
+ type: 'object',
52
+ properties: {
53
+ a: { type: 'number', description: 'First number' },
54
+ b: { type: 'number', description: 'Second number' },
55
+ },
56
+ required: ['a', 'b'],
57
+ },
58
+ },
59
+ {
60
+ name: 'multiply',
61
+ description: 'Multiply two numbers',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ a: { type: 'number', description: 'First number' },
66
+ b: { type: 'number', description: 'Second number' },
67
+ },
68
+ required: ['a', 'b'],
69
+ },
70
+ },
71
+ {
72
+ name: 'divide',
73
+ description: 'Divide two numbers',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: {
77
+ a: { type: 'number', description: 'Dividend' },
78
+ b: { type: 'number', description: 'Divisor' },
79
+ },
80
+ required: ['a', 'b'],
81
+ },
82
+ },
83
+ ]
84
+
85
+ const handleRequest = (request: JsonRpcRequest): JsonRpcResponse => {
86
+ const { id, method, params } = request
87
+
88
+ if (method === 'initialize') {
89
+ return {
90
+ jsonrpc: '2.0',
91
+ id,
92
+ result: {
93
+ protocolVersion: '2024-11-05',
94
+ capabilities: { tools: {} },
95
+ serverInfo: { name: 'calculator-mcp', version: '1.0.0' },
96
+ },
97
+ }
98
+ }
99
+
100
+ if (method === 'tools/list') {
101
+ return { jsonrpc: '2.0', id, result: { tools } }
102
+ }
103
+
104
+ if (method === 'tools/call') {
105
+ const { name, arguments: args } = params as { name: string; arguments: { a: number; b: number } }
106
+ let result: number
107
+
108
+ switch (name) {
109
+ case 'add':
110
+ result = args.a + args.b
111
+ break
112
+ case 'subtract':
113
+ result = args.a - args.b
114
+ break
115
+ case 'multiply':
116
+ result = args.a * args.b
117
+ break
118
+ case 'divide':
119
+ if (args.b === 0) {
120
+ return {
121
+ jsonrpc: '2.0',
122
+ id,
123
+ error: { code: -32602, message: 'Division by zero' },
124
+ }
125
+ }
126
+ result = args.a / args.b
127
+ break
128
+ default:
129
+ return {
130
+ jsonrpc: '2.0',
131
+ id,
132
+ error: { code: -32601, message: `Unknown tool: ${name}` },
133
+ }
134
+ }
135
+
136
+ return {
137
+ jsonrpc: '2.0',
138
+ id,
139
+ result: { content: [{ type: 'text', text: String(result) }] },
140
+ }
141
+ }
142
+
143
+ return {
144
+ jsonrpc: '2.0',
145
+ id,
146
+ error: { code: -32601, message: `Unknown method: ${method}` },
147
+ }
148
+ }
149
+
150
+ // MCP stdio transport with Content-Length framing (like LSP)
151
+ const decoder = new TextDecoder()
152
+ const encoder = new TextEncoder()
153
+ let buffer = ''
154
+
155
+ /** Send a JSON-RPC response with Content-Length framing */
156
+ const sendResponse = (response: JsonRpcResponse) => {
157
+ const json = JSON.stringify(response)
158
+ const message = `Content-Length: ${encoder.encode(json).length}\r\n\r\n${json}`
159
+ process.stdout.write(message)
160
+ }
161
+
162
+ /** Parse Content-Length header and extract message */
163
+ const parseMessage = (): JsonRpcRequest | null => {
164
+ // Look for Content-Length header
165
+ const headerEnd = buffer.indexOf('\r\n\r\n')
166
+ if (headerEnd === -1) return null
167
+
168
+ const header = buffer.slice(0, headerEnd)
169
+ const match = header.match(/Content-Length:\s*(\d+)/i)
170
+ if (!match) {
171
+ // Invalid header, skip to next potential header
172
+ buffer = buffer.slice(headerEnd + 4)
173
+ return null
174
+ }
175
+
176
+ // match[1] is guaranteed to be the captured group from the regex
177
+ const contentLength = parseInt(match[1] as string, 10)
178
+ const messageStart = headerEnd + 4
179
+ const messageEnd = messageStart + contentLength
180
+
181
+ // Check if we have the full message
182
+ if (buffer.length < messageEnd) return null
183
+
184
+ const json = buffer.slice(messageStart, messageEnd)
185
+ buffer = buffer.slice(messageEnd)
186
+
187
+ try {
188
+ return JSON.parse(json) as JsonRpcRequest
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ // Read from stdin
195
+ const stdin = Bun.stdin.stream()
196
+ const reader = stdin.getReader()
197
+
198
+ const read = async () => {
199
+ while (true) {
200
+ const { done, value } = await reader.read()
201
+ if (done) break
202
+
203
+ buffer += decoder.decode(value, { stream: true })
204
+
205
+ // Process all complete messages in buffer
206
+ let request = parseMessage()
207
+ while (request !== null) {
208
+ const response = handleRequest(request)
209
+ sendResponse(response)
210
+ request = parseMessage()
211
+ }
212
+ }
213
+ }
214
+
215
+ read()
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "include": ["src", "skills", "bin"],
3
+ "compilerOptions": {
4
+ // Enable latest features
5
+ "lib": ["ESNext"],
6
+ "target": "ESNext",
7
+ "module": "Preserve",
8
+ "moduleDetection": "force",
9
+ "allowJs": true,
10
+
11
+ // IDE may need this to properly show type errors
12
+ "rootDir": ".",
13
+
14
+ // Bundler mode
15
+ "moduleResolution": "bundler",
16
+ "allowImportingTsExtensions": true,
17
+ "verbatimModuleSyntax": true,
18
+ "noEmit": true,
19
+
20
+ // Best practices
21
+ "strict": true,
22
+ "skipLibCheck": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedIndexedAccess": true,
25
+ "noImplicitOverride": true,
26
+
27
+ // Some stricter flags (disabled by default)
28
+ "noUnusedLocals": false,
29
+ "noUnusedParameters": false,
30
+ "noPropertyAccessFromIndexSignature": false
31
+ }
32
+ }