@plaited/acp-harness 0.2.6 → 0.3.1

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 (57) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +120 -16
  3. package/bin/cli.ts +105 -636
  4. package/bin/tests/cli.spec.ts +218 -51
  5. package/package.json +20 -4
  6. package/src/acp-client.ts +5 -4
  7. package/src/acp-transport.ts +14 -7
  8. package/src/adapter-check.ts +542 -0
  9. package/src/adapter-scaffold.ts +934 -0
  10. package/src/balance.ts +232 -0
  11. package/src/calibrate.ts +300 -0
  12. package/src/capture.ts +457 -0
  13. package/src/constants.ts +94 -0
  14. package/src/grader-loader.ts +174 -0
  15. package/src/harness.ts +35 -0
  16. package/src/schemas-cli.ts +239 -0
  17. package/src/schemas.ts +567 -0
  18. package/src/summarize.ts +245 -0
  19. package/src/tests/adapter-check.spec.ts +70 -0
  20. package/src/tests/adapter-scaffold.spec.ts +112 -0
  21. package/src/tests/fixtures/grader-bad-module.ts +5 -0
  22. package/src/tests/fixtures/grader-exec-fail.py +9 -0
  23. package/src/tests/fixtures/grader-exec-invalid.py +6 -0
  24. package/src/tests/fixtures/grader-exec.py +29 -0
  25. package/src/tests/fixtures/grader-module.ts +14 -0
  26. package/src/tests/grader-loader.spec.ts +153 -0
  27. package/src/trials.ts +395 -0
  28. package/src/validate-refs.ts +188 -0
  29. package/.claude/rules/accuracy.md +0 -43
  30. package/.claude/rules/bun-apis.md +0 -80
  31. package/.claude/rules/code-review.md +0 -254
  32. package/.claude/rules/git-workflow.md +0 -37
  33. package/.claude/rules/github.md +0 -154
  34. package/.claude/rules/testing.md +0 -172
  35. package/.claude/skills/acp-harness/SKILL.md +0 -310
  36. package/.claude/skills/acp-harness/assets/Dockerfile.acp +0 -25
  37. package/.claude/skills/acp-harness/assets/docker-compose.acp.yml +0 -19
  38. package/.claude/skills/acp-harness/references/downstream.md +0 -288
  39. package/.claude/skills/acp-harness/references/output-formats.md +0 -221
  40. package/.claude-plugin/marketplace.json +0 -15
  41. package/.claude-plugin/plugin.json +0 -16
  42. package/.github/CODEOWNERS +0 -6
  43. package/.github/workflows/ci.yml +0 -63
  44. package/.github/workflows/publish.yml +0 -146
  45. package/.mcp.json +0 -20
  46. package/CLAUDE.md +0 -92
  47. package/Dockerfile.test +0 -23
  48. package/biome.json +0 -96
  49. package/bun.lock +0 -513
  50. package/docker-compose.test.yml +0 -21
  51. package/scripts/bun-test-wrapper.sh +0 -46
  52. package/src/acp.constants.ts +0 -56
  53. package/src/acp.schemas.ts +0 -161
  54. package/src/acp.types.ts +0 -28
  55. package/src/tests/fixtures/.claude/settings.local.json +0 -8
  56. package/src/tests/fixtures/.claude/skills/greeting/SKILL.md +0 -17
  57. package/tsconfig.json +0 -32
@@ -0,0 +1,934 @@
1
+ /**
2
+ * ACP adapter project scaffolding.
3
+ *
4
+ * @remarks
5
+ * Generates boilerplate for new ACP adapter projects with proper structure,
6
+ * TypeScript configuration, and example handlers.
7
+ *
8
+ * Supports TypeScript and Python adapters.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import { stat } from 'node:fs/promises'
14
+ import { join } from 'node:path'
15
+ import { parseArgs } from 'node:util'
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ /** Configuration for scaffold generation */
22
+ export type ScaffoldConfig = {
23
+ /** Adapter name (used for package name and directory) */
24
+ name: string
25
+ /** Output directory path */
26
+ outputDir: string
27
+ /** Language: 'ts' or 'python' */
28
+ lang: 'ts' | 'python'
29
+ /** Generate minimal boilerplate only */
30
+ minimal: boolean
31
+ }
32
+
33
+ /** Result of scaffold operation */
34
+ export type ScaffoldResult = {
35
+ /** Output directory path */
36
+ outputDir: string
37
+ /** List of created files */
38
+ files: string[]
39
+ /** Language used */
40
+ lang: 'ts' | 'python'
41
+ }
42
+
43
+ // ============================================================================
44
+ // TypeScript Templates
45
+ // ============================================================================
46
+
47
+ const tsPackageJson = (name: string): string => `{
48
+ "name": "${name}-acp",
49
+ "version": "1.0.0",
50
+ "type": "module",
51
+ "bin": {
52
+ "${name}-acp": "./src/index.ts"
53
+ },
54
+ "scripts": {
55
+ "start": "bun run src/index.ts",
56
+ "check": "bunx @plaited/acp-harness adapter:check bun ./src/index.ts"
57
+ },
58
+ "dependencies": {
59
+ "@agentclientprotocol/sdk": "^0.0.1"
60
+ },
61
+ "devDependencies": {
62
+ "@types/bun": "latest",
63
+ "typescript": "^5.0.0"
64
+ }
65
+ }
66
+ `
67
+
68
+ const tsTsConfig = (): string => `{
69
+ "compilerOptions": {
70
+ "target": "ES2022",
71
+ "module": "ESNext",
72
+ "moduleResolution": "bundler",
73
+ "strict": true,
74
+ "esModuleInterop": true,
75
+ "skipLibCheck": true,
76
+ "outDir": "dist",
77
+ "declaration": true
78
+ },
79
+ "include": ["src"]
80
+ }
81
+ `
82
+
83
+ const tsIndexFile = (name: string): string => `#!/usr/bin/env bun
84
+ /**
85
+ * ${name} ACP adapter entry point.
86
+ *
87
+ * This adapter translates between the Agent Client Protocol and
88
+ * your agent's native API.
89
+ */
90
+
91
+ import { createInterface } from 'node:readline'
92
+ import { handleInitialize } from './handlers/initialize.ts'
93
+ import { handleSessionNew, handleSessionLoad } from './handlers/session-new.ts'
94
+ import { handleSessionPrompt } from './handlers/session-prompt.ts'
95
+ import { handleSessionCancel } from './handlers/session-cancel.ts'
96
+ import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.ts'
97
+
98
+ // Method handlers
99
+ const methodHandlers: Record<string, (params: unknown) => Promise<unknown>> = {
100
+ initialize: handleInitialize,
101
+ 'session/new': handleSessionNew,
102
+ 'session/load': handleSessionLoad,
103
+ 'session/prompt': handleSessionPrompt,
104
+ }
105
+
106
+ // Notification handlers (no response expected)
107
+ const notificationHandlers: Record<string, (params: unknown) => Promise<void>> = {
108
+ 'session/cancel': handleSessionCancel,
109
+ }
110
+
111
+ /**
112
+ * Send a JSON-RPC message to stdout.
113
+ */
114
+ export const sendMessage = (message: JsonRpcResponse | JsonRpcNotification): void => {
115
+ // biome-ignore lint/suspicious/noConsole: Protocol output
116
+ console.log(JSON.stringify(message))
117
+ }
118
+
119
+ /**
120
+ * Send a session update notification.
121
+ */
122
+ export const sendSessionUpdate = (sessionId: string, update: unknown): void => {
123
+ sendMessage({
124
+ jsonrpc: '2.0',
125
+ method: 'session/update',
126
+ params: { sessionId, update },
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Process incoming JSON-RPC message.
132
+ */
133
+ const processMessage = async (line: string): Promise<void> => {
134
+ let request: JsonRpcRequest | JsonRpcNotification
135
+
136
+ try {
137
+ request = JSON.parse(line)
138
+ } catch {
139
+ sendMessage({
140
+ jsonrpc: '2.0',
141
+ id: null,
142
+ error: { code: -32700, message: 'Parse error' },
143
+ })
144
+ return
145
+ }
146
+
147
+ // Check if it's a notification (no id)
148
+ const isNotification = !('id' in request)
149
+
150
+ if (isNotification) {
151
+ const handler = notificationHandlers[request.method]
152
+ if (handler) {
153
+ await handler(request.params)
154
+ }
155
+ // No response for notifications
156
+ return
157
+ }
158
+
159
+ // It's a request - send response
160
+ const reqWithId = request as JsonRpcRequest
161
+ const handler = methodHandlers[reqWithId.method]
162
+
163
+ if (!handler) {
164
+ sendMessage({
165
+ jsonrpc: '2.0',
166
+ id: reqWithId.id,
167
+ error: { code: -32601, message: \`Method not found: \${reqWithId.method}\` },
168
+ })
169
+ return
170
+ }
171
+
172
+ try {
173
+ const result = await handler(reqWithId.params)
174
+ sendMessage({
175
+ jsonrpc: '2.0',
176
+ id: reqWithId.id,
177
+ result,
178
+ })
179
+ } catch (error) {
180
+ sendMessage({
181
+ jsonrpc: '2.0',
182
+ id: reqWithId.id,
183
+ error: {
184
+ code: -32603,
185
+ message: error instanceof Error ? error.message : 'Internal error',
186
+ },
187
+ })
188
+ }
189
+ }
190
+
191
+ // Main loop: read lines from stdin
192
+ const rl = createInterface({
193
+ input: process.stdin,
194
+ output: process.stdout,
195
+ terminal: false,
196
+ })
197
+
198
+ rl.on('line', processMessage)
199
+
200
+ // Handle clean shutdown
201
+ process.on('SIGTERM', () => {
202
+ rl.close()
203
+ process.exit(0)
204
+ })
205
+ `
206
+
207
+ const tsTypesFile = (): string => `/**
208
+ * TypeScript types for JSON-RPC 2.0 protocol.
209
+ */
210
+
211
+ export type JsonRpcRequest = {
212
+ jsonrpc: '2.0'
213
+ id: string | number
214
+ method: string
215
+ params?: unknown
216
+ }
217
+
218
+ export type JsonRpcNotification = {
219
+ jsonrpc: '2.0'
220
+ method: string
221
+ params?: unknown
222
+ }
223
+
224
+ export type JsonRpcSuccessResponse = {
225
+ jsonrpc: '2.0'
226
+ id: string | number
227
+ result: unknown
228
+ }
229
+
230
+ export type JsonRpcErrorResponse = {
231
+ jsonrpc: '2.0'
232
+ id: string | number | null
233
+ error: {
234
+ code: number
235
+ message: string
236
+ data?: unknown
237
+ }
238
+ }
239
+
240
+ export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse
241
+
242
+ export type ContentBlock =
243
+ | { type: 'text'; text: string }
244
+ | { type: 'image'; source: { type: 'base64'; mediaType: string; data: string } }
245
+ `
246
+
247
+ const tsInitializeHandler = (name: string): string => `/**
248
+ * Initialize handler - protocol handshake.
249
+ */
250
+
251
+ type InitializeParams = {
252
+ protocolVersion: number
253
+ clientInfo: { name: string; version: string }
254
+ clientCapabilities: Record<string, unknown>
255
+ }
256
+
257
+ type InitializeResult = {
258
+ protocolVersion: number
259
+ agentInfo: { name: string; version: string }
260
+ agentCapabilities: {
261
+ loadSession?: boolean
262
+ promptCapabilities?: {
263
+ image?: boolean
264
+ }
265
+ }
266
+ }
267
+
268
+ export const handleInitialize = async (params: unknown): Promise<InitializeResult> => {
269
+ const { protocolVersion } = params as InitializeParams
270
+
271
+ if (protocolVersion !== 1) {
272
+ throw new Error(\`Unsupported protocol version: \${protocolVersion}\`)
273
+ }
274
+
275
+ return {
276
+ protocolVersion: 1,
277
+ agentInfo: {
278
+ name: '${name}',
279
+ version: '1.0.0',
280
+ },
281
+ agentCapabilities: {
282
+ loadSession: false,
283
+ promptCapabilities: {
284
+ image: false,
285
+ },
286
+ },
287
+ }
288
+ }
289
+ `
290
+
291
+ const tsSessionNewHandler = (): string => `/**
292
+ * Session handlers - create and load sessions.
293
+ */
294
+
295
+ import { sessionManager } from '../session-manager.ts'
296
+
297
+ type SessionNewParams = {
298
+ cwd: string
299
+ mcpServers?: unknown[]
300
+ }
301
+
302
+ type SessionNewResult = {
303
+ sessionId: string
304
+ }
305
+
306
+ export const handleSessionNew = async (params: unknown): Promise<SessionNewResult> => {
307
+ const { cwd, mcpServers = [] } = params as SessionNewParams
308
+
309
+ const sessionId = sessionManager.createSession({
310
+ cwd,
311
+ mcpServers,
312
+ })
313
+
314
+ return { sessionId }
315
+ }
316
+
317
+ type SessionLoadParams = {
318
+ sessionId: string
319
+ }
320
+
321
+ export const handleSessionLoad = async (params: unknown): Promise<SessionNewResult> => {
322
+ const { sessionId } = params as SessionLoadParams
323
+
324
+ const session = sessionManager.getSession(sessionId)
325
+ if (!session) {
326
+ throw new Error(\`Session not found: \${sessionId}\`)
327
+ }
328
+
329
+ return { sessionId }
330
+ }
331
+ `
332
+
333
+ const tsSessionPromptHandler = (): string => `/**
334
+ * Session prompt handler - process prompts and emit updates.
335
+ */
336
+
337
+ import { sessionManager } from '../session-manager.ts'
338
+ import { sendSessionUpdate } from '../index.ts'
339
+ import type { ContentBlock } from '../types.ts'
340
+
341
+ type PromptParams = {
342
+ sessionId: string
343
+ prompt: ContentBlock[]
344
+ }
345
+
346
+ type PromptResult = {
347
+ content: ContentBlock[]
348
+ }
349
+
350
+ export const handleSessionPrompt = async (params: unknown): Promise<PromptResult> => {
351
+ const { sessionId, prompt } = params as PromptParams
352
+
353
+ const session = sessionManager.getSession(sessionId)
354
+ if (!session) {
355
+ throw new Error(\`Session not found: \${sessionId}\`)
356
+ }
357
+
358
+ // Extract text from content blocks
359
+ const promptText = prompt
360
+ .filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text')
361
+ .map((block) => block.text)
362
+ .join('\\n')
363
+
364
+ // Emit thinking update
365
+ sendSessionUpdate(sessionId, {
366
+ sessionUpdate: 'agent_thought_chunk',
367
+ content: { type: 'text', text: 'Processing your request...' },
368
+ })
369
+
370
+ // TODO: Replace with your agent's actual API call
371
+ const response = await processWithYourAgent(promptText, session.cwd)
372
+
373
+ // Emit message update
374
+ sendSessionUpdate(sessionId, {
375
+ sessionUpdate: 'agent_message_chunk',
376
+ content: { type: 'text', text: response },
377
+ })
378
+
379
+ return {
380
+ content: [{ type: 'text', text: response }],
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Replace this with your actual agent API call.
386
+ */
387
+ const processWithYourAgent = async (prompt: string, _cwd: string): Promise<string> => {
388
+ // Example echo implementation - replace with real agent call
389
+ return \`Echo: \${prompt}\`
390
+ }
391
+ `
392
+
393
+ const tsSessionCancelHandler = (): string => `/**
394
+ * Session cancel handler - cancel ongoing prompts.
395
+ */
396
+
397
+ type CancelParams = {
398
+ sessionId: string
399
+ }
400
+
401
+ // Track active requests for cancellation
402
+ const activeRequests = new Map<string, AbortController>()
403
+
404
+ export const handleSessionCancel = async (params: unknown): Promise<void> => {
405
+ const { sessionId } = params as CancelParams
406
+
407
+ const controller = activeRequests.get(sessionId)
408
+ if (controller) {
409
+ controller.abort()
410
+ activeRequests.delete(sessionId)
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Register an active request for cancellation support.
416
+ */
417
+ export const registerActiveRequest = (
418
+ sessionId: string,
419
+ controller: AbortController
420
+ ): void => {
421
+ activeRequests.set(sessionId, controller)
422
+ }
423
+
424
+ /**
425
+ * Unregister an active request after completion.
426
+ */
427
+ export const unregisterActiveRequest = (sessionId: string): void => {
428
+ activeRequests.delete(sessionId)
429
+ }
430
+ `
431
+
432
+ const tsSessionManager = (): string => `/**
433
+ * Session manager - tracks active conversation sessions.
434
+ */
435
+
436
+ import { randomUUID } from 'node:crypto'
437
+
438
+ type Session = {
439
+ id: string
440
+ cwd: string
441
+ mcpServers: unknown[]
442
+ createdAt: Date
443
+ }
444
+
445
+ class SessionManager {
446
+ #sessions = new Map<string, Session>()
447
+
448
+ createSession(params: { cwd: string; mcpServers: unknown[] }): string {
449
+ const id = \`sess_\${randomUUID().slice(0, 8)}\`
450
+ this.#sessions.set(id, {
451
+ id,
452
+ cwd: params.cwd,
453
+ mcpServers: params.mcpServers,
454
+ createdAt: new Date(),
455
+ })
456
+ return id
457
+ }
458
+
459
+ getSession(id: string): Session | undefined {
460
+ return this.#sessions.get(id)
461
+ }
462
+
463
+ deleteSession(id: string): boolean {
464
+ return this.#sessions.delete(id)
465
+ }
466
+
467
+ listSessions(): Session[] {
468
+ return Array.from(this.#sessions.values())
469
+ }
470
+ }
471
+
472
+ export const sessionManager = new SessionManager()
473
+ `
474
+
475
+ const tsReadme = (name: string): string => `# ${name} ACP Adapter
476
+
477
+ ACP (Agent Client Protocol) adapter for ${name}.
478
+
479
+ ## Quick Start
480
+
481
+ \`\`\`bash
482
+ # Install dependencies
483
+ bun install
484
+
485
+ # Run the adapter
486
+ bun run start
487
+
488
+ # Or run directly
489
+ bun run src/index.ts
490
+ \`\`\`
491
+
492
+ ## Verify Compliance
493
+
494
+ \`\`\`bash
495
+ # Run compliance checker
496
+ bun run check
497
+
498
+ # Or manually
499
+ bunx @plaited/acp-harness adapter:check bun ./src/index.ts
500
+ \`\`\`
501
+
502
+ ## Test with Harness
503
+
504
+ \`\`\`bash
505
+ # Create test prompts
506
+ echo '{"id":"test-1","input":"Hello"}' > prompts.jsonl
507
+
508
+ # Run capture
509
+ bunx @plaited/acp-harness capture prompts.jsonl bun ./src/index.ts -o results.jsonl
510
+
511
+ # View results
512
+ cat results.jsonl | jq .
513
+ \`\`\`
514
+
515
+ ## Implementation
516
+
517
+ Replace the placeholder in \`src/handlers/session-prompt.ts\`:
518
+
519
+ \`\`\`typescript
520
+ const processWithYourAgent = async (prompt: string, cwd: string): Promise<string> => {
521
+ // Call your agent's API here
522
+ const response = await yourAgentClient.chat(prompt)
523
+ return response.text
524
+ }
525
+ \`\`\`
526
+
527
+ ## Protocol Reference
528
+
529
+ See the [ACP Specification](https://agentclientprotocol.org) for protocol details.
530
+ `
531
+
532
+ // ============================================================================
533
+ // Python Templates
534
+ // ============================================================================
535
+
536
+ const pythonAdapter = (name: string): string => `#!/usr/bin/env python3
537
+ """
538
+ ${name} ACP adapter.
539
+
540
+ ACP (Agent Client Protocol) adapter for ${name}.
541
+ Translates between JSON-RPC 2.0 and your agent's native API.
542
+ """
543
+
544
+ import json
545
+ import sys
546
+ import uuid
547
+ from typing import Any, Dict, Optional
548
+
549
+ # Session storage
550
+ sessions: Dict[str, Dict[str, Any]] = {}
551
+
552
+
553
+ def create_session(cwd: str, mcp_servers: list) -> str:
554
+ """Create a new session."""
555
+ session_id = f"sess_{uuid.uuid4().hex[:8]}"
556
+ sessions[session_id] = {
557
+ "id": session_id,
558
+ "cwd": cwd,
559
+ "mcp_servers": mcp_servers,
560
+ }
561
+ return session_id
562
+
563
+
564
+ def get_session(session_id: str) -> Optional[Dict[str, Any]]:
565
+ """Get session by ID."""
566
+ return sessions.get(session_id)
567
+
568
+
569
+ def send_message(message: Dict[str, Any]) -> None:
570
+ """Send JSON-RPC message to stdout."""
571
+ print(json.dumps(message), flush=True)
572
+
573
+
574
+ def send_session_update(session_id: str, update: Dict[str, Any]) -> None:
575
+ """Send session update notification."""
576
+ send_message({
577
+ "jsonrpc": "2.0",
578
+ "method": "session/update",
579
+ "params": {"sessionId": session_id, "update": update},
580
+ })
581
+
582
+
583
+ def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
584
+ """Handle initialize request."""
585
+ protocol_version = params.get("protocolVersion", 0)
586
+ if protocol_version != 1:
587
+ raise ValueError(f"Unsupported protocol version: {protocol_version}")
588
+
589
+ return {
590
+ "protocolVersion": 1,
591
+ "agentInfo": {"name": "${name}", "version": "1.0.0"},
592
+ "agentCapabilities": {
593
+ "loadSession": False,
594
+ "promptCapabilities": {"image": False},
595
+ },
596
+ }
597
+
598
+
599
+ def handle_session_new(params: Dict[str, Any]) -> Dict[str, Any]:
600
+ """Handle session/new request."""
601
+ cwd = params.get("cwd", ".")
602
+ mcp_servers = params.get("mcpServers", [])
603
+ session_id = create_session(cwd, mcp_servers)
604
+ return {"sessionId": session_id}
605
+
606
+
607
+ def handle_session_prompt(params: Dict[str, Any]) -> Dict[str, Any]:
608
+ """Handle session/prompt request."""
609
+ session_id = params["sessionId"]
610
+ session = get_session(session_id)
611
+ if not session:
612
+ raise ValueError(f"Session not found: {session_id}")
613
+
614
+ # Extract text from prompt blocks
615
+ prompt_text = " ".join(
616
+ block["text"]
617
+ for block in params.get("prompt", [])
618
+ if block.get("type") == "text"
619
+ )
620
+
621
+ # Send thinking update
622
+ send_session_update(session_id, {
623
+ "sessionUpdate": "agent_thought_chunk",
624
+ "content": {"type": "text", "text": "Processing your request..."},
625
+ })
626
+
627
+ # TODO: Replace with your agent's actual API call
628
+ response = process_with_your_agent(prompt_text, session["cwd"])
629
+
630
+ # Send message update
631
+ send_session_update(session_id, {
632
+ "sessionUpdate": "agent_message_chunk",
633
+ "content": {"type": "text", "text": response},
634
+ })
635
+
636
+ return {"content": [{"type": "text", "text": response}]}
637
+
638
+
639
+ def process_with_your_agent(prompt: str, cwd: str) -> str:
640
+ """Replace with your actual agent API call."""
641
+ return f"Echo: {prompt}"
642
+
643
+
644
+ # Method handlers
645
+ METHOD_HANDLERS = {
646
+ "initialize": handle_initialize,
647
+ "session/new": handle_session_new,
648
+ "session/prompt": handle_session_prompt,
649
+ }
650
+
651
+
652
+ def process_message(line: str) -> None:
653
+ """Process incoming JSON-RPC message."""
654
+ try:
655
+ request = json.loads(line)
656
+ except json.JSONDecodeError:
657
+ send_message({
658
+ "jsonrpc": "2.0",
659
+ "id": None,
660
+ "error": {"code": -32700, "message": "Parse error"},
661
+ })
662
+ return
663
+
664
+ # Check if notification (no id)
665
+ if "id" not in request:
666
+ # Handle notification silently
667
+ return
668
+
669
+ method = request.get("method", "")
670
+ handler = METHOD_HANDLERS.get(method)
671
+
672
+ if not handler:
673
+ send_message({
674
+ "jsonrpc": "2.0",
675
+ "id": request["id"],
676
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
677
+ })
678
+ return
679
+
680
+ try:
681
+ result = handler(request.get("params", {}))
682
+ send_message({
683
+ "jsonrpc": "2.0",
684
+ "id": request["id"],
685
+ "result": result,
686
+ })
687
+ except Exception as e:
688
+ send_message({
689
+ "jsonrpc": "2.0",
690
+ "id": request["id"],
691
+ "error": {"code": -32603, "message": str(e)},
692
+ })
693
+
694
+
695
+ def main() -> None:
696
+ """Main loop: read lines from stdin."""
697
+ for line in sys.stdin:
698
+ line = line.strip()
699
+ if line:
700
+ process_message(line)
701
+
702
+
703
+ if __name__ == "__main__":
704
+ main()
705
+ `
706
+
707
+ const pythonReadme = (name: string): string => `# ${name} ACP Adapter
708
+
709
+ ACP (Agent Client Protocol) adapter for ${name} (Python).
710
+
711
+ ## Quick Start
712
+
713
+ \`\`\`bash
714
+ # Make executable
715
+ chmod +x adapter.py
716
+
717
+ # Run the adapter
718
+ python adapter.py
719
+ \`\`\`
720
+
721
+ ## Verify Compliance
722
+
723
+ \`\`\`bash
724
+ bunx @plaited/acp-harness adapter:check python ./adapter.py
725
+ \`\`\`
726
+
727
+ ## Test with Harness
728
+
729
+ \`\`\`bash
730
+ # Create test prompts
731
+ echo '{"id":"test-1","input":"Hello"}' > prompts.jsonl
732
+
733
+ # Run capture
734
+ bunx @plaited/acp-harness capture prompts.jsonl python ./adapter.py -o results.jsonl
735
+
736
+ # View results
737
+ cat results.jsonl | jq .
738
+ \`\`\`
739
+
740
+ ## Implementation
741
+
742
+ Replace the placeholder in \`adapter.py\`:
743
+
744
+ \`\`\`python
745
+ def process_with_your_agent(prompt: str, cwd: str) -> str:
746
+ # Call your agent's API here
747
+ response = your_agent_client.chat(prompt)
748
+ return response.text
749
+ \`\`\`
750
+
751
+ ## Protocol Reference
752
+
753
+ See the [ACP Specification](https://agentclientprotocol.org) for protocol details.
754
+ `
755
+
756
+ // ============================================================================
757
+ // Scaffold Implementation
758
+ // ============================================================================
759
+
760
+ /**
761
+ * Generate TypeScript adapter project.
762
+ */
763
+ const scaffoldTypeScript = async (config: ScaffoldConfig): Promise<string[]> => {
764
+ const { name, outputDir, minimal } = config
765
+ const files: string[] = []
766
+
767
+ // Create directories
768
+ await Bun.write(join(outputDir, 'src', 'handlers', '.gitkeep'), '')
769
+
770
+ // Core files
771
+ await Bun.write(join(outputDir, 'package.json'), tsPackageJson(name))
772
+ files.push('package.json')
773
+
774
+ await Bun.write(join(outputDir, 'tsconfig.json'), tsTsConfig())
775
+ files.push('tsconfig.json')
776
+
777
+ await Bun.write(join(outputDir, 'src', 'index.ts'), tsIndexFile(name))
778
+ files.push('src/index.ts')
779
+
780
+ await Bun.write(join(outputDir, 'src', 'types.ts'), tsTypesFile())
781
+ files.push('src/types.ts')
782
+
783
+ await Bun.write(join(outputDir, 'src', 'session-manager.ts'), tsSessionManager())
784
+ files.push('src/session-manager.ts')
785
+
786
+ // Handler files
787
+ await Bun.write(join(outputDir, 'src', 'handlers', 'initialize.ts'), tsInitializeHandler(name))
788
+ files.push('src/handlers/initialize.ts')
789
+
790
+ await Bun.write(join(outputDir, 'src', 'handlers', 'session-new.ts'), tsSessionNewHandler())
791
+ files.push('src/handlers/session-new.ts')
792
+
793
+ await Bun.write(join(outputDir, 'src', 'handlers', 'session-prompt.ts'), tsSessionPromptHandler())
794
+ files.push('src/handlers/session-prompt.ts')
795
+
796
+ await Bun.write(join(outputDir, 'src', 'handlers', 'session-cancel.ts'), tsSessionCancelHandler())
797
+ files.push('src/handlers/session-cancel.ts')
798
+
799
+ // README (unless minimal)
800
+ if (!minimal) {
801
+ await Bun.write(join(outputDir, 'README.md'), tsReadme(name))
802
+ files.push('README.md')
803
+ }
804
+
805
+ return files
806
+ }
807
+
808
+ /**
809
+ * Generate Python adapter project.
810
+ */
811
+ const scaffoldPython = async (config: ScaffoldConfig): Promise<string[]> => {
812
+ const { name, outputDir, minimal } = config
813
+ const files: string[] = []
814
+
815
+ await Bun.write(join(outputDir, 'adapter.py'), pythonAdapter(name))
816
+ files.push('adapter.py')
817
+
818
+ if (!minimal) {
819
+ await Bun.write(join(outputDir, 'README.md'), pythonReadme(name))
820
+ files.push('README.md')
821
+ }
822
+
823
+ return files
824
+ }
825
+
826
+ /**
827
+ * Run adapter scaffolding with configuration object.
828
+ *
829
+ * @param config - Scaffold configuration
830
+ * @returns Scaffold result with created files
831
+ */
832
+ export const runScaffold = async (config: ScaffoldConfig): Promise<ScaffoldResult> => {
833
+ const { outputDir, lang } = config
834
+
835
+ // Create output directory
836
+ await Bun.write(join(outputDir, '.gitkeep'), '')
837
+
838
+ const files = lang === 'python' ? await scaffoldPython(config) : await scaffoldTypeScript(config)
839
+
840
+ return {
841
+ outputDir,
842
+ files,
843
+ lang,
844
+ }
845
+ }
846
+
847
+ // ============================================================================
848
+ // CLI Entry Point
849
+ // ============================================================================
850
+
851
+ /**
852
+ * Adapter scaffold command CLI handler.
853
+ *
854
+ * @param args - Command line arguments (after 'adapter:scaffold')
855
+ */
856
+ export const adapterScaffold = async (args: string[]): Promise<void> => {
857
+ const { values, positionals } = parseArgs({
858
+ args,
859
+ options: {
860
+ output: { type: 'string', short: 'o' },
861
+ lang: { type: 'string', default: 'ts' },
862
+ minimal: { type: 'boolean', default: false },
863
+ help: { type: 'boolean', short: 'h' },
864
+ },
865
+ allowPositionals: true,
866
+ })
867
+
868
+ if (values.help) {
869
+ // biome-ignore lint/suspicious/noConsole: CLI help output
870
+ console.log(`
871
+ Usage: acp-harness adapter:scaffold [name] [options]
872
+
873
+ Arguments:
874
+ name Adapter name (used for package name)
875
+
876
+ Options:
877
+ -o, --output Output directory (default: ./<name>-acp)
878
+ --lang Language: ts or python (default: ts)
879
+ --minimal Generate minimal boilerplate only
880
+ -h, --help Show this help message
881
+
882
+ Examples:
883
+ # Scaffold TypeScript adapter
884
+ acp-harness adapter:scaffold my-agent
885
+
886
+ # Scaffold Python adapter
887
+ acp-harness adapter:scaffold my-agent --lang python
888
+
889
+ # Scaffold to specific directory
890
+ acp-harness adapter:scaffold my-agent -o ./adapters/my-agent
891
+ `)
892
+ return
893
+ }
894
+
895
+ const name = positionals[0]
896
+ if (!name) {
897
+ console.error('Error: adapter name is required')
898
+ console.error('Example: acp-harness adapter:scaffold my-agent')
899
+ process.exit(1)
900
+ }
901
+
902
+ const lang = values.lang === 'python' ? 'python' : 'ts'
903
+ const outputDir = values.output ?? `./${name}-acp`
904
+
905
+ // Check if directory already exists
906
+ const dirExists = await stat(outputDir).catch(() => null)
907
+ if (dirExists) {
908
+ console.error(`Error: directory already exists: ${outputDir}`)
909
+ process.exit(1)
910
+ }
911
+
912
+ const result = await runScaffold({
913
+ name,
914
+ outputDir,
915
+ lang,
916
+ minimal: values.minimal ?? false,
917
+ })
918
+
919
+ // biome-ignore lint/suspicious/noConsole: CLI output
920
+ console.log(`
921
+ Scaffolded ${result.lang === 'ts' ? 'TypeScript' : 'Python'} adapter: ${name}
922
+
923
+ Created files:
924
+ ${result.files.map((f) => ` ${result.outputDir}/${f}`).join('\n')}
925
+
926
+ Next steps:
927
+ cd ${result.outputDir}
928
+ ${result.lang === 'ts' ? ' bun install' : ' chmod +x adapter.py'}
929
+ ${result.lang === 'ts' ? ' bun run start' : ' python adapter.py'}
930
+
931
+ Verify compliance:
932
+ acp-harness adapter:check ${result.lang === 'ts' ? 'bun ./src/index.ts' : 'python ./adapter.py'}
933
+ `)
934
+ }