claude-sock 0.1.0__tar.gz
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_sock-0.1.0/PKG-INFO +146 -0
- claude_sock-0.1.0/README.md +131 -0
- claude_sock-0.1.0/claude_sock/__init__.py +0 -0
- claude_sock-0.1.0/claude_sock/cli.py +112 -0
- claude_sock-0.1.0/claude_sock/orchestrator.py +454 -0
- claude_sock-0.1.0/claude_sock.egg-info/PKG-INFO +146 -0
- claude_sock-0.1.0/claude_sock.egg-info/SOURCES.txt +10 -0
- claude_sock-0.1.0/claude_sock.egg-info/dependency_links.txt +1 -0
- claude_sock-0.1.0/claude_sock.egg-info/entry_points.txt +2 -0
- claude_sock-0.1.0/claude_sock.egg-info/top_level.txt +1 -0
- claude_sock-0.1.0/pyproject.toml +27 -0
- claude_sock-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-sock
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sockpuppet for Claude Code — drive the TUI programmatically, stream JSONL like claude -p
|
|
5
|
+
Author: Daniel Huynh
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dhuynh95/claude-sock
|
|
8
|
+
Project-URL: Repository, https://github.com/dhuynh95/claude-sock
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# claude-sock
|
|
17
|
+
|
|
18
|
+
A sockpuppet for Claude Code. Drive the TUI programmatically via a pty, stream JSONL output like `claude -p`.
|
|
19
|
+
|
|
20
|
+
## Why
|
|
21
|
+
|
|
22
|
+
`claude -p` (pipe mode) is great but limited — no MCP servers, no skills, no interactive tool use. `claude-sock` spawns the full Claude Code TUI, injects keystrokes through a pty, and reads structured output from the session JSONL file. Your code gets the full power of interactive Claude Code through a simple async Python API.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install claude-sock
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Claude Code CLI (`claude`) to be installed and authenticated.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
### Python API
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from claude_sock.orchestrator import ClaudeREPL
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
async with ClaudeREPL(timeout=60) as repl:
|
|
42
|
+
messages = await repl.query("What files are in this directory?")
|
|
43
|
+
for msg in messages:
|
|
44
|
+
print(msg)
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Streaming
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
async with ClaudeREPL() as repl:
|
|
53
|
+
async for msg in repl.query_stream("Refactor main.py"):
|
|
54
|
+
print(msg)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Resume a session
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
async with ClaudeREPL(resume="ef4f97fd-d265-474e-9544-3f228e9654c0") as repl:
|
|
61
|
+
messages = await repl.query("Continue where we left off")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Load skills
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
async with ClaudeREPL() as repl:
|
|
68
|
+
await repl.send_skill("commit")
|
|
69
|
+
messages = await repl.query("Fix the login bug and commit")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### MCP servers
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# Pick servers by name from your .mcp.json
|
|
76
|
+
async with ClaudeREPL(server_names=["my-server"]) as repl:
|
|
77
|
+
messages = await repl.query("Use the my-server tool")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### CLI (drop-in `claude -p` replacement)
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
echo "Explain this repo" | claude-sock -p
|
|
84
|
+
claude-sock "What does main.py do?" --timeout 60
|
|
85
|
+
claude-sock "Continue" --resume ef4f97fd-d265-474e-9544-3f228e9654c0
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Output is JSONL, compatible with anything that reads `claude -p` format.
|
|
89
|
+
|
|
90
|
+
## How it works
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Your code
|
|
94
|
+
│
|
|
95
|
+
▼ keystrokes
|
|
96
|
+
┌─────────┐ ┌──────────────┐
|
|
97
|
+
│ pty │───────▶│ Claude Code │
|
|
98
|
+
│ (write) │ │ TUI │
|
|
99
|
+
└─────────┘ └──────┬───────┘
|
|
100
|
+
│ writes
|
|
101
|
+
▼
|
|
102
|
+
┌──────────────┐
|
|
103
|
+
│ session.jsonl │
|
|
104
|
+
└──────┬───────┘
|
|
105
|
+
│ reads
|
|
106
|
+
▼
|
|
107
|
+
┌──────────────┐
|
|
108
|
+
│ Your code │
|
|
109
|
+
│ (parsed) │
|
|
110
|
+
└──────────────┘
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **Write channel**: pty file descriptor — fire keystrokes and forget
|
|
114
|
+
- **Read channel**: `~/.claude/projects/…/{session_id}.jsonl` — poll for new lines by byte offset
|
|
115
|
+
|
|
116
|
+
## API reference
|
|
117
|
+
|
|
118
|
+
### `ClaudeREPL(timeout, session_id, workdir, env, server_names, resume)`
|
|
119
|
+
|
|
120
|
+
| Param | Type | Default | Description |
|
|
121
|
+
|---|---|---|---|
|
|
122
|
+
| `timeout` | `float` | `120` | Seconds to wait for activity before timing out |
|
|
123
|
+
| `session_id` | `str \| None` | auto-generated | Force a specific session ID |
|
|
124
|
+
| `workdir` | `Path \| None` | `cwd` | Working directory for Claude |
|
|
125
|
+
| `env` | `dict \| None` | `None` | Extra environment variables |
|
|
126
|
+
| `server_names` | `list[str] \| None` | `None` | MCP server names from `.mcp.json` |
|
|
127
|
+
| `resume` | `str \| None` | `None` | Session ID to resume |
|
|
128
|
+
|
|
129
|
+
### Methods
|
|
130
|
+
|
|
131
|
+
| Method | Returns | Description |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `query(text)` | `list[Message]` | Send a message, wait for completion |
|
|
134
|
+
| `query_stream(text)` | `AsyncIterator[Message]` | Send a message, yield parsed messages |
|
|
135
|
+
| `query_stream_raw(text)` | `AsyncIterator[dict]` | Send a message, yield raw JSONL dicts |
|
|
136
|
+
| `send_skill(name)` | `list[Message]` | Load a slash command (e.g. `"commit"`) |
|
|
137
|
+
|
|
138
|
+
### Message types
|
|
139
|
+
|
|
140
|
+
- `AssistantMessage` — contains `TextBlock` and `ToolUseBlock`
|
|
141
|
+
- `ToolResultMessage` — contains `ToolResultBlock`
|
|
142
|
+
- `ResultMessage` — turn summary (duration, cost, session ID)
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# claude-sock
|
|
2
|
+
|
|
3
|
+
A sockpuppet for Claude Code. Drive the TUI programmatically via a pty, stream JSONL output like `claude -p`.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`claude -p` (pipe mode) is great but limited — no MCP servers, no skills, no interactive tool use. `claude-sock` spawns the full Claude Code TUI, injects keystrokes through a pty, and reads structured output from the session JSONL file. Your code gets the full power of interactive Claude Code through a simple async Python API.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install claude-sock
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Claude Code CLI (`claude`) to be installed and authenticated.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
### Python API
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
from claude_sock.orchestrator import ClaudeREPL
|
|
24
|
+
|
|
25
|
+
async def main():
|
|
26
|
+
async with ClaudeREPL(timeout=60) as repl:
|
|
27
|
+
messages = await repl.query("What files are in this directory?")
|
|
28
|
+
for msg in messages:
|
|
29
|
+
print(msg)
|
|
30
|
+
|
|
31
|
+
asyncio.run(main())
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Streaming
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
async with ClaudeREPL() as repl:
|
|
38
|
+
async for msg in repl.query_stream("Refactor main.py"):
|
|
39
|
+
print(msg)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Resume a session
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
async with ClaudeREPL(resume="ef4f97fd-d265-474e-9544-3f228e9654c0") as repl:
|
|
46
|
+
messages = await repl.query("Continue where we left off")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Load skills
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
async with ClaudeREPL() as repl:
|
|
53
|
+
await repl.send_skill("commit")
|
|
54
|
+
messages = await repl.query("Fix the login bug and commit")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### MCP servers
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
# Pick servers by name from your .mcp.json
|
|
61
|
+
async with ClaudeREPL(server_names=["my-server"]) as repl:
|
|
62
|
+
messages = await repl.query("Use the my-server tool")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### CLI (drop-in `claude -p` replacement)
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
echo "Explain this repo" | claude-sock -p
|
|
69
|
+
claude-sock "What does main.py do?" --timeout 60
|
|
70
|
+
claude-sock "Continue" --resume ef4f97fd-d265-474e-9544-3f228e9654c0
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Output is JSONL, compatible with anything that reads `claude -p` format.
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
Your code
|
|
79
|
+
│
|
|
80
|
+
▼ keystrokes
|
|
81
|
+
┌─────────┐ ┌──────────────┐
|
|
82
|
+
│ pty │───────▶│ Claude Code │
|
|
83
|
+
│ (write) │ │ TUI │
|
|
84
|
+
└─────────┘ └──────┬───────┘
|
|
85
|
+
│ writes
|
|
86
|
+
▼
|
|
87
|
+
┌──────────────┐
|
|
88
|
+
│ session.jsonl │
|
|
89
|
+
└──────┬───────┘
|
|
90
|
+
│ reads
|
|
91
|
+
▼
|
|
92
|
+
┌──────────────┐
|
|
93
|
+
│ Your code │
|
|
94
|
+
│ (parsed) │
|
|
95
|
+
└──────────────┘
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **Write channel**: pty file descriptor — fire keystrokes and forget
|
|
99
|
+
- **Read channel**: `~/.claude/projects/…/{session_id}.jsonl` — poll for new lines by byte offset
|
|
100
|
+
|
|
101
|
+
## API reference
|
|
102
|
+
|
|
103
|
+
### `ClaudeREPL(timeout, session_id, workdir, env, server_names, resume)`
|
|
104
|
+
|
|
105
|
+
| Param | Type | Default | Description |
|
|
106
|
+
|---|---|---|---|
|
|
107
|
+
| `timeout` | `float` | `120` | Seconds to wait for activity before timing out |
|
|
108
|
+
| `session_id` | `str \| None` | auto-generated | Force a specific session ID |
|
|
109
|
+
| `workdir` | `Path \| None` | `cwd` | Working directory for Claude |
|
|
110
|
+
| `env` | `dict \| None` | `None` | Extra environment variables |
|
|
111
|
+
| `server_names` | `list[str] \| None` | `None` | MCP server names from `.mcp.json` |
|
|
112
|
+
| `resume` | `str \| None` | `None` | Session ID to resume |
|
|
113
|
+
|
|
114
|
+
### Methods
|
|
115
|
+
|
|
116
|
+
| Method | Returns | Description |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `query(text)` | `list[Message]` | Send a message, wait for completion |
|
|
119
|
+
| `query_stream(text)` | `AsyncIterator[Message]` | Send a message, yield parsed messages |
|
|
120
|
+
| `query_stream_raw(text)` | `AsyncIterator[dict]` | Send a message, yield raw JSONL dicts |
|
|
121
|
+
| `send_skill(name)` | `list[Message]` | Load a slash command (e.g. `"commit"`) |
|
|
122
|
+
|
|
123
|
+
### Message types
|
|
124
|
+
|
|
125
|
+
- `AssistantMessage` — contains `TextBlock` and `ToolUseBlock`
|
|
126
|
+
- `ToolResultMessage` — contains `ToolResultBlock`
|
|
127
|
+
- `ResultMessage` — turn summary (duration, cost, session ID)
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Drop-in replacement for `claude -p` that drives the Claude Code TUI.
|
|
3
|
+
|
|
4
|
+
Accepts the same CLI interface as `claude -p` so OpenClaw (and anything
|
|
5
|
+
else expecting pipe-mode Claude) can call it directly. Reads prompt from
|
|
6
|
+
stdin, streams JSONL to stdout in `claude -p` format.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from claude_sock.orchestrator import ClaudeREPL
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def emit(obj: dict) -> None:
|
|
20
|
+
print(json.dumps(obj), flush=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _extract_text(raw: dict[str, Any]) -> str:
|
|
24
|
+
"""Pull plain text from an assistant message's content blocks."""
|
|
25
|
+
msg = raw.get("message", {})
|
|
26
|
+
parts = []
|
|
27
|
+
for block in msg.get("content", []):
|
|
28
|
+
if block.get("type") == "text":
|
|
29
|
+
parts.append(block["text"])
|
|
30
|
+
return "".join(parts)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _run() -> None:
|
|
34
|
+
parser = argparse.ArgumentParser(description="claude -p compatible wrapper over Claude Code TUI.")
|
|
35
|
+
parser.add_argument("query", nargs="?", default=None, help="Message to send (or read from stdin)")
|
|
36
|
+
parser.add_argument("--timeout", type=float, default=120)
|
|
37
|
+
|
|
38
|
+
# claude -p compatible flags (accepted, mostly ignored)
|
|
39
|
+
parser.add_argument("-p", action="store_true")
|
|
40
|
+
parser.add_argument("--output-format", default=None)
|
|
41
|
+
parser.add_argument("--include-partial-messages", action="store_true")
|
|
42
|
+
parser.add_argument("--verbose", action="store_true")
|
|
43
|
+
parser.add_argument("--permission-mode", default=None)
|
|
44
|
+
parser.add_argument("--model", default=None)
|
|
45
|
+
parser.add_argument("--session-id", default=None)
|
|
46
|
+
parser.add_argument("--append-system-prompt", default=None)
|
|
47
|
+
parser.add_argument("--resume", default=None)
|
|
48
|
+
parser.add_argument("--mcp-config", default=None)
|
|
49
|
+
parser.add_argument("--strict-mcp-config", action="store_true")
|
|
50
|
+
|
|
51
|
+
args = parser.parse_args()
|
|
52
|
+
|
|
53
|
+
prompt = args.query
|
|
54
|
+
if not prompt and not sys.stdin.isatty():
|
|
55
|
+
prompt = sys.stdin.read().strip()
|
|
56
|
+
if not prompt:
|
|
57
|
+
print("Error: no prompt provided (pass as argument or via stdin)", file=sys.stderr)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
t0 = time.monotonic()
|
|
61
|
+
|
|
62
|
+
async with ClaudeREPL(timeout=args.timeout, server_names=[], resume=args.resume) as repl:
|
|
63
|
+
# 1. Init line
|
|
64
|
+
emit({
|
|
65
|
+
"type": "system",
|
|
66
|
+
"subtype": "init",
|
|
67
|
+
"session_id": repl.session_id,
|
|
68
|
+
"model": args.model or "",
|
|
69
|
+
"tools": [],
|
|
70
|
+
"mcp_servers": [],
|
|
71
|
+
"permissionMode": args.permission_mode or "default",
|
|
72
|
+
"claude_code_version": "claude-tui",
|
|
73
|
+
"uuid": repl.session_id,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
# 2. Stream — forward assistant messages, collect final text
|
|
77
|
+
final_text = ""
|
|
78
|
+
async for raw in repl.query_stream_raw(prompt):
|
|
79
|
+
t = raw.get("type")
|
|
80
|
+
if t == "assistant":
|
|
81
|
+
emit({
|
|
82
|
+
"type": "assistant",
|
|
83
|
+
"message": raw.get("message", {}),
|
|
84
|
+
"session_id": repl.session_id,
|
|
85
|
+
"uuid": raw.get("uuid", ""),
|
|
86
|
+
})
|
|
87
|
+
final_text = _extract_text(raw) or final_text
|
|
88
|
+
|
|
89
|
+
# 3. Result line
|
|
90
|
+
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
|
91
|
+
emit({
|
|
92
|
+
"type": "result",
|
|
93
|
+
"subtype": "success",
|
|
94
|
+
"is_error": False,
|
|
95
|
+
"duration_ms": elapsed_ms,
|
|
96
|
+
"num_turns": 1,
|
|
97
|
+
"result": final_text,
|
|
98
|
+
"stop_reason": "end_turn",
|
|
99
|
+
"session_id": repl.session_id,
|
|
100
|
+
"total_cost_usd": 0,
|
|
101
|
+
"usage": {},
|
|
102
|
+
"terminal_reason": "completed",
|
|
103
|
+
"uuid": repl.session_id,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> None:
|
|
108
|
+
asyncio.run(_run())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Programmatic interface to Claude Code CLI for batch evals.
|
|
3
|
+
|
|
4
|
+
Write channel: pty fd (keystrokes into the TUI) — fire and forget.
|
|
5
|
+
Read channel: session JSONL file (~/.claude/projects/…/{session_id}.jsonl)
|
|
6
|
+
|
|
7
|
+
async with ClaudeREPL() as repl:
|
|
8
|
+
await repl.send_skill("reduck-mcp")
|
|
9
|
+
messages = await repl.query("Take a screenshot")
|
|
10
|
+
|
|
11
|
+
CC JSONL format insights:
|
|
12
|
+
- CC splits a single LLM response into multiple JSONL lines: thinking, text,
|
|
13
|
+
tool_use — each with stop_reason=None except (sometimes) the last.
|
|
14
|
+
- stop_reason=end_turn is unreliable: CC sometimes writes None for the final
|
|
15
|
+
assistant message.
|
|
16
|
+
- type=progress with hookEvent=Stop is the completion signal. However,
|
|
17
|
+
agent_progress events appear MID-TURN while a subagent runs — these must
|
|
18
|
+
be excluded from completion detection.
|
|
19
|
+
- Closing the pty master fd sends SIGHUP to the child — the process must stay
|
|
20
|
+
alive until the turn completes. close() sends /exit then kills.
|
|
21
|
+
- Watermark (line offset into JSONL) scopes each wait to the current turn.
|
|
22
|
+
After completion, watermark is set via _count_lines() (not watermark +
|
|
23
|
+
len(new_lines)) to capture trailing metadata (system, file-history-snapshot).
|
|
24
|
+
- The saw_user gate prevents a previous turn's progress from triggering the
|
|
25
|
+
current turn's completion: progress from the skill turn appears before the
|
|
26
|
+
query's user message in the file, so saw_user is still False.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import fcntl
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import pty
|
|
34
|
+
import select
|
|
35
|
+
import struct
|
|
36
|
+
import subprocess
|
|
37
|
+
import tempfile
|
|
38
|
+
import termios
|
|
39
|
+
import uuid
|
|
40
|
+
from collections.abc import AsyncIterator
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
46
|
+
FOCUS_IN = b"\x1b[I"
|
|
47
|
+
POLL_INTERVAL = 0.15
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_mcp_config(server_names: list[str], cwd: Path) -> dict[str, Any]:
|
|
51
|
+
"""Pick servers from .mcp.json by name. Single source of truth."""
|
|
52
|
+
if not server_names:
|
|
53
|
+
return {"mcpServers": {}}
|
|
54
|
+
mcp_path = cwd / ".mcp.json"
|
|
55
|
+
if not mcp_path.exists():
|
|
56
|
+
raise FileNotFoundError(f"No .mcp.json found in {cwd}")
|
|
57
|
+
project_mcp = json.loads(mcp_path.read_text())
|
|
58
|
+
all_servers = project_mcp["mcpServers"]
|
|
59
|
+
missing = set(server_names) - set(all_servers)
|
|
60
|
+
if missing:
|
|
61
|
+
raise ValueError(f"MCP servers not found in .mcp.json: {missing}")
|
|
62
|
+
servers = {k: all_servers[k] for k in server_names}
|
|
63
|
+
return {"mcpServers": servers}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
KEYS: dict[str, bytes] = {
|
|
67
|
+
"enter": b"\r",
|
|
68
|
+
"escape": b"\x1b",
|
|
69
|
+
"tab": b"\t",
|
|
70
|
+
"shift+tab": b"\x1b[Z",
|
|
71
|
+
"ctrl+c": b"\x03",
|
|
72
|
+
"up": b"\x1b[A",
|
|
73
|
+
"down": b"\x1b[B",
|
|
74
|
+
"backspace": b"\x7f",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# -- Message types -------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class TextBlock:
|
|
83
|
+
text: str
|
|
84
|
+
type: str = "text"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ToolUseBlock:
|
|
89
|
+
id: str
|
|
90
|
+
name: str
|
|
91
|
+
input: dict[str, Any]
|
|
92
|
+
type: str = "tool_use"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ToolResultBlock:
|
|
97
|
+
tool_use_id: str
|
|
98
|
+
content: str
|
|
99
|
+
type: str = "tool_result"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class AssistantMessage:
|
|
104
|
+
content: list[TextBlock | ToolUseBlock]
|
|
105
|
+
model: str = ""
|
|
106
|
+
stop_reason: str = ""
|
|
107
|
+
usage: dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class ToolResultMessage:
|
|
112
|
+
content: list[ToolResultBlock]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class ResultMessage:
|
|
117
|
+
duration_ms: int = 0
|
|
118
|
+
num_turns: int = 0
|
|
119
|
+
session_id: str = ""
|
|
120
|
+
total_cost_usd: float = 0.0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
Message = AssistantMessage | ToolResultMessage | ResultMessage
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# -- Parsing -------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_assistant(raw: dict[str, Any]) -> AssistantMessage:
|
|
130
|
+
msg = raw["message"]
|
|
131
|
+
blocks: list[TextBlock | ToolUseBlock] = []
|
|
132
|
+
for b in msg.get("content", []):
|
|
133
|
+
if b.get("type") == "text":
|
|
134
|
+
blocks.append(TextBlock(text=b["text"]))
|
|
135
|
+
elif b.get("type") == "tool_use":
|
|
136
|
+
blocks.append(
|
|
137
|
+
ToolUseBlock(id=b["id"], name=b["name"], input=b.get("input", {}))
|
|
138
|
+
)
|
|
139
|
+
return AssistantMessage(
|
|
140
|
+
content=blocks,
|
|
141
|
+
model=msg.get("model", ""),
|
|
142
|
+
stop_reason=msg.get("stop_reason", ""),
|
|
143
|
+
usage=msg.get("usage", {}),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse_tool_result(raw: dict[str, Any]) -> ToolResultMessage:
|
|
148
|
+
msg = raw["message"]
|
|
149
|
+
blocks: list[ToolResultBlock] = []
|
|
150
|
+
for b in msg.get("content", []):
|
|
151
|
+
if b.get("type") == "tool_result":
|
|
152
|
+
content = b.get("content", "")
|
|
153
|
+
if isinstance(content, list):
|
|
154
|
+
content = "\n".join(
|
|
155
|
+
p.get("text", "") for p in content if p.get("type") == "text"
|
|
156
|
+
)
|
|
157
|
+
blocks.append(
|
|
158
|
+
ToolResultBlock(tool_use_id=b.get("tool_use_id", ""), content=content)
|
|
159
|
+
)
|
|
160
|
+
return ToolResultMessage(content=blocks)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_result(raw: dict[str, Any]) -> ResultMessage:
|
|
164
|
+
return ResultMessage(
|
|
165
|
+
duration_ms=raw.get("duration_ms", 0),
|
|
166
|
+
num_turns=raw.get("num_turns", 0),
|
|
167
|
+
session_id=raw.get("session_id", ""),
|
|
168
|
+
total_cost_usd=raw.get("total_cost_usd", 0.0),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_line(raw: dict[str, Any]) -> Message | None:
|
|
173
|
+
t = raw.get("type")
|
|
174
|
+
if t == "assistant":
|
|
175
|
+
return _parse_assistant(raw)
|
|
176
|
+
if t == "user" and isinstance(raw.get("message", {}).get("content"), list):
|
|
177
|
+
has_tool_result = any(
|
|
178
|
+
b.get("type") == "tool_result" for b in raw["message"]["content"]
|
|
179
|
+
)
|
|
180
|
+
if has_tool_result:
|
|
181
|
+
return _parse_tool_result(raw)
|
|
182
|
+
if t == "result":
|
|
183
|
+
return _parse_result(raw)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_done(raw: dict[str, Any]) -> bool:
|
|
188
|
+
"""True if this JSONL line signals turn completion.
|
|
189
|
+
|
|
190
|
+
Primary signal: type=progress with hookEvent=Stop (written after every
|
|
191
|
+
completed turn). agent_progress is an intermediate signal emitted while
|
|
192
|
+
a subagent is still running and must be excluded.
|
|
193
|
+
Fallback: stop_reason=end_turn on a non-tool-use assistant message,
|
|
194
|
+
because CC sometimes omits the progress line after skill turns.
|
|
195
|
+
"""
|
|
196
|
+
if raw.get("type") == "result":
|
|
197
|
+
return True
|
|
198
|
+
if raw.get("type") == "progress":
|
|
199
|
+
data = raw.get("data", {})
|
|
200
|
+
# agent_progress is mid-turn — subagent still running
|
|
201
|
+
if data.get("type") == "agent_progress":
|
|
202
|
+
return False
|
|
203
|
+
return True
|
|
204
|
+
if raw.get("type") == "assistant":
|
|
205
|
+
msg = raw.get("message", {})
|
|
206
|
+
if msg.get("stop_reason") == "end_turn":
|
|
207
|
+
has_tool_use = any(
|
|
208
|
+
b.get("type") == "tool_use" for b in msg.get("content", [])
|
|
209
|
+
)
|
|
210
|
+
if not has_tool_use:
|
|
211
|
+
return True
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _is_user_message(raw: dict[str, Any]) -> bool:
|
|
216
|
+
return raw.get("type") == "user"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# -- Path encoding -------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def encode_project_path(path: Path) -> str:
|
|
223
|
+
return str(path.resolve()).replace("/", "-").replace(".", "-")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# -- REPL ----------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ClaudeREPL:
|
|
230
|
+
"""Programmatic handle to a Claude Code TUI session.
|
|
231
|
+
|
|
232
|
+
All synchronization goes through the JSONL session file.
|
|
233
|
+
The pty is a write-only channel — fire keystrokes and forget.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
timeout: float = 120,
|
|
239
|
+
session_id: str | None = None,
|
|
240
|
+
workdir: Path | None = None,
|
|
241
|
+
env: dict[str, str] | None = None,
|
|
242
|
+
server_names: list[str] | None = None,
|
|
243
|
+
resume: str | None = None,
|
|
244
|
+
):
|
|
245
|
+
self.timeout = timeout
|
|
246
|
+
self.session_id = resume or session_id or str(uuid.uuid4())
|
|
247
|
+
self._resume = resume
|
|
248
|
+
self._workdir = (workdir or Path.cwd()).resolve()
|
|
249
|
+
self._byte_offset = 0
|
|
250
|
+
|
|
251
|
+
# Write MCP config to temp file (auto-cleaned on process exit)
|
|
252
|
+
mcp_cfg = _build_mcp_config(server_names or [], self._workdir)
|
|
253
|
+
self._mcp_tmp = tempfile.NamedTemporaryFile(
|
|
254
|
+
mode="w", suffix=".json", prefix="mcp_", delete=False
|
|
255
|
+
)
|
|
256
|
+
json.dump(mcp_cfg, self._mcp_tmp)
|
|
257
|
+
self._mcp_tmp.close()
|
|
258
|
+
|
|
259
|
+
# Spawn pty
|
|
260
|
+
child_env = {
|
|
261
|
+
k: v
|
|
262
|
+
for k, v in os.environ.items()
|
|
263
|
+
if k not in ("CLAUDE_REPL", "CLAUDECODE")
|
|
264
|
+
}
|
|
265
|
+
child_env["TERM"] = "xterm-256color"
|
|
266
|
+
if env:
|
|
267
|
+
child_env.update(env)
|
|
268
|
+
|
|
269
|
+
self._master, slave = pty.openpty()
|
|
270
|
+
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
|
|
271
|
+
|
|
272
|
+
cmd = ["claude"]
|
|
273
|
+
if self._resume:
|
|
274
|
+
cmd += ["--resume", self._resume]
|
|
275
|
+
else:
|
|
276
|
+
cmd += ["--session-id", self.session_id]
|
|
277
|
+
cmd += [
|
|
278
|
+
"--dangerously-skip-permissions",
|
|
279
|
+
"--mcp-config",
|
|
280
|
+
self._mcp_tmp.name,
|
|
281
|
+
"--strict-mcp-config",
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
self._proc = subprocess.Popen(
|
|
285
|
+
cmd,
|
|
286
|
+
stdin=slave,
|
|
287
|
+
stdout=slave,
|
|
288
|
+
stderr=slave,
|
|
289
|
+
close_fds=True,
|
|
290
|
+
cwd=self._workdir,
|
|
291
|
+
env=child_env,
|
|
292
|
+
)
|
|
293
|
+
os.close(slave)
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def session_path(self) -> Path:
|
|
297
|
+
project_dir = encode_project_path(self._workdir)
|
|
298
|
+
return PROJECTS_DIR / project_dir / f"{self.session_id}.jsonl"
|
|
299
|
+
|
|
300
|
+
# -- Pty helpers (write-only) ----------------------------------------------
|
|
301
|
+
|
|
302
|
+
def _type_text(self, text: str) -> None:
|
|
303
|
+
"""Inject characters into the pty synchronously."""
|
|
304
|
+
for c in text:
|
|
305
|
+
os.write(self._master, c.encode())
|
|
306
|
+
|
|
307
|
+
def _drain_pty(self) -> None:
|
|
308
|
+
"""Consume pty output so the buffer doesn't block."""
|
|
309
|
+
while True:
|
|
310
|
+
ready, _, _ = select.select([self._master], [], [], 0)
|
|
311
|
+
if not ready:
|
|
312
|
+
break
|
|
313
|
+
try:
|
|
314
|
+
if not os.read(self._master, 4096):
|
|
315
|
+
break
|
|
316
|
+
except OSError:
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
# -- JSONL tail (read-only, source of truth) --------------------------------
|
|
320
|
+
|
|
321
|
+
async def _tail_jsonl(self, timeout: float) -> AsyncIterator[dict[str, Any]]:
|
|
322
|
+
"""Yield new JSONL objects as they're appended to the session file.
|
|
323
|
+
|
|
324
|
+
Seeks to byte offset and reads forward — O(new bytes) per poll,
|
|
325
|
+
not O(total file). Deadline resets on every new line (activity-based).
|
|
326
|
+
"""
|
|
327
|
+
loop = asyncio.get_event_loop()
|
|
328
|
+
deadline = loop.time() + timeout
|
|
329
|
+
|
|
330
|
+
while not self.session_path.exists():
|
|
331
|
+
if loop.time() > deadline:
|
|
332
|
+
raise TimeoutError("Session file never appeared")
|
|
333
|
+
self._drain_pty()
|
|
334
|
+
await asyncio.sleep(0.2)
|
|
335
|
+
|
|
336
|
+
with open(self.session_path) as f:
|
|
337
|
+
f.seek(self._byte_offset)
|
|
338
|
+
buf = ""
|
|
339
|
+
while loop.time() < deadline:
|
|
340
|
+
chunk = f.read()
|
|
341
|
+
if chunk:
|
|
342
|
+
buf += chunk
|
|
343
|
+
while "\n" in buf:
|
|
344
|
+
line, buf = buf.split("\n", 1)
|
|
345
|
+
if line.strip():
|
|
346
|
+
self._byte_offset = f.tell() - len(buf.encode())
|
|
347
|
+
deadline = loop.time() + timeout
|
|
348
|
+
yield json.loads(line)
|
|
349
|
+
else:
|
|
350
|
+
self._drain_pty()
|
|
351
|
+
await asyncio.sleep(POLL_INTERVAL)
|
|
352
|
+
|
|
353
|
+
raise TimeoutError(f"No activity for {timeout:.0f}s")
|
|
354
|
+
|
|
355
|
+
# -- Public API ------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
async def _collect_turn(self, timeout: float) -> list[Message]:
|
|
358
|
+
"""Collect all messages until the turn completes."""
|
|
359
|
+
saw_user = False
|
|
360
|
+
msgs: list[Message] = []
|
|
361
|
+
async for raw in self._tail_jsonl(timeout):
|
|
362
|
+
if _is_user_message(raw):
|
|
363
|
+
saw_user = True
|
|
364
|
+
if not saw_user:
|
|
365
|
+
continue
|
|
366
|
+
msg = _parse_line(raw)
|
|
367
|
+
if msg:
|
|
368
|
+
msgs.append(msg)
|
|
369
|
+
if _is_done(raw):
|
|
370
|
+
return msgs
|
|
371
|
+
return msgs
|
|
372
|
+
|
|
373
|
+
async def send_skill(
|
|
374
|
+
self, name: str, timeout: float | None = None
|
|
375
|
+
) -> list[Message]:
|
|
376
|
+
"""Load a slash command. Returns messages from the skill acknowledgment turn."""
|
|
377
|
+
timeout = timeout or self.timeout
|
|
378
|
+
text = name if name.startswith("/") else f"/{name}"
|
|
379
|
+
|
|
380
|
+
self._type_text(text)
|
|
381
|
+
await asyncio.sleep(0.05)
|
|
382
|
+
os.write(self._master, KEYS["tab"])
|
|
383
|
+
await asyncio.sleep(0.3)
|
|
384
|
+
os.write(self._master, KEYS["enter"])
|
|
385
|
+
|
|
386
|
+
return await self._collect_turn(timeout)
|
|
387
|
+
|
|
388
|
+
async def query_stream_raw(
|
|
389
|
+
self, text: str, timeout: float | None = None
|
|
390
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
391
|
+
"""Send a message and yield raw JSONL dicts as they arrive."""
|
|
392
|
+
timeout = timeout or self.timeout
|
|
393
|
+
|
|
394
|
+
self._type_text(text)
|
|
395
|
+
await asyncio.sleep(0.05)
|
|
396
|
+
os.write(self._master, KEYS["enter"])
|
|
397
|
+
|
|
398
|
+
saw_user = False
|
|
399
|
+
async for raw in self._tail_jsonl(timeout):
|
|
400
|
+
if _is_user_message(raw):
|
|
401
|
+
saw_user = True
|
|
402
|
+
if saw_user:
|
|
403
|
+
yield raw
|
|
404
|
+
if saw_user and _is_done(raw):
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
async def query_stream(
|
|
408
|
+
self, text: str, timeout: float | None = None
|
|
409
|
+
) -> AsyncIterator[Message]:
|
|
410
|
+
"""Send a message and yield parsed messages as they arrive."""
|
|
411
|
+
async for raw in self.query_stream_raw(text, timeout):
|
|
412
|
+
msg = _parse_line(raw)
|
|
413
|
+
if msg:
|
|
414
|
+
yield msg
|
|
415
|
+
|
|
416
|
+
async def query(self, text: str, timeout: float | None = None) -> list[Message]:
|
|
417
|
+
"""Send a message and wait for completion. Returns all messages from the turn."""
|
|
418
|
+
return [msg async for msg in self.query_stream(text, timeout)]
|
|
419
|
+
|
|
420
|
+
# -- Lifecycle -------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
async def start(self) -> None:
|
|
423
|
+
"""Wait for TUI startup and activate input."""
|
|
424
|
+
await asyncio.sleep(5)
|
|
425
|
+
self._drain_pty()
|
|
426
|
+
os.write(self._master, FOCUS_IN)
|
|
427
|
+
await asyncio.sleep(0.2)
|
|
428
|
+
self._drain_pty()
|
|
429
|
+
if self.session_path.exists():
|
|
430
|
+
self._byte_offset = self.session_path.stat().st_size
|
|
431
|
+
|
|
432
|
+
async def close(self) -> None:
|
|
433
|
+
"""Shut down the Claude process and close the pty."""
|
|
434
|
+
try:
|
|
435
|
+
os.write(self._master, b"/exit\r")
|
|
436
|
+
await asyncio.sleep(2)
|
|
437
|
+
except OSError:
|
|
438
|
+
pass
|
|
439
|
+
self._proc.kill()
|
|
440
|
+
try:
|
|
441
|
+
os.close(self._master)
|
|
442
|
+
except OSError:
|
|
443
|
+
pass
|
|
444
|
+
try:
|
|
445
|
+
os.unlink(self._mcp_tmp.name)
|
|
446
|
+
except OSError:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
async def __aenter__(self) -> "ClaudeREPL":
|
|
450
|
+
await self.start()
|
|
451
|
+
return self
|
|
452
|
+
|
|
453
|
+
async def __aexit__(self, *args: object) -> None:
|
|
454
|
+
await self.close()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-sock
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Sockpuppet for Claude Code — drive the TUI programmatically, stream JSONL like claude -p
|
|
5
|
+
Author: Daniel Huynh
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dhuynh95/claude-sock
|
|
8
|
+
Project-URL: Repository, https://github.com/dhuynh95/claude-sock
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# claude-sock
|
|
17
|
+
|
|
18
|
+
A sockpuppet for Claude Code. Drive the TUI programmatically via a pty, stream JSONL output like `claude -p`.
|
|
19
|
+
|
|
20
|
+
## Why
|
|
21
|
+
|
|
22
|
+
`claude -p` (pipe mode) is great but limited — no MCP servers, no skills, no interactive tool use. `claude-sock` spawns the full Claude Code TUI, injects keystrokes through a pty, and reads structured output from the session JSONL file. Your code gets the full power of interactive Claude Code through a simple async Python API.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install claude-sock
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Claude Code CLI (`claude`) to be installed and authenticated.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
### Python API
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
from claude_sock.orchestrator import ClaudeREPL
|
|
39
|
+
|
|
40
|
+
async def main():
|
|
41
|
+
async with ClaudeREPL(timeout=60) as repl:
|
|
42
|
+
messages = await repl.query("What files are in this directory?")
|
|
43
|
+
for msg in messages:
|
|
44
|
+
print(msg)
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Streaming
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
async with ClaudeREPL() as repl:
|
|
53
|
+
async for msg in repl.query_stream("Refactor main.py"):
|
|
54
|
+
print(msg)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Resume a session
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
async with ClaudeREPL(resume="ef4f97fd-d265-474e-9544-3f228e9654c0") as repl:
|
|
61
|
+
messages = await repl.query("Continue where we left off")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Load skills
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
async with ClaudeREPL() as repl:
|
|
68
|
+
await repl.send_skill("commit")
|
|
69
|
+
messages = await repl.query("Fix the login bug and commit")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### MCP servers
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# Pick servers by name from your .mcp.json
|
|
76
|
+
async with ClaudeREPL(server_names=["my-server"]) as repl:
|
|
77
|
+
messages = await repl.query("Use the my-server tool")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### CLI (drop-in `claude -p` replacement)
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
echo "Explain this repo" | claude-sock -p
|
|
84
|
+
claude-sock "What does main.py do?" --timeout 60
|
|
85
|
+
claude-sock "Continue" --resume ef4f97fd-d265-474e-9544-3f228e9654c0
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Output is JSONL, compatible with anything that reads `claude -p` format.
|
|
89
|
+
|
|
90
|
+
## How it works
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Your code
|
|
94
|
+
│
|
|
95
|
+
▼ keystrokes
|
|
96
|
+
┌─────────┐ ┌──────────────┐
|
|
97
|
+
│ pty │───────▶│ Claude Code │
|
|
98
|
+
│ (write) │ │ TUI │
|
|
99
|
+
└─────────┘ └──────┬───────┘
|
|
100
|
+
│ writes
|
|
101
|
+
▼
|
|
102
|
+
┌──────────────┐
|
|
103
|
+
│ session.jsonl │
|
|
104
|
+
└──────┬───────┘
|
|
105
|
+
│ reads
|
|
106
|
+
▼
|
|
107
|
+
┌──────────────┐
|
|
108
|
+
│ Your code │
|
|
109
|
+
│ (parsed) │
|
|
110
|
+
└──────────────┘
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **Write channel**: pty file descriptor — fire keystrokes and forget
|
|
114
|
+
- **Read channel**: `~/.claude/projects/…/{session_id}.jsonl` — poll for new lines by byte offset
|
|
115
|
+
|
|
116
|
+
## API reference
|
|
117
|
+
|
|
118
|
+
### `ClaudeREPL(timeout, session_id, workdir, env, server_names, resume)`
|
|
119
|
+
|
|
120
|
+
| Param | Type | Default | Description |
|
|
121
|
+
|---|---|---|---|
|
|
122
|
+
| `timeout` | `float` | `120` | Seconds to wait for activity before timing out |
|
|
123
|
+
| `session_id` | `str \| None` | auto-generated | Force a specific session ID |
|
|
124
|
+
| `workdir` | `Path \| None` | `cwd` | Working directory for Claude |
|
|
125
|
+
| `env` | `dict \| None` | `None` | Extra environment variables |
|
|
126
|
+
| `server_names` | `list[str] \| None` | `None` | MCP server names from `.mcp.json` |
|
|
127
|
+
| `resume` | `str \| None` | `None` | Session ID to resume |
|
|
128
|
+
|
|
129
|
+
### Methods
|
|
130
|
+
|
|
131
|
+
| Method | Returns | Description |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `query(text)` | `list[Message]` | Send a message, wait for completion |
|
|
134
|
+
| `query_stream(text)` | `AsyncIterator[Message]` | Send a message, yield parsed messages |
|
|
135
|
+
| `query_stream_raw(text)` | `AsyncIterator[dict]` | Send a message, yield raw JSONL dicts |
|
|
136
|
+
| `send_skill(name)` | `list[Message]` | Load a slash command (e.g. `"commit"`) |
|
|
137
|
+
|
|
138
|
+
### Message types
|
|
139
|
+
|
|
140
|
+
- `AssistantMessage` — contains `TextBlock` and `ToolUseBlock`
|
|
141
|
+
- `ToolResultMessage` — contains `ToolResultBlock`
|
|
142
|
+
- `ResultMessage` — turn summary (duration, cost, session ID)
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
claude_sock/__init__.py
|
|
4
|
+
claude_sock/cli.py
|
|
5
|
+
claude_sock/orchestrator.py
|
|
6
|
+
claude_sock.egg-info/PKG-INFO
|
|
7
|
+
claude_sock.egg-info/SOURCES.txt
|
|
8
|
+
claude_sock.egg-info/dependency_links.txt
|
|
9
|
+
claude_sock.egg-info/entry_points.txt
|
|
10
|
+
claude_sock.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
claude_sock
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "claude-sock"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Sockpuppet for Claude Code — drive the TUI programmatically, stream JSONL like claude -p"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Daniel Huynh" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/dhuynh95/claude-sock"
|
|
24
|
+
Repository = "https://github.com/dhuynh95/claude-sock"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
claude-sock = "claude_sock.cli:main"
|