claude-code-acp 0.3.1__py3-none-any.whl → 0.3.4__py3-none-any.whl
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.
- claude_code_acp/__init__.py +1 -1
- claude_code_acp/acp_client.py +336 -22
- claude_code_acp/agent.py +71 -2
- {claude_code_acp-0.3.1.dist-info → claude_code_acp-0.3.4.dist-info}/METADATA +81 -40
- claude_code_acp-0.3.4.dist-info/RECORD +10 -0
- claude_code_acp-0.3.1.dist-info/RECORD +0 -10
- {claude_code_acp-0.3.1.dist-info → claude_code_acp-0.3.4.dist-info}/WHEEL +0 -0
- {claude_code_acp-0.3.1.dist-info → claude_code_acp-0.3.4.dist-info}/entry_points.txt +0 -0
- {claude_code_acp-0.3.1.dist-info → claude_code_acp-0.3.4.dist-info}/licenses/LICENSE +0 -0
claude_code_acp/__init__.py
CHANGED
claude_code_acp/acp_client.py
CHANGED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import logging
|
|
12
12
|
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
13
14
|
from typing import Any, Callable, Coroutine
|
|
14
15
|
|
|
15
16
|
from acp.client.connection import ClientSideConnection
|
|
@@ -30,6 +31,17 @@ logger = logging.getLogger(__name__)
|
|
|
30
31
|
__all__ = ["AcpClient", "AcpClientEvents"]
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
@dataclass
|
|
35
|
+
class TerminalProcess:
|
|
36
|
+
"""Represents an active terminal process."""
|
|
37
|
+
|
|
38
|
+
process: asyncio.subprocess.Process
|
|
39
|
+
command: str
|
|
40
|
+
cwd: str
|
|
41
|
+
output_buffer: list[str]
|
|
42
|
+
exit_code: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
33
45
|
@dataclass
|
|
34
46
|
class AcpClientEvents:
|
|
35
47
|
"""Event handlers for ACP client."""
|
|
@@ -41,6 +53,12 @@ class AcpClientEvents:
|
|
|
41
53
|
on_permission: Callable[[str, dict, list], Coroutine[Any, Any, str]] | None = None
|
|
42
54
|
on_error: Callable[[Exception], Coroutine[Any, Any, None]] | None = None
|
|
43
55
|
on_complete: Callable[[], Coroutine[Any, Any, None]] | None = None
|
|
56
|
+
# File operation handlers (optional - if not set, operations proceed automatically)
|
|
57
|
+
on_file_read: Callable[[str], Coroutine[Any, Any, str | None]] | None = None
|
|
58
|
+
on_file_write: Callable[[str, str], Coroutine[Any, Any, bool]] | None = None
|
|
59
|
+
# Terminal operation handlers (optional)
|
|
60
|
+
on_terminal_create: Callable[[str, str], Coroutine[Any, Any, bool]] | None = None
|
|
61
|
+
on_terminal_output: Callable[[str, str], Coroutine[Any, Any, None]] | None = None
|
|
44
62
|
|
|
45
63
|
|
|
46
64
|
class AcpClient:
|
|
@@ -94,6 +112,9 @@ class AcpClient:
|
|
|
94
112
|
self._session_id: str | None = None
|
|
95
113
|
self._text_buffer = ""
|
|
96
114
|
self._initialized = False
|
|
115
|
+
# Terminal management
|
|
116
|
+
self._terminals: dict[str, TerminalProcess] = {}
|
|
117
|
+
self._terminal_counter = 0
|
|
97
118
|
|
|
98
119
|
# --- Event decorators ---
|
|
99
120
|
|
|
@@ -137,6 +158,55 @@ class AcpClient:
|
|
|
137
158
|
self.events.on_complete = func
|
|
138
159
|
return func
|
|
139
160
|
|
|
161
|
+
def on_file_read(self, func: Callable[[str], Coroutine[Any, Any, str | None]]):
|
|
162
|
+
"""
|
|
163
|
+
Register handler for file read operations.
|
|
164
|
+
|
|
165
|
+
The handler receives (path) and can return:
|
|
166
|
+
- str: Override the file content with this value
|
|
167
|
+
- None: Proceed with normal file reading
|
|
168
|
+
|
|
169
|
+
This allows intercepting file reads for security or custom handling.
|
|
170
|
+
"""
|
|
171
|
+
self.events.on_file_read = func
|
|
172
|
+
return func
|
|
173
|
+
|
|
174
|
+
def on_file_write(self, func: Callable[[str, str], Coroutine[Any, Any, bool]]):
|
|
175
|
+
"""
|
|
176
|
+
Register handler for file write operations.
|
|
177
|
+
|
|
178
|
+
The handler receives (path, content) and should return:
|
|
179
|
+
- True: Allow the write to proceed
|
|
180
|
+
- False: Block the write
|
|
181
|
+
|
|
182
|
+
This allows intercepting file writes for security or confirmation prompts.
|
|
183
|
+
"""
|
|
184
|
+
self.events.on_file_write = func
|
|
185
|
+
return func
|
|
186
|
+
|
|
187
|
+
def on_terminal_create(self, func: Callable[[str, str], Coroutine[Any, Any, bool]]):
|
|
188
|
+
"""
|
|
189
|
+
Register handler for terminal creation requests.
|
|
190
|
+
|
|
191
|
+
The handler receives (command, cwd) and should return:
|
|
192
|
+
- True: Allow the terminal to be created
|
|
193
|
+
- False: Block the terminal creation
|
|
194
|
+
|
|
195
|
+
This allows intercepting shell command execution for security.
|
|
196
|
+
"""
|
|
197
|
+
self.events.on_terminal_create = func
|
|
198
|
+
return func
|
|
199
|
+
|
|
200
|
+
def on_terminal_output(self, func: Callable[[str, str], Coroutine[Any, Any, None]]):
|
|
201
|
+
"""
|
|
202
|
+
Register handler for terminal output.
|
|
203
|
+
|
|
204
|
+
The handler receives (terminal_id, output) when new output is available.
|
|
205
|
+
This allows displaying or logging terminal output in real-time.
|
|
206
|
+
"""
|
|
207
|
+
self.events.on_terminal_output = func
|
|
208
|
+
return func
|
|
209
|
+
|
|
140
210
|
# --- Connection management ---
|
|
141
211
|
|
|
142
212
|
async def connect(self) -> None:
|
|
@@ -179,6 +249,15 @@ class AcpClient:
|
|
|
179
249
|
|
|
180
250
|
async def disconnect(self) -> None:
|
|
181
251
|
"""Disconnect from the ACP agent."""
|
|
252
|
+
# Clean up all terminals
|
|
253
|
+
for terminal_id, terminal in list(self._terminals.items()):
|
|
254
|
+
try:
|
|
255
|
+
terminal.process.kill()
|
|
256
|
+
await terminal.process.wait()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
self._terminals.clear()
|
|
260
|
+
|
|
182
261
|
if self._connection:
|
|
183
262
|
await self._connection.close()
|
|
184
263
|
self._connection = None
|
|
@@ -331,33 +410,268 @@ class AcpClient:
|
|
|
331
410
|
outcome={"outcome": "selected", "option_id": selected_id}
|
|
332
411
|
)
|
|
333
412
|
|
|
334
|
-
async def write_text_file(
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
413
|
+
async def write_text_file(
|
|
414
|
+
self,
|
|
415
|
+
path: str,
|
|
416
|
+
content: str,
|
|
417
|
+
**kwargs,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""
|
|
420
|
+
Handle write file requests from the agent.
|
|
421
|
+
|
|
422
|
+
The agent requests the client to write a file to disk.
|
|
423
|
+
This enables the agent to create/modify files in the user's filesystem.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
path: The file path to write to.
|
|
427
|
+
content: The content to write.
|
|
428
|
+
"""
|
|
429
|
+
# Check if handler wants to intercept/block the write
|
|
430
|
+
if client.events.on_file_write:
|
|
431
|
+
allowed = await client.events.on_file_write(path, content)
|
|
432
|
+
if not allowed:
|
|
433
|
+
logger.info(f"File write blocked by handler: {path}")
|
|
434
|
+
return
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
file_path = Path(path)
|
|
438
|
+
# Create parent directories if they don't exist
|
|
439
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
# Write the file
|
|
441
|
+
file_path.write_text(content, encoding="utf-8")
|
|
442
|
+
logger.debug(f"Wrote file: {path}")
|
|
443
|
+
except Exception as e:
|
|
444
|
+
logger.error(f"Failed to write file {path}: {e}")
|
|
445
|
+
raise
|
|
446
|
+
|
|
447
|
+
async def read_text_file(
|
|
448
|
+
self,
|
|
449
|
+
path: str,
|
|
450
|
+
**kwargs,
|
|
451
|
+
) -> dict:
|
|
452
|
+
"""
|
|
453
|
+
Handle read file requests from the agent.
|
|
454
|
+
|
|
455
|
+
The agent requests the client to read a file from disk.
|
|
456
|
+
This enables the agent to access files in the user's filesystem.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
path: The file path to read.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
A dict with 'content' key containing the file content.
|
|
463
|
+
"""
|
|
464
|
+
# Check if handler wants to override the content
|
|
465
|
+
if client.events.on_file_read:
|
|
466
|
+
override = await client.events.on_file_read(path)
|
|
467
|
+
if override is not None:
|
|
468
|
+
logger.debug(f"File read overridden by handler: {path}")
|
|
469
|
+
return {"content": override}
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
file_path = Path(path)
|
|
473
|
+
if not file_path.exists():
|
|
474
|
+
logger.warning(f"File not found: {path}")
|
|
475
|
+
return {"content": "", "error": f"File not found: {path}"}
|
|
476
|
+
content = file_path.read_text(encoding="utf-8")
|
|
477
|
+
logger.debug(f"Read file: {path} ({len(content)} chars)")
|
|
478
|
+
return {"content": content}
|
|
479
|
+
except Exception as e:
|
|
480
|
+
logger.error(f"Failed to read file {path}: {e}")
|
|
481
|
+
return {"content": "", "error": str(e)}
|
|
482
|
+
|
|
483
|
+
async def create_terminal(
|
|
484
|
+
self,
|
|
485
|
+
command: str = "",
|
|
486
|
+
args: list[str] | None = None,
|
|
487
|
+
cwd: str | None = None,
|
|
488
|
+
env: dict[str, str] | None = None,
|
|
489
|
+
**kwargs,
|
|
490
|
+
) -> dict:
|
|
491
|
+
"""
|
|
492
|
+
Create a terminal and execute a command.
|
|
493
|
+
|
|
494
|
+
The agent requests the client to run a shell command.
|
|
495
|
+
This enables command execution in the user's environment.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
command: The command to execute.
|
|
499
|
+
args: Command arguments.
|
|
500
|
+
cwd: Working directory (defaults to client cwd).
|
|
501
|
+
env: Additional environment variables.
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
A dict with 'terminal_id' for tracking the process.
|
|
505
|
+
"""
|
|
506
|
+
work_dir = cwd or client.cwd
|
|
507
|
+
full_command = command
|
|
508
|
+
if args:
|
|
509
|
+
full_command = f"{command} {' '.join(args)}"
|
|
510
|
+
|
|
511
|
+
# Check if handler wants to block the terminal creation
|
|
512
|
+
if client.events.on_terminal_create:
|
|
513
|
+
allowed = await client.events.on_terminal_create(full_command, work_dir)
|
|
514
|
+
if not allowed:
|
|
515
|
+
logger.info(f"Terminal creation blocked by handler: {full_command}")
|
|
516
|
+
return {"terminal_id": "", "error": "Terminal creation blocked"}
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
# Create the subprocess
|
|
520
|
+
process = await asyncio.create_subprocess_shell(
|
|
521
|
+
full_command,
|
|
522
|
+
stdout=asyncio.subprocess.PIPE,
|
|
523
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
524
|
+
cwd=work_dir,
|
|
525
|
+
env={**dict(env or {})} if env else None,
|
|
526
|
+
)
|
|
341
527
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
528
|
+
# Generate terminal ID
|
|
529
|
+
client._terminal_counter += 1
|
|
530
|
+
terminal_id = f"terminal-{client._terminal_counter}"
|
|
345
531
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
532
|
+
# Store the terminal
|
|
533
|
+
client._terminals[terminal_id] = TerminalProcess(
|
|
534
|
+
process=process,
|
|
535
|
+
command=full_command,
|
|
536
|
+
cwd=work_dir,
|
|
537
|
+
output_buffer=[],
|
|
538
|
+
)
|
|
349
539
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
pass
|
|
540
|
+
logger.debug(f"Created terminal {terminal_id}: {full_command}")
|
|
541
|
+
return {"terminal_id": terminal_id}
|
|
353
542
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
543
|
+
except Exception as e:
|
|
544
|
+
logger.error(f"Failed to create terminal: {e}")
|
|
545
|
+
return {"terminal_id": "", "error": str(e)}
|
|
357
546
|
|
|
358
|
-
async def
|
|
359
|
-
|
|
360
|
-
|
|
547
|
+
async def terminal_output(
|
|
548
|
+
self,
|
|
549
|
+
terminal_id: str = "",
|
|
550
|
+
**kwargs,
|
|
551
|
+
) -> dict:
|
|
552
|
+
"""
|
|
553
|
+
Get output from a terminal.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
terminal_id: The terminal to get output from.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
A dict with 'output' containing available output.
|
|
560
|
+
"""
|
|
561
|
+
terminal = client._terminals.get(terminal_id)
|
|
562
|
+
if not terminal:
|
|
563
|
+
return {"output": "", "error": f"Terminal not found: {terminal_id}"}
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
# Try to read available output (non-blocking)
|
|
567
|
+
if terminal.process.stdout:
|
|
568
|
+
try:
|
|
569
|
+
# Read with a short timeout
|
|
570
|
+
output = await asyncio.wait_for(
|
|
571
|
+
terminal.process.stdout.read(4096),
|
|
572
|
+
timeout=0.1,
|
|
573
|
+
)
|
|
574
|
+
if output:
|
|
575
|
+
decoded = output.decode("utf-8", errors="replace")
|
|
576
|
+
terminal.output_buffer.append(decoded)
|
|
577
|
+
|
|
578
|
+
# Notify handler if registered
|
|
579
|
+
if client.events.on_terminal_output:
|
|
580
|
+
await client.events.on_terminal_output(terminal_id, decoded)
|
|
581
|
+
|
|
582
|
+
return {"output": decoded}
|
|
583
|
+
except asyncio.TimeoutError:
|
|
584
|
+
pass
|
|
585
|
+
|
|
586
|
+
# Return buffered output if no new output
|
|
587
|
+
if terminal.output_buffer:
|
|
588
|
+
return {"output": "".join(terminal.output_buffer)}
|
|
589
|
+
return {"output": ""}
|
|
590
|
+
|
|
591
|
+
except Exception as e:
|
|
592
|
+
logger.error(f"Failed to get terminal output: {e}")
|
|
593
|
+
return {"output": "", "error": str(e)}
|
|
594
|
+
|
|
595
|
+
async def release_terminal(
|
|
596
|
+
self,
|
|
597
|
+
terminal_id: str = "",
|
|
598
|
+
**kwargs,
|
|
599
|
+
) -> None:
|
|
600
|
+
"""
|
|
601
|
+
Release a terminal without killing it.
|
|
602
|
+
|
|
603
|
+
The terminal continues running but we stop tracking it.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
terminal_id: The terminal to release.
|
|
607
|
+
"""
|
|
608
|
+
if terminal_id in client._terminals:
|
|
609
|
+
logger.debug(f"Released terminal: {terminal_id}")
|
|
610
|
+
del client._terminals[terminal_id]
|
|
611
|
+
|
|
612
|
+
async def wait_for_terminal_exit(
|
|
613
|
+
self,
|
|
614
|
+
terminal_id: str = "",
|
|
615
|
+
**kwargs,
|
|
616
|
+
) -> dict:
|
|
617
|
+
"""
|
|
618
|
+
Wait for a terminal to exit and return its exit code.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
terminal_id: The terminal to wait for.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
A dict with 'exit_code'.
|
|
625
|
+
"""
|
|
626
|
+
terminal = client._terminals.get(terminal_id)
|
|
627
|
+
if not terminal:
|
|
628
|
+
return {"exit_code": -1, "error": f"Terminal not found: {terminal_id}"}
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
# Read remaining output while waiting
|
|
632
|
+
if terminal.process.stdout:
|
|
633
|
+
remaining = await terminal.process.stdout.read()
|
|
634
|
+
if remaining:
|
|
635
|
+
decoded = remaining.decode("utf-8", errors="replace")
|
|
636
|
+
terminal.output_buffer.append(decoded)
|
|
637
|
+
if client.events.on_terminal_output:
|
|
638
|
+
await client.events.on_terminal_output(terminal_id, decoded)
|
|
639
|
+
|
|
640
|
+
# Wait for process to exit
|
|
641
|
+
exit_code = await terminal.process.wait()
|
|
642
|
+
terminal.exit_code = exit_code
|
|
643
|
+
logger.debug(f"Terminal {terminal_id} exited with code {exit_code}")
|
|
644
|
+
return {"exit_code": exit_code}
|
|
645
|
+
|
|
646
|
+
except Exception as e:
|
|
647
|
+
logger.error(f"Failed to wait for terminal exit: {e}")
|
|
648
|
+
return {"exit_code": -1, "error": str(e)}
|
|
649
|
+
|
|
650
|
+
async def kill_terminal(
|
|
651
|
+
self,
|
|
652
|
+
terminal_id: str = "",
|
|
653
|
+
**kwargs,
|
|
654
|
+
) -> None:
|
|
655
|
+
"""
|
|
656
|
+
Kill a terminal process.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
terminal_id: The terminal to kill.
|
|
660
|
+
"""
|
|
661
|
+
terminal = client._terminals.get(terminal_id)
|
|
662
|
+
if not terminal:
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
terminal.process.kill()
|
|
667
|
+
await terminal.process.wait()
|
|
668
|
+
logger.debug(f"Killed terminal: {terminal_id}")
|
|
669
|
+
except Exception as e:
|
|
670
|
+
logger.error(f"Failed to kill terminal: {e}")
|
|
671
|
+
|
|
672
|
+
# Remove from tracking
|
|
673
|
+
if terminal_id in client._terminals:
|
|
674
|
+
del client._terminals[terminal_id]
|
|
361
675
|
|
|
362
676
|
async def ext_method(self, method: str, params: dict) -> dict:
|
|
363
677
|
"""Handle extension methods."""
|
claude_code_acp/agent.py
CHANGED
|
@@ -73,6 +73,7 @@ class Session:
|
|
|
73
73
|
permission_mode: PermissionMode = "default"
|
|
74
74
|
cancelled: bool = False
|
|
75
75
|
tool_use_cache: dict[str, ToolUseBlock] = field(default_factory=dict)
|
|
76
|
+
mcp_servers: dict[str, Any] = field(default_factory=dict)
|
|
76
77
|
|
|
77
78
|
|
|
78
79
|
class ClaudeAcpAgent(Agent):
|
|
@@ -141,12 +142,16 @@ class ClaudeAcpAgent(Agent):
|
|
|
141
142
|
"""Create a new Claude session."""
|
|
142
143
|
session_id = str(uuid4())
|
|
143
144
|
|
|
145
|
+
# Convert ACP MCP servers to Claude SDK format
|
|
146
|
+
sdk_mcp_servers = self._convert_mcp_servers(mcp_servers)
|
|
147
|
+
|
|
144
148
|
self._sessions[session_id] = Session(
|
|
145
149
|
session_id=session_id,
|
|
146
150
|
cwd=cwd,
|
|
151
|
+
mcp_servers=sdk_mcp_servers,
|
|
147
152
|
)
|
|
148
153
|
|
|
149
|
-
logger.info(f"New session created: {session_id} in {cwd}")
|
|
154
|
+
logger.info(f"New session created: {session_id} in {cwd} with {len(sdk_mcp_servers)} MCP servers")
|
|
150
155
|
|
|
151
156
|
return NewSessionResponse(
|
|
152
157
|
session_id=session_id,
|
|
@@ -224,11 +229,12 @@ class ClaudeAcpAgent(Agent):
|
|
|
224
229
|
|
|
225
230
|
logger.info(f"Prompt for session {session_id}: {prompt_text[:100]}...")
|
|
226
231
|
|
|
227
|
-
# Build Claude options with
|
|
232
|
+
# Build Claude options with MCP servers and permission callback
|
|
228
233
|
options = ClaudeAgentOptions(
|
|
229
234
|
cwd=session.cwd,
|
|
230
235
|
permission_mode=session.permission_mode,
|
|
231
236
|
include_partial_messages=True,
|
|
237
|
+
mcp_servers=session.mcp_servers if session.mcp_servers else {},
|
|
232
238
|
)
|
|
233
239
|
|
|
234
240
|
# Add permission callback if not bypassing permissions
|
|
@@ -237,6 +243,7 @@ class ClaudeAcpAgent(Agent):
|
|
|
237
243
|
cwd=session.cwd,
|
|
238
244
|
permission_mode=session.permission_mode,
|
|
239
245
|
include_partial_messages=True,
|
|
246
|
+
mcp_servers=session.mcp_servers if session.mcp_servers else {},
|
|
240
247
|
can_use_tool=self._create_permission_handler(session_id),
|
|
241
248
|
)
|
|
242
249
|
|
|
@@ -300,6 +307,68 @@ class ClaudeAcpAgent(Agent):
|
|
|
300
307
|
|
|
301
308
|
return "\n".join(parts)
|
|
302
309
|
|
|
310
|
+
def _convert_mcp_servers(
|
|
311
|
+
self,
|
|
312
|
+
acp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
|
|
313
|
+
) -> dict[str, Any]:
|
|
314
|
+
"""Convert ACP MCP server configs to Claude SDK format."""
|
|
315
|
+
sdk_servers: dict[str, Any] = {}
|
|
316
|
+
|
|
317
|
+
for i, server in enumerate(acp_servers):
|
|
318
|
+
if isinstance(server, dict):
|
|
319
|
+
server_type = server.get("type")
|
|
320
|
+
name = server.get("name", f"mcp-server-{i}")
|
|
321
|
+
|
|
322
|
+
if server_type == "stdio":
|
|
323
|
+
# Stdio MCP server
|
|
324
|
+
sdk_servers[name] = {
|
|
325
|
+
"type": "stdio",
|
|
326
|
+
"command": server.get("command", ""),
|
|
327
|
+
"args": server.get("args", []),
|
|
328
|
+
"env": server.get("env", {}),
|
|
329
|
+
}
|
|
330
|
+
elif server_type == "sse":
|
|
331
|
+
# SSE MCP server
|
|
332
|
+
sdk_servers[name] = {
|
|
333
|
+
"type": "sse",
|
|
334
|
+
"url": server.get("url", ""),
|
|
335
|
+
}
|
|
336
|
+
elif server_type == "http":
|
|
337
|
+
# HTTP MCP server
|
|
338
|
+
sdk_servers[name] = {
|
|
339
|
+
"type": "http",
|
|
340
|
+
"url": server.get("url", ""),
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
logger.info(f"Added MCP server: {name} ({server_type})")
|
|
344
|
+
|
|
345
|
+
elif hasattr(server, "type"):
|
|
346
|
+
# Pydantic model
|
|
347
|
+
name = getattr(server, "name", f"mcp-server-{i}")
|
|
348
|
+
server_type = server.type
|
|
349
|
+
|
|
350
|
+
if server_type == "stdio":
|
|
351
|
+
sdk_servers[name] = {
|
|
352
|
+
"type": "stdio",
|
|
353
|
+
"command": getattr(server, "command", ""),
|
|
354
|
+
"args": getattr(server, "args", []),
|
|
355
|
+
"env": getattr(server, "env", {}),
|
|
356
|
+
}
|
|
357
|
+
elif server_type == "sse":
|
|
358
|
+
sdk_servers[name] = {
|
|
359
|
+
"type": "sse",
|
|
360
|
+
"url": getattr(server, "url", ""),
|
|
361
|
+
}
|
|
362
|
+
elif server_type == "http":
|
|
363
|
+
sdk_servers[name] = {
|
|
364
|
+
"type": "http",
|
|
365
|
+
"url": getattr(server, "url", ""),
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
logger.info(f"Added MCP server: {name} ({server_type})")
|
|
369
|
+
|
|
370
|
+
return sdk_servers
|
|
371
|
+
|
|
303
372
|
async def _handle_message(self, session_id: str, message: Message) -> None:
|
|
304
373
|
"""Convert and emit a Claude message as ACP updates."""
|
|
305
374
|
if self._conn is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: claude-code-acp
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: ACP-compatible agent for Claude Code (Python version)
|
|
5
5
|
Project-URL: Homepage, https://github.com/yazelin/claude-code-acp-py
|
|
6
6
|
Project-URL: Repository, https://github.com/yazelin/claude-code-acp-py
|
|
@@ -234,6 +234,43 @@ gemini = AcpClient(command="gemini", args=["--experimental-acp"])
|
|
|
234
234
|
ts_claude = AcpClient(command="npx", args=["@zed-industries/claude-code-acp"])
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
+
### File Operation Handlers
|
|
238
|
+
|
|
239
|
+
AcpClient supports intercepting file read/write operations for security or custom handling:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
@client.on_file_read
|
|
243
|
+
async def handle_read(path: str) -> str | None:
|
|
244
|
+
"""Intercept file reads. Return content to override, or None to proceed."""
|
|
245
|
+
print(f"📖 Reading: {path}")
|
|
246
|
+
return None # Proceed with normal read
|
|
247
|
+
|
|
248
|
+
@client.on_file_write
|
|
249
|
+
async def handle_write(path: str, content: str) -> bool:
|
|
250
|
+
"""Intercept file writes. Return True to allow, False to block."""
|
|
251
|
+
print(f"📝 Writing: {path}")
|
|
252
|
+
response = input("Allow write? [y/N]: ")
|
|
253
|
+
return response.lower() == "y"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Terminal Operation Handlers
|
|
257
|
+
|
|
258
|
+
AcpClient supports intercepting terminal/shell execution for security:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
@client.on_terminal_create
|
|
262
|
+
async def handle_terminal(command: str, cwd: str) -> bool:
|
|
263
|
+
"""Intercept shell commands. Return True to allow, False to block."""
|
|
264
|
+
print(f"🖥️ Command: {command} in {cwd}")
|
|
265
|
+
response = input("Allow execution? [y/N]: ")
|
|
266
|
+
return response.lower() == "y"
|
|
267
|
+
|
|
268
|
+
@client.on_terminal_output
|
|
269
|
+
async def handle_output(terminal_id: str, output: str) -> None:
|
|
270
|
+
"""Receive terminal output in real-time."""
|
|
271
|
+
print(output, end="")
|
|
272
|
+
```
|
|
273
|
+
|
|
237
274
|
### AcpClient vs ClaudeClient
|
|
238
275
|
|
|
239
276
|
| Feature | `ClaudeClient` | `AcpClient` |
|
|
@@ -255,48 +292,52 @@ ts_claude = AcpClient(command="npx", args=["@zed-industries/claude-code-acp"])
|
|
|
255
292
|
|
|
256
293
|
## Architecture
|
|
257
294
|
|
|
295
|
+
This package provides **three ways** to use Claude:
|
|
296
|
+
|
|
297
|
+
### Method A: Editor via ACP (ClaudeAcpAgent)
|
|
298
|
+
|
|
299
|
+
For Zed, Neovim, and other ACP-compatible editors:
|
|
300
|
+
|
|
258
301
|
```
|
|
259
|
-
|
|
260
|
-
│
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
│ ACP SERVER (for editors) ACP CLIENT (for Python apps) │
|
|
264
|
-
│ ───────────────────────── ──────────────────────────── │
|
|
265
|
-
│ │
|
|
266
|
-
│ ┌─────────────┐ ┌─────────────┐ │
|
|
267
|
-
│ │ Zed/Neovim │ │ Your Python │ │
|
|
268
|
-
│ │ Editor │ │ App │ │
|
|
269
|
-
│ └──────┬──────┘ └──────┬──────┘ │
|
|
270
|
-
│ │ │ │
|
|
271
|
-
│ │ ACP │ uses │
|
|
272
|
-
│ ▼ ▼ │
|
|
273
|
-
│ ┌──────────────────┐ ┌─────────────────┐ │
|
|
274
|
-
│ │ ClaudeAcpAgent │ │ AcpClient │───┐ │
|
|
275
|
-
│ │ (ACP Server) │ │ (ACP Client) │ │ │
|
|
276
|
-
│ └────────┬─────────┘ └────────┬────────┘ │ │
|
|
277
|
-
│ │ │ │ can connect to │
|
|
278
|
-
│ │ │ ACP │ any ACP agent │
|
|
279
|
-
│ ▼ ▼ │ │
|
|
280
|
-
│ ┌──────────────────┐ ┌─────────────────┐ │ │
|
|
281
|
-
│ │ ClaudeClient │ │ claude-code-acp│◄─┘ │
|
|
282
|
-
│ │ (Python wrapper) │ │ Gemini CLI │ │
|
|
283
|
-
│ └────────┬─────────┘ │ Other agents │ │
|
|
284
|
-
│ │ └─────────────────┘ │
|
|
285
|
-
│ │ │
|
|
286
|
-
│ ▼ │
|
|
287
|
-
│ ┌──────────────────┐ │
|
|
288
|
-
│ │ Claude Agent SDK │ │
|
|
289
|
-
│ └────────┬─────────┘ │
|
|
290
|
-
│ │ │
|
|
291
|
-
│ ▼ │
|
|
292
|
-
│ ┌──────────────────┐ │
|
|
293
|
-
│ │ Claude CLI │ │
|
|
294
|
-
│ │ (Subscription) │ │
|
|
295
|
-
│ └──────────────────┘ │
|
|
296
|
-
│ │
|
|
297
|
-
└──────────────────────────────────────────────────────────────────────────────┘
|
|
302
|
+
┌──────────┐ ACP Protocol ┌─────────────────┐ SDK ┌────────────┐
|
|
303
|
+
│ Zed │ ────── stdio ───────► │ ClaudeAcpAgent │ ──────────► │ Claude CLI │
|
|
304
|
+
│ Editor │ │ (ACP Server) │ │ │
|
|
305
|
+
└──────────┘ └─────────────────┘ └────────────┘
|
|
298
306
|
```
|
|
299
307
|
|
|
308
|
+
### Method B: Python Direct (ClaudeClient)
|
|
309
|
+
|
|
310
|
+
For Python apps that want simple, direct access to Claude (**no ACP protocol**):
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
┌──────────┐ direct call ┌─────────────────┐ SDK ┌────────────┐
|
|
314
|
+
│ Python │ ──── in-process ───► │ ClaudeClient │ ──────────► │ Claude CLI │
|
|
315
|
+
│ App │ │ │ │ │
|
|
316
|
+
└──────────┘ └─────────────────┘ └────────────┘
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Method C: Python via ACP (AcpClient)
|
|
320
|
+
|
|
321
|
+
For Python apps that want to connect to **any** ACP-compatible agent:
|
|
322
|
+
|
|
323
|
+
```
|
|
324
|
+
┌──────────┐ ACP Protocol ┌─────────────────┐
|
|
325
|
+
│ Python │ ────── stdio ───────► │ Any ACP Agent │
|
|
326
|
+
│ App │ │ │
|
|
327
|
+
│ │ │ • claude-code │
|
|
328
|
+
│ AcpClient│ │ • gemini │
|
|
329
|
+
│ │ │ • custom agents │
|
|
330
|
+
└──────────┘ └─────────────────┘
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Summary
|
|
334
|
+
|
|
335
|
+
| Component | Uses ACP? | Purpose |
|
|
336
|
+
|-----------|-----------|---------|
|
|
337
|
+
| `ClaudeAcpAgent` | Yes (Server) | Let editors connect to Claude |
|
|
338
|
+
| `ClaudeClient` | **No** | Simplest way for Python apps |
|
|
339
|
+
| `AcpClient` | Yes (Client) | Connect to any ACP agent |
|
|
340
|
+
|
|
300
341
|
---
|
|
301
342
|
|
|
302
343
|
## What We Built
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
claude_code_acp/__init__.py,sha256=JSAUndeyZn-6EYXtA0MJMNj8fMiiuYFLGftwY2ERxVw,828
|
|
2
|
+
claude_code_acp/__main__.py,sha256=zuAdIOmaCsDeRxj2yIl_qMx-74QFaA3nWiS8gti0og0,118
|
|
3
|
+
claude_code_acp/acp_client.py,sha256=p2mmRB1Z1DaEGCkBznbLeKe_zUqrZOJMGCm17IW0gkE,25064
|
|
4
|
+
claude_code_acp/agent.py,sha256=PuWPFhBlOSkdYCt4yuxSxI1i9gVy_FwvabkmDYyiHLA,23421
|
|
5
|
+
claude_code_acp/client.py,sha256=jKi6tPNqNu0GWTsXxVa1BREGE1wkm_kbIEDulXKGDHE,10680
|
|
6
|
+
claude_code_acp-0.3.4.dist-info/METADATA,sha256=TuTB5bpDj3UGmu06kIvJOabLYX_X_ZEu1zIR6vuoQr0,14940
|
|
7
|
+
claude_code_acp-0.3.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
claude_code_acp-0.3.4.dist-info/entry_points.txt,sha256=cc_pkg_V_1zctD5CUqjATCxglkf5-UArEMfN3q9We-A,57
|
|
9
|
+
claude_code_acp-0.3.4.dist-info/licenses/LICENSE,sha256=5NCM9Q9UTfsn-VyafO7htdlYyPPO8H-NHYrO5UV9sT4,1064
|
|
10
|
+
claude_code_acp-0.3.4.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
claude_code_acp/__init__.py,sha256=dwYHKNrSkC4DZr2qa95eOy8geHMuzlgdtiB__G92nns,828
|
|
2
|
-
claude_code_acp/__main__.py,sha256=zuAdIOmaCsDeRxj2yIl_qMx-74QFaA3nWiS8gti0og0,118
|
|
3
|
-
claude_code_acp/acp_client.py,sha256=E0e8XBco3cBV4sPi4txXpS13WPXRDodwYqHpXmCJd4A,12572
|
|
4
|
-
claude_code_acp/agent.py,sha256=qos32gnMAilOlz6ebllkmuJZS3UgpbAtAZw58r3SYig,20619
|
|
5
|
-
claude_code_acp/client.py,sha256=jKi6tPNqNu0GWTsXxVa1BREGE1wkm_kbIEDulXKGDHE,10680
|
|
6
|
-
claude_code_acp-0.3.1.dist-info/METADATA,sha256=sLheVL9IRJFKCC_gK1lzSItmatqOTquLcDG9sOA3Y3c,15895
|
|
7
|
-
claude_code_acp-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
-
claude_code_acp-0.3.1.dist-info/entry_points.txt,sha256=cc_pkg_V_1zctD5CUqjATCxglkf5-UArEMfN3q9We-A,57
|
|
9
|
-
claude_code_acp-0.3.1.dist-info/licenses/LICENSE,sha256=5NCM9Q9UTfsn-VyafO7htdlYyPPO8H-NHYrO5UV9sT4,1064
|
|
10
|
-
claude_code_acp-0.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|