claude-code-acp 0.1.0__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 +29 -0
- claude_code_acp/__main__.py +6 -0
- claude_code_acp/agent.py +615 -0
- claude_code_acp-0.1.0.dist-info/METADATA +111 -0
- claude_code_acp-0.1.0.dist-info/RECORD +8 -0
- claude_code_acp-0.1.0.dist-info/WHEEL +4 -0
- claude_code_acp-0.1.0.dist-info/entry_points.txt +2 -0
- claude_code_acp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code ACP - ACP-compatible agent for Claude Code (Python version).
|
|
3
|
+
|
|
4
|
+
This package bridges the Claude Agent SDK with the Agent Client Protocol (ACP),
|
|
5
|
+
allowing Claude Code to work with any ACP-compatible client like Zed, Neovim, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from .agent import ClaudeAcpAgent
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["ClaudeAcpAgent", "main", "run"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def run() -> None:
|
|
18
|
+
"""Run the Claude ACP agent."""
|
|
19
|
+
from acp import run_agent
|
|
20
|
+
await run_agent(ClaudeAcpAgent())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
"""Entry point for the claude-code-acp command."""
|
|
25
|
+
asyncio.run(run())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
claude_code_acp/agent.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude ACP Agent - Bridges Claude Agent SDK with ACP protocol.
|
|
3
|
+
|
|
4
|
+
This module implements the ACP Agent interface, converting Claude SDK
|
|
5
|
+
messages to ACP session updates for bidirectional communication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from acp import (
|
|
16
|
+
Agent,
|
|
17
|
+
InitializeResponse,
|
|
18
|
+
NewSessionResponse,
|
|
19
|
+
PromptResponse,
|
|
20
|
+
SetSessionModeResponse,
|
|
21
|
+
start_tool_call,
|
|
22
|
+
text_block,
|
|
23
|
+
update_agent_message,
|
|
24
|
+
update_agent_thought,
|
|
25
|
+
update_tool_call,
|
|
26
|
+
)
|
|
27
|
+
from acp.interfaces import Client
|
|
28
|
+
from acp.schema import (
|
|
29
|
+
AgentCapabilities,
|
|
30
|
+
AudioContentBlock,
|
|
31
|
+
ClientCapabilities,
|
|
32
|
+
EmbeddedResourceContentBlock,
|
|
33
|
+
HttpMcpServer,
|
|
34
|
+
ImageContentBlock,
|
|
35
|
+
Implementation,
|
|
36
|
+
McpServerStdio,
|
|
37
|
+
PermissionOption,
|
|
38
|
+
PromptCapabilities,
|
|
39
|
+
ResourceContentBlock,
|
|
40
|
+
SessionCapabilities,
|
|
41
|
+
SseMcpServer,
|
|
42
|
+
TextContentBlock,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
from claude_agent_sdk import (
|
|
46
|
+
AssistantMessage,
|
|
47
|
+
ClaudeAgentOptions,
|
|
48
|
+
ClaudeSDKClient,
|
|
49
|
+
Message,
|
|
50
|
+
PermissionMode,
|
|
51
|
+
PermissionResultAllow,
|
|
52
|
+
PermissionResultDeny,
|
|
53
|
+
ResultMessage,
|
|
54
|
+
SystemMessage,
|
|
55
|
+
TextBlock,
|
|
56
|
+
ThinkingBlock,
|
|
57
|
+
ToolPermissionContext,
|
|
58
|
+
ToolResultBlock,
|
|
59
|
+
ToolUseBlock,
|
|
60
|
+
UserMessage,
|
|
61
|
+
)
|
|
62
|
+
from claude_agent_sdk.types import StreamEvent
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Session:
|
|
69
|
+
"""Represents an active Claude session."""
|
|
70
|
+
|
|
71
|
+
session_id: str
|
|
72
|
+
cwd: str
|
|
73
|
+
permission_mode: PermissionMode = "default"
|
|
74
|
+
cancelled: bool = False
|
|
75
|
+
tool_use_cache: dict[str, ToolUseBlock] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ClaudeAcpAgent(Agent):
|
|
79
|
+
"""
|
|
80
|
+
ACP Agent implementation that bridges Claude Agent SDK with ACP protocol.
|
|
81
|
+
|
|
82
|
+
This agent:
|
|
83
|
+
1. Receives ACP requests from clients (Zed, Neovim, etc.)
|
|
84
|
+
2. Converts them to Claude Agent SDK format
|
|
85
|
+
3. Streams Claude responses back as ACP session updates
|
|
86
|
+
4. Handles bidirectional communication (permissions, file ops, etc.)
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self._conn: Client | None = None
|
|
91
|
+
self._sessions: dict[str, Session] = {}
|
|
92
|
+
|
|
93
|
+
def on_connect(self, conn: Client) -> None:
|
|
94
|
+
"""Called when an ACP client connects."""
|
|
95
|
+
self._conn = conn
|
|
96
|
+
logger.info("ACP client connected")
|
|
97
|
+
|
|
98
|
+
async def initialize(
|
|
99
|
+
self,
|
|
100
|
+
protocol_version: int,
|
|
101
|
+
client_capabilities: ClientCapabilities | None = None,
|
|
102
|
+
client_info: Implementation | None = None,
|
|
103
|
+
**kwargs: Any,
|
|
104
|
+
) -> InitializeResponse:
|
|
105
|
+
"""Handle ACP initialize request."""
|
|
106
|
+
logger.info(f"Initialize request from {client_info}")
|
|
107
|
+
|
|
108
|
+
return InitializeResponse(
|
|
109
|
+
protocol_version=protocol_version,
|
|
110
|
+
agent_capabilities=AgentCapabilities(
|
|
111
|
+
prompt_capabilities=PromptCapabilities(
|
|
112
|
+
image=True,
|
|
113
|
+
embedded_context=True,
|
|
114
|
+
),
|
|
115
|
+
session_capabilities=SessionCapabilities(
|
|
116
|
+
fork={},
|
|
117
|
+
list={},
|
|
118
|
+
resume={},
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
agent_info=Implementation(
|
|
122
|
+
name="claude-code-acp-py",
|
|
123
|
+
title="Claude Code (Python)",
|
|
124
|
+
version="0.1.0",
|
|
125
|
+
),
|
|
126
|
+
auth_methods=[
|
|
127
|
+
{
|
|
128
|
+
"id": "claude-login",
|
|
129
|
+
"name": "Log in with Claude Code",
|
|
130
|
+
"description": "Run `claude /login` in the terminal",
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def new_session(
|
|
136
|
+
self,
|
|
137
|
+
cwd: str,
|
|
138
|
+
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio],
|
|
139
|
+
**kwargs: Any,
|
|
140
|
+
) -> NewSessionResponse:
|
|
141
|
+
"""Create a new Claude session."""
|
|
142
|
+
session_id = str(uuid4())
|
|
143
|
+
|
|
144
|
+
self._sessions[session_id] = Session(
|
|
145
|
+
session_id=session_id,
|
|
146
|
+
cwd=cwd,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
logger.info(f"New session created: {session_id} in {cwd}")
|
|
150
|
+
|
|
151
|
+
return NewSessionResponse(
|
|
152
|
+
session_id=session_id,
|
|
153
|
+
modes={
|
|
154
|
+
"current_mode_id": "default",
|
|
155
|
+
"available_modes": [
|
|
156
|
+
{
|
|
157
|
+
"id": "default",
|
|
158
|
+
"name": "Default",
|
|
159
|
+
"description": "Standard behavior, prompts for dangerous operations",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"id": "acceptEdits",
|
|
163
|
+
"name": "Accept Edits",
|
|
164
|
+
"description": "Auto-accept file edit operations",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"id": "plan",
|
|
168
|
+
"name": "Plan Mode",
|
|
169
|
+
"description": "Planning mode, no actual tool execution",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"id": "bypassPermissions",
|
|
173
|
+
"name": "Bypass Permissions",
|
|
174
|
+
"description": "Bypass all permission checks",
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
async def set_session_mode(
|
|
181
|
+
self, mode_id: str, session_id: str, **kwargs: Any
|
|
182
|
+
) -> SetSessionModeResponse | None:
|
|
183
|
+
"""Change the permission mode for a session."""
|
|
184
|
+
if session_id not in self._sessions:
|
|
185
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
186
|
+
|
|
187
|
+
valid_modes = ["default", "acceptEdits", "plan", "bypassPermissions", "dontAsk"]
|
|
188
|
+
if mode_id not in valid_modes:
|
|
189
|
+
raise ValueError(f"Invalid mode: {mode_id}")
|
|
190
|
+
|
|
191
|
+
self._sessions[session_id].permission_mode = mode_id # type: ignore
|
|
192
|
+
logger.info(f"Session {session_id} mode changed to {mode_id}")
|
|
193
|
+
|
|
194
|
+
return SetSessionModeResponse()
|
|
195
|
+
|
|
196
|
+
async def prompt(
|
|
197
|
+
self,
|
|
198
|
+
prompt: list[
|
|
199
|
+
TextContentBlock
|
|
200
|
+
| ImageContentBlock
|
|
201
|
+
| AudioContentBlock
|
|
202
|
+
| ResourceContentBlock
|
|
203
|
+
| EmbeddedResourceContentBlock
|
|
204
|
+
],
|
|
205
|
+
session_id: str,
|
|
206
|
+
**kwargs: Any,
|
|
207
|
+
) -> PromptResponse:
|
|
208
|
+
"""
|
|
209
|
+
Handle a prompt from the ACP client.
|
|
210
|
+
|
|
211
|
+
This is the main method that:
|
|
212
|
+
1. Converts ACP prompt to Claude format
|
|
213
|
+
2. Streams Claude responses via ClaudeSDKClient
|
|
214
|
+
3. Converts Claude messages to ACP updates
|
|
215
|
+
"""
|
|
216
|
+
if session_id not in self._sessions:
|
|
217
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
218
|
+
|
|
219
|
+
session = self._sessions[session_id]
|
|
220
|
+
session.cancelled = False
|
|
221
|
+
|
|
222
|
+
# Convert ACP prompt to text
|
|
223
|
+
prompt_text = self._convert_prompt_to_text(prompt)
|
|
224
|
+
|
|
225
|
+
logger.info(f"Prompt for session {session_id}: {prompt_text[:100]}...")
|
|
226
|
+
|
|
227
|
+
# Build Claude options with permission callback for bidirectional communication
|
|
228
|
+
options = ClaudeAgentOptions(
|
|
229
|
+
cwd=session.cwd,
|
|
230
|
+
permission_mode=session.permission_mode,
|
|
231
|
+
include_partial_messages=True,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Add permission callback if not bypassing permissions
|
|
235
|
+
if session.permission_mode != "bypassPermissions":
|
|
236
|
+
options = ClaudeAgentOptions(
|
|
237
|
+
cwd=session.cwd,
|
|
238
|
+
permission_mode=session.permission_mode,
|
|
239
|
+
include_partial_messages=True,
|
|
240
|
+
can_use_tool=self._create_permission_handler(session_id),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
# Use ClaudeSDKClient for streaming mode (required for can_use_tool callback)
|
|
245
|
+
async with ClaudeSDKClient(options) as client:
|
|
246
|
+
# Send the query
|
|
247
|
+
await client.query(prompt_text)
|
|
248
|
+
|
|
249
|
+
# Receive and process messages
|
|
250
|
+
async for message in client.receive_response():
|
|
251
|
+
if session.cancelled:
|
|
252
|
+
await client.interrupt()
|
|
253
|
+
return PromptResponse(stop_reason="cancelled")
|
|
254
|
+
|
|
255
|
+
await self._handle_message(session_id, message)
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
logger.error(f"Error in prompt: {e}")
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
return PromptResponse(stop_reason="end_turn")
|
|
262
|
+
|
|
263
|
+
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
|
264
|
+
"""Cancel the current operation for a session."""
|
|
265
|
+
if session_id in self._sessions:
|
|
266
|
+
self._sessions[session_id].cancelled = True
|
|
267
|
+
logger.info(f"Session {session_id} cancelled")
|
|
268
|
+
|
|
269
|
+
# --- Conversion Methods ---
|
|
270
|
+
|
|
271
|
+
def _convert_prompt_to_text(
|
|
272
|
+
self,
|
|
273
|
+
prompt: list[
|
|
274
|
+
TextContentBlock
|
|
275
|
+
| ImageContentBlock
|
|
276
|
+
| AudioContentBlock
|
|
277
|
+
| ResourceContentBlock
|
|
278
|
+
| EmbeddedResourceContentBlock
|
|
279
|
+
],
|
|
280
|
+
) -> str:
|
|
281
|
+
"""Convert ACP prompt blocks to text for Claude."""
|
|
282
|
+
parts = []
|
|
283
|
+
|
|
284
|
+
for block in prompt:
|
|
285
|
+
if isinstance(block, dict):
|
|
286
|
+
block_type = block.get("type")
|
|
287
|
+
if block_type == "text":
|
|
288
|
+
parts.append(block.get("text", ""))
|
|
289
|
+
elif block_type == "resource":
|
|
290
|
+
resource = block.get("resource", {})
|
|
291
|
+
if "text" in resource:
|
|
292
|
+
uri = resource.get("uri", "unknown")
|
|
293
|
+
parts.append(f"\n<context ref=\"{uri}\">\n{resource['text']}\n</context>")
|
|
294
|
+
elif block_type == "resource_link":
|
|
295
|
+
uri = block.get("uri", "")
|
|
296
|
+
name = block.get("name", uri.split("/")[-1])
|
|
297
|
+
parts.append(f"[@{name}]({uri})")
|
|
298
|
+
elif hasattr(block, "text"):
|
|
299
|
+
parts.append(block.text)
|
|
300
|
+
|
|
301
|
+
return "\n".join(parts)
|
|
302
|
+
|
|
303
|
+
async def _handle_message(self, session_id: str, message: Message) -> None:
|
|
304
|
+
"""Convert and emit a Claude message as ACP updates."""
|
|
305
|
+
if self._conn is None:
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
session = self._sessions.get(session_id)
|
|
309
|
+
if session is None:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
if isinstance(message, AssistantMessage):
|
|
313
|
+
await self._handle_assistant_message(session_id, message, session)
|
|
314
|
+
|
|
315
|
+
elif isinstance(message, StreamEvent):
|
|
316
|
+
await self._handle_stream_event(session_id, message)
|
|
317
|
+
|
|
318
|
+
elif isinstance(message, SystemMessage):
|
|
319
|
+
# System messages (init, status, etc.) - log but don't emit
|
|
320
|
+
logger.debug(f"System message: {message.subtype}")
|
|
321
|
+
|
|
322
|
+
elif isinstance(message, ResultMessage):
|
|
323
|
+
# Result message - session complete
|
|
324
|
+
logger.info(f"Session {session_id} completed: {message.subtype}")
|
|
325
|
+
|
|
326
|
+
elif isinstance(message, UserMessage):
|
|
327
|
+
# User messages from history - usually skip
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
async def _handle_assistant_message(
|
|
331
|
+
self, session_id: str, message: AssistantMessage, session: Session
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Handle an assistant message from Claude."""
|
|
334
|
+
if self._conn is None:
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
for block in message.content:
|
|
338
|
+
if isinstance(block, TextBlock):
|
|
339
|
+
# Text content
|
|
340
|
+
await self._conn.session_update(
|
|
341
|
+
session_id,
|
|
342
|
+
update_agent_message(text_block(block.text)),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
elif isinstance(block, ThinkingBlock):
|
|
346
|
+
# Thinking/reasoning content
|
|
347
|
+
await self._conn.session_update(
|
|
348
|
+
session_id,
|
|
349
|
+
update_agent_thought(text_block(block.thinking)),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
elif isinstance(block, ToolUseBlock):
|
|
353
|
+
# Tool invocation
|
|
354
|
+
session.tool_use_cache[block.id] = block
|
|
355
|
+
|
|
356
|
+
await self._conn.session_update(
|
|
357
|
+
session_id,
|
|
358
|
+
start_tool_call(
|
|
359
|
+
tool_call_id=block.id,
|
|
360
|
+
title=self._get_tool_title(block.name, block.input),
|
|
361
|
+
status="pending",
|
|
362
|
+
raw_input=block.input,
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
elif isinstance(block, ToolResultBlock):
|
|
367
|
+
# Tool result
|
|
368
|
+
status = "failed" if block.is_error else "completed"
|
|
369
|
+
|
|
370
|
+
await self._conn.session_update(
|
|
371
|
+
session_id,
|
|
372
|
+
update_tool_call(
|
|
373
|
+
tool_call_id=block.tool_use_id,
|
|
374
|
+
status=status,
|
|
375
|
+
raw_output=block.content,
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
async def _handle_stream_event(self, session_id: str, event: StreamEvent) -> None:
|
|
380
|
+
"""Handle a streaming event from Claude."""
|
|
381
|
+
if self._conn is None:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
event_data = event.event
|
|
385
|
+
event_type = event_data.get("type")
|
|
386
|
+
|
|
387
|
+
if event_type == "content_block_delta":
|
|
388
|
+
delta = event_data.get("delta", {})
|
|
389
|
+
delta_type = delta.get("type")
|
|
390
|
+
|
|
391
|
+
if delta_type == "text_delta":
|
|
392
|
+
text = delta.get("text", "")
|
|
393
|
+
if text:
|
|
394
|
+
await self._conn.session_update(
|
|
395
|
+
session_id,
|
|
396
|
+
update_agent_message(text_block(text)),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
elif delta_type == "thinking_delta":
|
|
400
|
+
thinking = delta.get("thinking", "")
|
|
401
|
+
if thinking:
|
|
402
|
+
await self._conn.session_update(
|
|
403
|
+
session_id,
|
|
404
|
+
update_agent_thought(text_block(thinking)),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def _get_tool_title(self, tool_name: str, tool_input: dict[str, Any]) -> str:
|
|
408
|
+
"""Generate a human-readable title for a tool call."""
|
|
409
|
+
if tool_name == "Read":
|
|
410
|
+
path = tool_input.get("file_path", tool_input.get("path", ""))
|
|
411
|
+
return f"Read {path}"
|
|
412
|
+
elif tool_name in ["Write", "Edit"]:
|
|
413
|
+
path = tool_input.get("file_path", tool_input.get("path", ""))
|
|
414
|
+
return f"{tool_name} {path}"
|
|
415
|
+
elif tool_name == "Bash":
|
|
416
|
+
cmd = tool_input.get("command", "")
|
|
417
|
+
return f"Run: {cmd[:50]}..." if len(cmd) > 50 else f"Run: {cmd}"
|
|
418
|
+
elif tool_name == "Glob":
|
|
419
|
+
pattern = tool_input.get("pattern", "")
|
|
420
|
+
return f"Find files: {pattern}"
|
|
421
|
+
elif tool_name == "Grep":
|
|
422
|
+
pattern = tool_input.get("pattern", "")
|
|
423
|
+
return f"Search: {pattern}"
|
|
424
|
+
else:
|
|
425
|
+
return tool_name
|
|
426
|
+
|
|
427
|
+
def _create_permission_handler(self, session_id: str):
|
|
428
|
+
"""Create a permission handler for bidirectional permission requests."""
|
|
429
|
+
|
|
430
|
+
async def can_use_tool(
|
|
431
|
+
tool_name: str,
|
|
432
|
+
tool_input: dict[str, Any],
|
|
433
|
+
context: ToolPermissionContext,
|
|
434
|
+
) -> PermissionResultAllow | PermissionResultDeny:
|
|
435
|
+
logger.info(f"🔧 Permission requested for tool: {tool_name}")
|
|
436
|
+
|
|
437
|
+
if self._conn is None:
|
|
438
|
+
logger.warning("No ACP connection for permission request")
|
|
439
|
+
return PermissionResultDeny(message="No ACP connection")
|
|
440
|
+
|
|
441
|
+
session = self._sessions.get(session_id)
|
|
442
|
+
if session is None:
|
|
443
|
+
return PermissionResultDeny(message="Session not found")
|
|
444
|
+
|
|
445
|
+
# For certain modes, auto-allow
|
|
446
|
+
if session.permission_mode == "bypassPermissions":
|
|
447
|
+
return PermissionResultAllow()
|
|
448
|
+
|
|
449
|
+
if session.permission_mode == "acceptEdits" and tool_name in [
|
|
450
|
+
"Write",
|
|
451
|
+
"Edit",
|
|
452
|
+
"MultiEdit",
|
|
453
|
+
]:
|
|
454
|
+
return PermissionResultAllow()
|
|
455
|
+
|
|
456
|
+
# Request permission from ACP client
|
|
457
|
+
tool_use_id = str(uuid4())
|
|
458
|
+
|
|
459
|
+
response = await self._conn.request_permission(
|
|
460
|
+
options=[
|
|
461
|
+
PermissionOption(
|
|
462
|
+
kind="allow_always",
|
|
463
|
+
name="Always Allow",
|
|
464
|
+
option_id="allow_always",
|
|
465
|
+
),
|
|
466
|
+
PermissionOption(
|
|
467
|
+
kind="allow_once",
|
|
468
|
+
name="Allow",
|
|
469
|
+
option_id="allow",
|
|
470
|
+
),
|
|
471
|
+
PermissionOption(
|
|
472
|
+
kind="reject_once",
|
|
473
|
+
name="Reject",
|
|
474
|
+
option_id="reject",
|
|
475
|
+
),
|
|
476
|
+
],
|
|
477
|
+
session_id=session_id,
|
|
478
|
+
tool_call={
|
|
479
|
+
"tool_call_id": tool_use_id,
|
|
480
|
+
"title": self._get_tool_title(tool_name, tool_input),
|
|
481
|
+
"raw_input": tool_input,
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
outcome = response.get("outcome", {})
|
|
486
|
+
if outcome.get("outcome") == "selected":
|
|
487
|
+
option_id = outcome.get("option_id")
|
|
488
|
+
if option_id in ["allow", "allow_always"]:
|
|
489
|
+
return PermissionResultAllow()
|
|
490
|
+
|
|
491
|
+
return PermissionResultDeny(message="User denied permission")
|
|
492
|
+
|
|
493
|
+
return can_use_tool
|
|
494
|
+
|
|
495
|
+
# --- Additional ACP Methods ---
|
|
496
|
+
|
|
497
|
+
async def list_sessions(
|
|
498
|
+
self,
|
|
499
|
+
cursor: str | None = None,
|
|
500
|
+
cwd: str | None = None,
|
|
501
|
+
**kwargs: Any,
|
|
502
|
+
):
|
|
503
|
+
"""List available sessions."""
|
|
504
|
+
from acp.schema import ListSessionsResponse, SessionInfo
|
|
505
|
+
|
|
506
|
+
sessions = []
|
|
507
|
+
for session_id, session in self._sessions.items():
|
|
508
|
+
if cwd is None or session.cwd == cwd:
|
|
509
|
+
sessions.append(
|
|
510
|
+
SessionInfo(
|
|
511
|
+
sessionId=session_id,
|
|
512
|
+
cwd=session.cwd,
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
return ListSessionsResponse(sessions=sessions)
|
|
517
|
+
|
|
518
|
+
async def load_session(
|
|
519
|
+
self,
|
|
520
|
+
cwd: str,
|
|
521
|
+
mcp_servers: list,
|
|
522
|
+
session_id: str,
|
|
523
|
+
**kwargs: Any,
|
|
524
|
+
):
|
|
525
|
+
"""Load an existing session."""
|
|
526
|
+
from acp.schema import LoadSessionResponse
|
|
527
|
+
|
|
528
|
+
if session_id not in self._sessions:
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
session = self._sessions[session_id]
|
|
532
|
+
session.cwd = cwd
|
|
533
|
+
|
|
534
|
+
return LoadSessionResponse()
|
|
535
|
+
|
|
536
|
+
async def fork_session(
|
|
537
|
+
self,
|
|
538
|
+
cwd: str,
|
|
539
|
+
session_id: str,
|
|
540
|
+
mcp_servers: list | None = None,
|
|
541
|
+
**kwargs: Any,
|
|
542
|
+
):
|
|
543
|
+
"""Fork an existing session."""
|
|
544
|
+
from acp.schema import ForkSessionResponse
|
|
545
|
+
|
|
546
|
+
if session_id not in self._sessions:
|
|
547
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
548
|
+
|
|
549
|
+
new_session_id = str(uuid4())
|
|
550
|
+
old_session = self._sessions[session_id]
|
|
551
|
+
|
|
552
|
+
self._sessions[new_session_id] = Session(
|
|
553
|
+
session_id=new_session_id,
|
|
554
|
+
cwd=cwd,
|
|
555
|
+
permission_mode=old_session.permission_mode,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
logger.info(f"Forked session {session_id} to {new_session_id}")
|
|
559
|
+
|
|
560
|
+
return ForkSessionResponse(session_id=new_session_id)
|
|
561
|
+
|
|
562
|
+
async def resume_session(
|
|
563
|
+
self,
|
|
564
|
+
cwd: str,
|
|
565
|
+
session_id: str,
|
|
566
|
+
mcp_servers: list | None = None,
|
|
567
|
+
**kwargs: Any,
|
|
568
|
+
):
|
|
569
|
+
"""Resume an existing session."""
|
|
570
|
+
from acp.schema import ResumeSessionResponse
|
|
571
|
+
|
|
572
|
+
if session_id not in self._sessions:
|
|
573
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
574
|
+
|
|
575
|
+
session = self._sessions[session_id]
|
|
576
|
+
session.cwd = cwd
|
|
577
|
+
session.cancelled = False
|
|
578
|
+
|
|
579
|
+
logger.info(f"Resumed session {session_id}")
|
|
580
|
+
|
|
581
|
+
return ResumeSessionResponse()
|
|
582
|
+
|
|
583
|
+
async def authenticate(self, method_id: str, **kwargs: Any):
|
|
584
|
+
"""Handle authentication requests."""
|
|
585
|
+
from acp.schema import AuthenticateResponse
|
|
586
|
+
|
|
587
|
+
logger.info(f"Authentication requested: {method_id}")
|
|
588
|
+
|
|
589
|
+
# For Claude login, the user needs to run `claude /login` in terminal
|
|
590
|
+
# The AuthenticateResponse is empty per ACP spec - auth status is handled differently
|
|
591
|
+
return AuthenticateResponse()
|
|
592
|
+
|
|
593
|
+
async def set_session_model(
|
|
594
|
+
self,
|
|
595
|
+
model_id: str,
|
|
596
|
+
session_id: str,
|
|
597
|
+
**kwargs: Any,
|
|
598
|
+
):
|
|
599
|
+
"""Set the model for a session (stub - Claude CLI handles model selection)."""
|
|
600
|
+
from acp.schema import SetSessionModelResponse
|
|
601
|
+
|
|
602
|
+
logger.info(f"Model change requested for session {session_id}: {model_id}")
|
|
603
|
+
# Note: Claude CLI handles model selection, this is just for compatibility
|
|
604
|
+
return SetSessionModelResponse()
|
|
605
|
+
|
|
606
|
+
# --- Extension Methods ---
|
|
607
|
+
|
|
608
|
+
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
609
|
+
"""Handle extension method calls."""
|
|
610
|
+
logger.info(f"Extension method: {method}")
|
|
611
|
+
return {"status": "ok"}
|
|
612
|
+
|
|
613
|
+
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
614
|
+
"""Handle extension notifications."""
|
|
615
|
+
logger.info(f"Extension notification: {method}")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-code-acp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ACP-compatible agent for Claude Code (Python version)
|
|
5
|
+
Project-URL: Homepage, https://github.com/yazelin/claude-code-acp-py
|
|
6
|
+
Project-URL: Repository, https://github.com/yazelin/claude-code-acp-py
|
|
7
|
+
Project-URL: Issues, https://github.com/yazelin/claude-code-acp-py/issues
|
|
8
|
+
Author: yazelin
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: acp,agent,anthropic,claude,claude-code,python
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: agent-client-protocol>=0.7.0
|
|
21
|
+
Requires-Dist: claude-agent-sdk>=0.1.29
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Claude Code ACP (Python)
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/claude-code-acp/)
|
|
27
|
+
[](https://pypi.org/project/claude-code-acp/)
|
|
28
|
+
[](https://github.com/yazelin/claude-code-acp-py/blob/main/LICENSE)
|
|
29
|
+
|
|
30
|
+
ACP-compatible agent for Claude Code using the Python SDK.
|
|
31
|
+
|
|
32
|
+
This package bridges the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-python) with the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/), allowing Claude Code to work with any ACP-compatible client like [Zed](https://zed.dev), Neovim, JetBrains IDEs, etc.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- Full ACP protocol support
|
|
37
|
+
- Bidirectional communication (permission requests, tool calls)
|
|
38
|
+
- Uses your Claude CLI subscription (no API key needed)
|
|
39
|
+
- Session management (create, fork, resume, list)
|
|
40
|
+
- Multiple permission modes (default, acceptEdits, plan, bypassPermissions)
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install claude-code-acp
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or with uv:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv add claude-code-acp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- Python 3.10+
|
|
57
|
+
- Claude CLI installed and authenticated (`claude /login`)
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### As a standalone ACP server
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
claude-code-acp
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### With Zed Editor
|
|
68
|
+
|
|
69
|
+
Add to your Zed `settings.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"agent_servers": {
|
|
74
|
+
"Claude Code Python": {
|
|
75
|
+
"type": "custom",
|
|
76
|
+
"command": "claude-code-acp",
|
|
77
|
+
"args": [],
|
|
78
|
+
"env": {}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then open the Agent Panel (`Ctrl+?`) and select "Claude Code Python" from the `+` menu.
|
|
85
|
+
|
|
86
|
+
### As a library
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import asyncio
|
|
90
|
+
from claude_code_acp import ClaudeAcpAgent
|
|
91
|
+
from acp import run_agent
|
|
92
|
+
|
|
93
|
+
async def main():
|
|
94
|
+
agent = ClaudeAcpAgent()
|
|
95
|
+
await run_agent(agent)
|
|
96
|
+
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
┌─────────────┐ ACP ┌──────────────────┐ SDK ┌─────────────┐
|
|
104
|
+
│ Zed/IDE │ ◄──────────► │ claude-code-acp │ ◄────────► │ Claude CLI │
|
|
105
|
+
│ (ACP Client)│ (stdio) │ (This package) │ │(Subscription)│
|
|
106
|
+
└─────────────┘ └──────────────────┘ └─────────────┘
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
claude_code_acp/__init__.py,sha256=CsH-yvcnqCtSci91gwsWeLBpvhYAX1_61QrBSndg2U0,635
|
|
2
|
+
claude_code_acp/__main__.py,sha256=zuAdIOmaCsDeRxj2yIl_qMx-74QFaA3nWiS8gti0og0,118
|
|
3
|
+
claude_code_acp/agent.py,sha256=qos32gnMAilOlz6ebllkmuJZS3UgpbAtAZw58r3SYig,20619
|
|
4
|
+
claude_code_acp-0.1.0.dist-info/METADATA,sha256=aaNmQTUTBgMVFfWKZoUxyedNUkwVc0sLjf-3DIGAOMM,3383
|
|
5
|
+
claude_code_acp-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
claude_code_acp-0.1.0.dist-info/entry_points.txt,sha256=cc_pkg_V_1zctD5CUqjATCxglkf5-UArEMfN3q9We-A,57
|
|
7
|
+
claude_code_acp-0.1.0.dist-info/licenses/LICENSE,sha256=5NCM9Q9UTfsn-VyafO7htdlYyPPO8H-NHYrO5UV9sT4,1064
|
|
8
|
+
claude_code_acp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 yazelin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|