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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ claude-sock = claude_sock.cli:main
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+