claude-p 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_p-0.1.0/.github/workflows/publish.yml +21 -0
- claude_p-0.1.0/.gitignore +9 -0
- claude_p-0.1.0/LICENSE +22 -0
- claude_p-0.1.0/PKG-INFO +128 -0
- claude_p-0.1.0/README.md +106 -0
- claude_p-0.1.0/claude-p.py +9 -0
- claude_p-0.1.0/pyproject.toml +38 -0
- claude_p-0.1.0/src/claude_p/__init__.py +16 -0
- claude_p-0.1.0/src/claude_p/__main__.py +6 -0
- claude_p-0.1.0/src/claude_p/cli.py +702 -0
- claude_p-0.1.0/src/claude_p/sdk.py +154 -0
- claude_p-0.1.0/src/claude_p/types.py +39 -0
- claude_p-0.1.0/tests/test_sdk.py +10 -0
- claude_p-0.1.0/uv.lock +8 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
release:
|
|
6
|
+
types: [published]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
id-token: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
- run: uv build
|
|
19
|
+
- run: uvx twine check dist/*
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
21
|
+
|
claude_p-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Equality Machine
|
|
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.
|
|
22
|
+
|
claude_p-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-p
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Claude -p compatible wrapper backed by interactive Claude Code subscription sessions
|
|
5
|
+
Project-URL: Homepage, https://github.com/Equality-Machine/claude-p
|
|
6
|
+
Project-URL: Repository, https://github.com/Equality-Machine/claude-p
|
|
7
|
+
Project-URL: Issues, https://github.com/Equality-Machine/claude-p/issues
|
|
8
|
+
Author: Equality Machine
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,claude,claude-code,cli,sdk
|
|
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
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# claude-p
|
|
24
|
+
|
|
25
|
+
`claude-p` is a `claude -p` compatible Python CLI and SDK backed by the
|
|
26
|
+
interactive Claude Code TUI.
|
|
27
|
+
|
|
28
|
+
Use it when `claude -p` is unavailable in an environment, but interactive
|
|
29
|
+
`claude` works with the local Claude Code subscription login state.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install claude-p
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## CLI
|
|
38
|
+
|
|
39
|
+
Default output matches `claude -p`: plain text.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude-p.py "Respond exactly: hello"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Structured outputs:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
claude-p.py "Respond exactly: hello" --output-format json
|
|
49
|
+
claude-p.py "Respond exactly: hello" --output-format stream-json --include-partial-messages
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The CLI accepts a broad subset of `claude -p` flags, including:
|
|
53
|
+
|
|
54
|
+
- `-p`, `--print`
|
|
55
|
+
- `--model`
|
|
56
|
+
- `--tools`
|
|
57
|
+
- `--permission-mode`
|
|
58
|
+
- `--output-format text|json|stream-json`
|
|
59
|
+
- `--include-partial-messages`
|
|
60
|
+
- `--session-id`
|
|
61
|
+
- `--cwd`
|
|
62
|
+
- common Claude Code context/config flags such as `--system-prompt`,
|
|
63
|
+
`--append-system-prompt`, `--mcp-config`, `--settings`, `--plugin-dir`,
|
|
64
|
+
`--allowedTools`, `--disallowedTools`, `--resume`, and `--continue`
|
|
65
|
+
|
|
66
|
+
Known limits:
|
|
67
|
+
|
|
68
|
+
- Token usage, cost, and exact rate-limit fields are best-effort placeholders.
|
|
69
|
+
- Hook lifecycle events from `claude -p --include-hook-events` are not replayed yet.
|
|
70
|
+
- `--input-format stream-json` is accepted but not implemented.
|
|
71
|
+
- `--bare` conflicts with the subscription-login goal because Claude bare mode
|
|
72
|
+
bypasses OAuth/keychain auth.
|
|
73
|
+
|
|
74
|
+
## Python SDK
|
|
75
|
+
|
|
76
|
+
The API is intentionally shaped like the official Claude Agent SDK:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import asyncio
|
|
80
|
+
from claude_p import ClaudePOptions, query
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
options = ClaudePOptions(
|
|
85
|
+
model="sonnet",
|
|
86
|
+
tools="default",
|
|
87
|
+
permission_mode="default",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async for message in query("这个目录里有多少个文件", options=options):
|
|
91
|
+
print(message)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For a single final result:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import asyncio
|
|
101
|
+
from claude_p import ClaudePClient, ClaudePOptions
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def main():
|
|
105
|
+
async with ClaudePClient(ClaudePOptions(model="sonnet")) as client:
|
|
106
|
+
result = await client.run("Respond exactly: SDK_OK")
|
|
107
|
+
print(result.result)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
asyncio.run(main())
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## How it works
|
|
114
|
+
|
|
115
|
+
The wrapper does not call `claude -p`.
|
|
116
|
+
|
|
117
|
+
It:
|
|
118
|
+
|
|
119
|
+
1. starts interactive `claude` in a pseudo-TTY;
|
|
120
|
+
2. passes a deterministic `--session-id`;
|
|
121
|
+
3. waits for the TUI response to complete;
|
|
122
|
+
4. reads Claude Code's canonical session JSONL from
|
|
123
|
+
`~/.claude/projects/**/<session-id>.jsonl`;
|
|
124
|
+
5. emits text/json/stream-json output compatible with `claude -p`.
|
|
125
|
+
|
|
126
|
+
The session JSONL is required because terminal rendering is lossy and can drop
|
|
127
|
+
characters during redraws.
|
|
128
|
+
|
claude_p-0.1.0/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# claude-p
|
|
2
|
+
|
|
3
|
+
`claude-p` is a `claude -p` compatible Python CLI and SDK backed by the
|
|
4
|
+
interactive Claude Code TUI.
|
|
5
|
+
|
|
6
|
+
Use it when `claude -p` is unavailable in an environment, but interactive
|
|
7
|
+
`claude` works with the local Claude Code subscription login state.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install claude-p
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## CLI
|
|
16
|
+
|
|
17
|
+
Default output matches `claude -p`: plain text.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude-p.py "Respond exactly: hello"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Structured outputs:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
claude-p.py "Respond exactly: hello" --output-format json
|
|
27
|
+
claude-p.py "Respond exactly: hello" --output-format stream-json --include-partial-messages
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The CLI accepts a broad subset of `claude -p` flags, including:
|
|
31
|
+
|
|
32
|
+
- `-p`, `--print`
|
|
33
|
+
- `--model`
|
|
34
|
+
- `--tools`
|
|
35
|
+
- `--permission-mode`
|
|
36
|
+
- `--output-format text|json|stream-json`
|
|
37
|
+
- `--include-partial-messages`
|
|
38
|
+
- `--session-id`
|
|
39
|
+
- `--cwd`
|
|
40
|
+
- common Claude Code context/config flags such as `--system-prompt`,
|
|
41
|
+
`--append-system-prompt`, `--mcp-config`, `--settings`, `--plugin-dir`,
|
|
42
|
+
`--allowedTools`, `--disallowedTools`, `--resume`, and `--continue`
|
|
43
|
+
|
|
44
|
+
Known limits:
|
|
45
|
+
|
|
46
|
+
- Token usage, cost, and exact rate-limit fields are best-effort placeholders.
|
|
47
|
+
- Hook lifecycle events from `claude -p --include-hook-events` are not replayed yet.
|
|
48
|
+
- `--input-format stream-json` is accepted but not implemented.
|
|
49
|
+
- `--bare` conflicts with the subscription-login goal because Claude bare mode
|
|
50
|
+
bypasses OAuth/keychain auth.
|
|
51
|
+
|
|
52
|
+
## Python SDK
|
|
53
|
+
|
|
54
|
+
The API is intentionally shaped like the official Claude Agent SDK:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from claude_p import ClaudePOptions, query
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def main():
|
|
62
|
+
options = ClaudePOptions(
|
|
63
|
+
model="sonnet",
|
|
64
|
+
tools="default",
|
|
65
|
+
permission_mode="default",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async for message in query("这个目录里有多少个文件", options=options):
|
|
69
|
+
print(message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
asyncio.run(main())
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For a single final result:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio
|
|
79
|
+
from claude_p import ClaudePClient, ClaudePOptions
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def main():
|
|
83
|
+
async with ClaudePClient(ClaudePOptions(model="sonnet")) as client:
|
|
84
|
+
result = await client.run("Respond exactly: SDK_OK")
|
|
85
|
+
print(result.result)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
asyncio.run(main())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
The wrapper does not call `claude -p`.
|
|
94
|
+
|
|
95
|
+
It:
|
|
96
|
+
|
|
97
|
+
1. starts interactive `claude` in a pseudo-TTY;
|
|
98
|
+
2. passes a deterministic `--session-id`;
|
|
99
|
+
3. waits for the TUI response to complete;
|
|
100
|
+
4. reads Claude Code's canonical session JSONL from
|
|
101
|
+
`~/.claude/projects/**/<session-id>.jsonl`;
|
|
102
|
+
5. emits text/json/stream-json output compatible with `claude -p`.
|
|
103
|
+
|
|
104
|
+
The session JSONL is required because terminal rendering is lossy and can drop
|
|
105
|
+
characters during redraws.
|
|
106
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "claude-p"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Claude -p compatible wrapper backed by interactive Claude Code subscription sessions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Equality Machine" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["claude", "claude-code", "agent", "sdk", "cli"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/Equality-Machine/claude-p"
|
|
29
|
+
Repository = "https://github.com/Equality-Machine/claude-p"
|
|
30
|
+
Issues = "https://github.com/Equality-Machine/claude-p/issues"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
claude-p = "claude_p.cli:main"
|
|
34
|
+
"claude-p.py" = "claude_p.cli:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/claude_p"]
|
|
38
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Python SDK for the interactive Claude Code `claude -p` fallback."""
|
|
2
|
+
|
|
3
|
+
from .sdk import ClaudePClient, ClaudePOptions, query
|
|
4
|
+
from .types import AssistantMessage, ResultMessage, SDKMessage, StreamEventMessage, SystemMessage
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AssistantMessage",
|
|
8
|
+
"ClaudePClient",
|
|
9
|
+
"ClaudePOptions",
|
|
10
|
+
"ResultMessage",
|
|
11
|
+
"SDKMessage",
|
|
12
|
+
"StreamEventMessage",
|
|
13
|
+
"SystemMessage",
|
|
14
|
+
"query",
|
|
15
|
+
]
|
|
16
|
+
|
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Code interactive-TUI backend with `claude -p` compatible output.
|
|
3
|
+
|
|
4
|
+
This script does not invoke `claude -p`. It starts interactive `claude` under a
|
|
5
|
+
pseudo-TTY, captures the rendered terminal, extracts the assistant answer, and
|
|
6
|
+
emits text/json/stream-json output shaped like `claude -p`.
|
|
7
|
+
|
|
8
|
+
Compatibility target:
|
|
9
|
+
- Same line-oriented JSON transport.
|
|
10
|
+
- Same core event families: system init, stream_event message_start,
|
|
11
|
+
content_block_start/delta/stop, assistant, message_delta, message_stop, result.
|
|
12
|
+
- Usage/cost/tool events are best-effort placeholders because the interactive
|
|
13
|
+
TUI does not expose a machine-readable protocol.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import glob
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
import pty
|
|
24
|
+
import re
|
|
25
|
+
import select
|
|
26
|
+
import signal
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
34
|
+
OSC_RE = re.compile(r"\x1b\][^\x07]*(?:\x07|\x1b\\)")
|
|
35
|
+
SPINNER_RE = re.compile(r"\n?[✳✶✻✽✢·].*$", re.DOTALL)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def warn(message: str) -> None:
|
|
39
|
+
print(f"claude_tui_agent.py: warning: {message}", file=sys.stderr)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def append_flag(cmd: list[str], enabled: bool, flag: str) -> None:
|
|
43
|
+
if enabled:
|
|
44
|
+
cmd.append(flag)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def append_value(cmd: list[str], flag: str, value: str | None) -> None:
|
|
48
|
+
if value is not None:
|
|
49
|
+
cmd.extend([flag, value])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def append_optional_value(cmd: list[str], flag: str, value: str | None) -> None:
|
|
53
|
+
if value is None:
|
|
54
|
+
return
|
|
55
|
+
cmd.append(flag)
|
|
56
|
+
if value:
|
|
57
|
+
cmd.append(value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def append_repeated_values(cmd: list[str], flag: str, values: list[str] | None) -> None:
|
|
61
|
+
if not values:
|
|
62
|
+
return
|
|
63
|
+
for value in values:
|
|
64
|
+
cmd.extend([flag, value])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def now_ms(start: float) -> int:
|
|
68
|
+
return int((time.time() - start) * 1000)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def emit(obj: dict, enabled: bool = True) -> None:
|
|
72
|
+
if enabled:
|
|
73
|
+
print(json.dumps(obj, ensure_ascii=False, separators=(",", ":")), flush=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def clean_terminal(text: str) -> str:
|
|
77
|
+
text = OSC_RE.sub("", text)
|
|
78
|
+
text = ANSI_RE.sub("", text)
|
|
79
|
+
return text.replace("\r", "").replace("\u00a0", " ")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_answer(text: str) -> str:
|
|
83
|
+
text = clean_terminal(text)
|
|
84
|
+
text = SPINNER_RE.sub("", text)
|
|
85
|
+
# Drop common TUI chrome if it leaked into the block.
|
|
86
|
+
text = re.split(r"\n?────────────────", text, maxsplit=1)[0]
|
|
87
|
+
return text.strip()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extract_assistant_snapshot(transcript: str) -> str:
|
|
91
|
+
clean = clean_terminal(transcript)
|
|
92
|
+
marker = clean.rfind("⏺")
|
|
93
|
+
if marker < 0:
|
|
94
|
+
return ""
|
|
95
|
+
after = clean[marker + len("⏺") :]
|
|
96
|
+
return normalize_answer(after)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def classify_failure(transcript: str, assistant_text: str, timed_out: bool) -> str | None:
|
|
100
|
+
if assistant_text:
|
|
101
|
+
return None
|
|
102
|
+
low = transcript.lower()
|
|
103
|
+
if "do you trust" in low or "workspace trust" in low:
|
|
104
|
+
return "workspace_trust_blocked"
|
|
105
|
+
if "permission" in low and ("allow" in low or "deny" in low):
|
|
106
|
+
return "tool_approval_blocked"
|
|
107
|
+
if timed_out:
|
|
108
|
+
return "assistant_output_timeout"
|
|
109
|
+
return "assistant_output_not_found"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_usage(output_text: str) -> dict:
|
|
113
|
+
# The TUI does not expose reliable token/cost data. Keep shape-compatible
|
|
114
|
+
# fields with null/zero values and mark the source in result metadata.
|
|
115
|
+
approx_output_tokens = max(1, len(output_text.split()))
|
|
116
|
+
return {
|
|
117
|
+
"input_tokens": None,
|
|
118
|
+
"cache_creation_input_tokens": None,
|
|
119
|
+
"cache_read_input_tokens": None,
|
|
120
|
+
"output_tokens": approx_output_tokens,
|
|
121
|
+
"server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0},
|
|
122
|
+
"service_tier": None,
|
|
123
|
+
"cache_creation": {"ephemeral_1h_input_tokens": None, "ephemeral_5m_input_tokens": None},
|
|
124
|
+
"iterations": [
|
|
125
|
+
{
|
|
126
|
+
"input_tokens": None,
|
|
127
|
+
"output_tokens": approx_output_tokens,
|
|
128
|
+
"cache_read_input_tokens": None,
|
|
129
|
+
"cache_creation_input_tokens": None,
|
|
130
|
+
"cache_creation": {
|
|
131
|
+
"ephemeral_5m_input_tokens": None,
|
|
132
|
+
"ephemeral_1h_input_tokens": None,
|
|
133
|
+
},
|
|
134
|
+
"type": "message",
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
"speed": None,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def extract_text_from_content(content: object) -> str:
|
|
142
|
+
if isinstance(content, str):
|
|
143
|
+
return content
|
|
144
|
+
if not isinstance(content, list):
|
|
145
|
+
return ""
|
|
146
|
+
parts: list[str] = []
|
|
147
|
+
for block in content:
|
|
148
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
149
|
+
text = block.get("text")
|
|
150
|
+
if isinstance(text, str):
|
|
151
|
+
parts.append(text)
|
|
152
|
+
return "".join(parts).strip()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def canonical_json_if_equivalent(left: str, right: str) -> str | None:
|
|
156
|
+
try:
|
|
157
|
+
left_obj = json.loads(left)
|
|
158
|
+
right_obj = json.loads(right)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
return None
|
|
161
|
+
if left_obj != right_obj:
|
|
162
|
+
return None
|
|
163
|
+
return json.dumps(right_obj, ensure_ascii=False, separators=(",", ":"))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def read_persisted_assistant(session_id: str) -> dict | None:
|
|
167
|
+
"""Read Claude Code's persisted JSONL for exact final assistant text.
|
|
168
|
+
|
|
169
|
+
The interactive terminal is a lossy rendering surface: wide glyphs, cursor
|
|
170
|
+
redraws, and spinner updates can drop or smear characters in the captured
|
|
171
|
+
TTY transcript. Claude Code still writes the canonical session JSONL for
|
|
172
|
+
interactive sessions. When available, use it as the source of truth for the
|
|
173
|
+
final assistant message while keeping the TUI transcript as provenance.
|
|
174
|
+
"""
|
|
175
|
+
pattern = str(Path.home() / ".claude" / "projects" / "**" / f"{session_id}.jsonl")
|
|
176
|
+
paths = [Path(p) for p in glob.glob(pattern, recursive=True)]
|
|
177
|
+
if not paths:
|
|
178
|
+
return None
|
|
179
|
+
path = max(paths, key=lambda p: p.stat().st_mtime)
|
|
180
|
+
latest: dict | None = None
|
|
181
|
+
try:
|
|
182
|
+
with path.open() as f:
|
|
183
|
+
for line in f:
|
|
184
|
+
try:
|
|
185
|
+
event = json.loads(line)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
continue
|
|
188
|
+
if event.get("type") != "assistant":
|
|
189
|
+
continue
|
|
190
|
+
message = event.get("message")
|
|
191
|
+
if not isinstance(message, dict):
|
|
192
|
+
continue
|
|
193
|
+
text = extract_text_from_content(message.get("content"))
|
|
194
|
+
if not text:
|
|
195
|
+
continue
|
|
196
|
+
latest = {
|
|
197
|
+
"path": str(path),
|
|
198
|
+
"text": text,
|
|
199
|
+
"message": message,
|
|
200
|
+
"model": message.get("model"),
|
|
201
|
+
"message_id": message.get("id"),
|
|
202
|
+
"usage": message.get("usage"),
|
|
203
|
+
"stop_reason": message.get("stop_reason"),
|
|
204
|
+
}
|
|
205
|
+
except OSError:
|
|
206
|
+
return None
|
|
207
|
+
return latest
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def run_tui(args: argparse.Namespace, stream_json: bool) -> tuple[str, str, int | None, bool, float]:
|
|
211
|
+
cmd = ["claude", "--session-id", args.session_id]
|
|
212
|
+
|
|
213
|
+
# Pass through options that the interactive `claude` entrypoint itself
|
|
214
|
+
# understands. Print-only options are handled by this wrapper and are not
|
|
215
|
+
# forwarded.
|
|
216
|
+
append_repeated_values(cmd, "--add-dir", args.add_dir)
|
|
217
|
+
append_value(cmd, "--agent", args.agent)
|
|
218
|
+
append_value(cmd, "--agents", args.agents)
|
|
219
|
+
append_flag(cmd, args.allow_dangerously_skip_permissions, "--allow-dangerously-skip-permissions")
|
|
220
|
+
append_repeated_values(cmd, "--allowedTools", args.allowed_tools)
|
|
221
|
+
append_value(cmd, "--append-system-prompt", args.append_system_prompt)
|
|
222
|
+
append_repeated_values(cmd, "--betas", args.betas)
|
|
223
|
+
append_flag(cmd, args.brief, "--brief")
|
|
224
|
+
append_flag(cmd, args.chrome, "--chrome")
|
|
225
|
+
append_flag(cmd, args.no_chrome, "--no-chrome")
|
|
226
|
+
append_flag(cmd, args.continue_session, "--continue")
|
|
227
|
+
append_flag(cmd, args.dangerously_skip_permissions, "--dangerously-skip-permissions")
|
|
228
|
+
append_optional_value(cmd, "--debug", args.debug)
|
|
229
|
+
append_value(cmd, "--debug-file", args.debug_file)
|
|
230
|
+
append_flag(cmd, args.disable_slash_commands, "--disable-slash-commands")
|
|
231
|
+
append_repeated_values(cmd, "--disallowedTools", args.disallowed_tools)
|
|
232
|
+
append_value(cmd, "--effort", args.effort)
|
|
233
|
+
append_flag(cmd, args.exclude_dynamic_system_prompt_sections, "--exclude-dynamic-system-prompt-sections")
|
|
234
|
+
append_repeated_values(cmd, "--file", args.files)
|
|
235
|
+
append_flag(cmd, args.fork_session, "--fork-session")
|
|
236
|
+
append_optional_value(cmd, "--from-pr", args.from_pr)
|
|
237
|
+
append_flag(cmd, args.ide, "--ide")
|
|
238
|
+
append_value(cmd, "--json-schema", args.json_schema)
|
|
239
|
+
append_repeated_values(cmd, "--mcp-config", args.mcp_config)
|
|
240
|
+
append_flag(cmd, args.mcp_debug, "--mcp-debug")
|
|
241
|
+
append_value(cmd, "--tools", args.tools)
|
|
242
|
+
append_value(cmd, "--model", args.model)
|
|
243
|
+
append_value(cmd, "--name", args.name)
|
|
244
|
+
append_value(cmd, "--permission-mode", args.permission_mode)
|
|
245
|
+
append_repeated_values(cmd, "--plugin-dir", args.plugin_dir)
|
|
246
|
+
append_value(cmd, "--remote-control-session-name-prefix", args.remote_control_session_name_prefix)
|
|
247
|
+
append_optional_value(cmd, "--resume", args.resume)
|
|
248
|
+
append_value(cmd, "--setting-sources", args.setting_sources)
|
|
249
|
+
append_value(cmd, "--settings", args.settings)
|
|
250
|
+
append_flag(cmd, args.strict_mcp_config, "--strict-mcp-config")
|
|
251
|
+
append_value(cmd, "--system-prompt", args.system_prompt)
|
|
252
|
+
append_optional_value(cmd, "--tmux", args.tmux)
|
|
253
|
+
append_optional_value(cmd, "--worktree", args.worktree)
|
|
254
|
+
|
|
255
|
+
cmd.append(args.prompt)
|
|
256
|
+
master, slave = pty.openpty()
|
|
257
|
+
env = {**os.environ, "NO_COLOR": "1", "TERM": args.term}
|
|
258
|
+
start = time.time()
|
|
259
|
+
proc = subprocess.Popen(
|
|
260
|
+
cmd,
|
|
261
|
+
stdin=slave,
|
|
262
|
+
stdout=slave,
|
|
263
|
+
stderr=slave,
|
|
264
|
+
cwd=args.cwd,
|
|
265
|
+
env=env,
|
|
266
|
+
)
|
|
267
|
+
os.close(slave)
|
|
268
|
+
|
|
269
|
+
raw = bytearray()
|
|
270
|
+
last_output = time.time()
|
|
271
|
+
last_snapshot = ""
|
|
272
|
+
timed_out = True
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
while time.time() - start < args.timeout_sec:
|
|
276
|
+
ready, _, _ = select.select([master], [], [], 0.2)
|
|
277
|
+
if ready:
|
|
278
|
+
try:
|
|
279
|
+
data = os.read(master, 65536)
|
|
280
|
+
except OSError:
|
|
281
|
+
break
|
|
282
|
+
if not data:
|
|
283
|
+
break
|
|
284
|
+
raw.extend(data)
|
|
285
|
+
last_output = time.time()
|
|
286
|
+
|
|
287
|
+
if args.emit_terminal_delta:
|
|
288
|
+
emit(
|
|
289
|
+
{
|
|
290
|
+
"type": "tui_terminal_delta",
|
|
291
|
+
"text": clean_terminal(data.decode("utf-8", "replace")),
|
|
292
|
+
"uuid": str(uuid.uuid4()),
|
|
293
|
+
"session_id": args.session_id,
|
|
294
|
+
},
|
|
295
|
+
enabled=stream_json,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
snapshot = extract_assistant_snapshot(raw.decode("utf-8", "replace"))
|
|
299
|
+
if snapshot and snapshot != last_snapshot:
|
|
300
|
+
if args.live_tui_deltas:
|
|
301
|
+
delta = snapshot[len(last_snapshot) :] if snapshot.startswith(last_snapshot) else snapshot
|
|
302
|
+
if delta.strip():
|
|
303
|
+
emit(
|
|
304
|
+
{
|
|
305
|
+
"type": "stream_event",
|
|
306
|
+
"event": {
|
|
307
|
+
"type": "content_block_delta",
|
|
308
|
+
"index": 0,
|
|
309
|
+
"delta": {"type": "text_delta", "text": delta},
|
|
310
|
+
},
|
|
311
|
+
"session_id": args.session_id,
|
|
312
|
+
"parent_tool_use_id": None,
|
|
313
|
+
"uuid": str(uuid.uuid4()),
|
|
314
|
+
},
|
|
315
|
+
enabled=stream_json,
|
|
316
|
+
)
|
|
317
|
+
last_snapshot = snapshot
|
|
318
|
+
|
|
319
|
+
transcript = raw.decode("utf-8", "replace")
|
|
320
|
+
if last_snapshot and time.time() - last_output >= args.quiet_after_sec:
|
|
321
|
+
timed_out = False
|
|
322
|
+
break
|
|
323
|
+
if proc.poll() is not None:
|
|
324
|
+
timed_out = False
|
|
325
|
+
break
|
|
326
|
+
finally:
|
|
327
|
+
if proc.poll() is None:
|
|
328
|
+
proc.send_signal(signal.SIGTERM)
|
|
329
|
+
try:
|
|
330
|
+
proc.wait(timeout=2)
|
|
331
|
+
except subprocess.TimeoutExpired:
|
|
332
|
+
proc.kill()
|
|
333
|
+
os.close(master)
|
|
334
|
+
|
|
335
|
+
transcript = clean_terminal(raw.decode("utf-8", "replace"))
|
|
336
|
+
answer = extract_assistant_snapshot(transcript)
|
|
337
|
+
return transcript, answer, proc.returncode, timed_out, start
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main() -> int:
|
|
341
|
+
parser = argparse.ArgumentParser()
|
|
342
|
+
parser.add_argument("prompt", nargs="?")
|
|
343
|
+
parser.add_argument("--cwd", default=os.getcwd())
|
|
344
|
+
|
|
345
|
+
# Common Claude Code options. The goal is CLI compatibility with the print
|
|
346
|
+
# path while still using the interactive TUI backend internally.
|
|
347
|
+
parser.add_argument("-p", "--print", dest="print_mode", action="store_true", help="Accepted for claude -p compatibility.")
|
|
348
|
+
parser.add_argument("--add-dir", action="append", default=[])
|
|
349
|
+
parser.add_argument("--agent")
|
|
350
|
+
parser.add_argument("--agents")
|
|
351
|
+
parser.add_argument("--allow-dangerously-skip-permissions", action="store_true")
|
|
352
|
+
parser.add_argument("--allowedTools", "--allowed-tools", dest="allowed_tools", action="append", default=[])
|
|
353
|
+
parser.add_argument("--append-system-prompt")
|
|
354
|
+
parser.add_argument("--bare", action="store_true")
|
|
355
|
+
parser.add_argument("--betas", action="append", default=[])
|
|
356
|
+
parser.add_argument("--brief", action="store_true")
|
|
357
|
+
parser.add_argument("--chrome", action="store_true")
|
|
358
|
+
parser.add_argument("--no-chrome", action="store_true")
|
|
359
|
+
parser.add_argument("-c", "--continue", dest="continue_session", action="store_true")
|
|
360
|
+
parser.add_argument("--dangerously-skip-permissions", action="store_true")
|
|
361
|
+
parser.add_argument("-d", "--debug", nargs="?", const="")
|
|
362
|
+
parser.add_argument("--debug-file")
|
|
363
|
+
parser.add_argument("--disable-slash-commands", action="store_true")
|
|
364
|
+
parser.add_argument("--disallowedTools", "--disallowed-tools", dest="disallowed_tools", action="append", default=[])
|
|
365
|
+
parser.add_argument("--effort")
|
|
366
|
+
parser.add_argument("--exclude-dynamic-system-prompt-sections", action="store_true")
|
|
367
|
+
parser.add_argument("--fallback-model")
|
|
368
|
+
parser.add_argument("--file", dest="files", action="append", default=[])
|
|
369
|
+
parser.add_argument("--fork-session", action="store_true")
|
|
370
|
+
parser.add_argument("--from-pr", nargs="?", const="")
|
|
371
|
+
parser.add_argument("--ide", action="store_true")
|
|
372
|
+
parser.add_argument("--model", default="sonnet")
|
|
373
|
+
parser.add_argument("--tools", default="default")
|
|
374
|
+
parser.add_argument("--permission-mode", default="default")
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"--output-format",
|
|
377
|
+
choices=["text", "json", "stream-json"],
|
|
378
|
+
default="text",
|
|
379
|
+
help="Output format, matching claude -p. Default: text.",
|
|
380
|
+
)
|
|
381
|
+
parser.add_argument("--verbose", action="store_true", help="Accepted for claude -p CLI compatibility.")
|
|
382
|
+
parser.add_argument("--include-hook-events", action="store_true")
|
|
383
|
+
parser.add_argument(
|
|
384
|
+
"--include-partial-messages",
|
|
385
|
+
action="store_true",
|
|
386
|
+
help="Accepted for claude -p CLI compatibility. With the TUI backend, stream-json emits one final text delta by default.",
|
|
387
|
+
)
|
|
388
|
+
parser.add_argument("--input-format", choices=["text", "stream-json"], default="text")
|
|
389
|
+
parser.add_argument("--json-schema")
|
|
390
|
+
parser.add_argument("--max-budget-usd")
|
|
391
|
+
parser.add_argument("--mcp-config", action="append", default=[])
|
|
392
|
+
parser.add_argument("--mcp-debug", action="store_true")
|
|
393
|
+
parser.add_argument("-n", "--name")
|
|
394
|
+
parser.add_argument("--no-session-persistence", action="store_true")
|
|
395
|
+
parser.add_argument("--plugin-dir", action="append", default=[])
|
|
396
|
+
parser.add_argument("--remote-control-session-name-prefix")
|
|
397
|
+
parser.add_argument("--replay-user-messages", action="store_true")
|
|
398
|
+
parser.add_argument("-r", "--resume", nargs="?", const="")
|
|
399
|
+
parser.add_argument("--setting-sources")
|
|
400
|
+
parser.add_argument("--settings")
|
|
401
|
+
parser.add_argument("--strict-mcp-config", action="store_true")
|
|
402
|
+
parser.add_argument("--system-prompt")
|
|
403
|
+
parser.add_argument("--tmux", nargs="?", const="")
|
|
404
|
+
parser.add_argument("-v", "--version", action="store_true")
|
|
405
|
+
parser.add_argument("-w", "--worktree", nargs="?", const="")
|
|
406
|
+
|
|
407
|
+
# Wrapper-only controls.
|
|
408
|
+
parser.add_argument("--timeout-sec", type=float, default=90)
|
|
409
|
+
parser.add_argument("--quiet-after-sec", type=float, default=3)
|
|
410
|
+
parser.add_argument("--session-id", default=str(uuid.uuid4()))
|
|
411
|
+
parser.add_argument("--term", default="xterm-256color")
|
|
412
|
+
parser.add_argument("--raw-log")
|
|
413
|
+
parser.add_argument("--emit-terminal-delta", action="store_true")
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"--live-tui-deltas",
|
|
416
|
+
action="store_true",
|
|
417
|
+
help="Emit live text deltas from the lossy TUI surface. Default buffers until persisted JSONL final text is available.",
|
|
418
|
+
)
|
|
419
|
+
args = parser.parse_args()
|
|
420
|
+
|
|
421
|
+
if args.version:
|
|
422
|
+
subprocess.run(["claude", "--version"], check=False)
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
if args.prompt is None:
|
|
426
|
+
if sys.stdin.isatty():
|
|
427
|
+
parser.error("prompt is required unless stdin provides input")
|
|
428
|
+
args.prompt = sys.stdin.read()
|
|
429
|
+
|
|
430
|
+
unsupported: list[str] = []
|
|
431
|
+
if args.input_format != "text":
|
|
432
|
+
unsupported.append("--input-format stream-json")
|
|
433
|
+
if args.replay_user_messages:
|
|
434
|
+
unsupported.append("--replay-user-messages")
|
|
435
|
+
if args.no_session_persistence:
|
|
436
|
+
unsupported.append("--no-session-persistence")
|
|
437
|
+
if args.bare:
|
|
438
|
+
unsupported.append("--bare")
|
|
439
|
+
if args.max_budget_usd:
|
|
440
|
+
unsupported.append("--max-budget-usd")
|
|
441
|
+
if args.fallback_model:
|
|
442
|
+
unsupported.append("--fallback-model")
|
|
443
|
+
if unsupported:
|
|
444
|
+
for flag in unsupported:
|
|
445
|
+
warn(f"{flag} is not supported by the interactive subscription backend; continuing without exact claude -p semantics")
|
|
446
|
+
|
|
447
|
+
stream_json = args.output_format == "stream-json"
|
|
448
|
+
message_id = f"msg_tui_{uuid.uuid4().hex[:24]}"
|
|
449
|
+
start = time.time()
|
|
450
|
+
|
|
451
|
+
emit(
|
|
452
|
+
{
|
|
453
|
+
"type": "system",
|
|
454
|
+
"subtype": "init",
|
|
455
|
+
"cwd": args.cwd,
|
|
456
|
+
"session_id": args.session_id,
|
|
457
|
+
"tools": [],
|
|
458
|
+
"mcp_servers": [],
|
|
459
|
+
"model": args.model,
|
|
460
|
+
"permissionMode": args.permission_mode,
|
|
461
|
+
"apiKeySource": "interactive_tui_subscription",
|
|
462
|
+
"claude_code_version": None,
|
|
463
|
+
"output_style": "default",
|
|
464
|
+
"uuid": str(uuid.uuid4()),
|
|
465
|
+
"fast_mode_state": "off",
|
|
466
|
+
},
|
|
467
|
+
enabled=stream_json,
|
|
468
|
+
)
|
|
469
|
+
emit(
|
|
470
|
+
{
|
|
471
|
+
"type": "system",
|
|
472
|
+
"subtype": "status",
|
|
473
|
+
"status": "requesting",
|
|
474
|
+
"uuid": str(uuid.uuid4()),
|
|
475
|
+
"session_id": args.session_id,
|
|
476
|
+
},
|
|
477
|
+
enabled=stream_json,
|
|
478
|
+
)
|
|
479
|
+
emit(
|
|
480
|
+
{
|
|
481
|
+
"type": "stream_event",
|
|
482
|
+
"event": {
|
|
483
|
+
"type": "message_start",
|
|
484
|
+
"message": {
|
|
485
|
+
"model": args.model,
|
|
486
|
+
"id": message_id,
|
|
487
|
+
"type": "message",
|
|
488
|
+
"role": "assistant",
|
|
489
|
+
"content": [],
|
|
490
|
+
"stop_reason": None,
|
|
491
|
+
"stop_sequence": None,
|
|
492
|
+
"stop_details": None,
|
|
493
|
+
"usage": {
|
|
494
|
+
"input_tokens": None,
|
|
495
|
+
"cache_creation_input_tokens": None,
|
|
496
|
+
"cache_read_input_tokens": None,
|
|
497
|
+
"output_tokens": None,
|
|
498
|
+
"service_tier": None,
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
"session_id": args.session_id,
|
|
503
|
+
"parent_tool_use_id": None,
|
|
504
|
+
"uuid": str(uuid.uuid4()),
|
|
505
|
+
"ttft_ms": None,
|
|
506
|
+
},
|
|
507
|
+
enabled=stream_json,
|
|
508
|
+
)
|
|
509
|
+
emit(
|
|
510
|
+
{
|
|
511
|
+
"type": "stream_event",
|
|
512
|
+
"event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
|
513
|
+
"session_id": args.session_id,
|
|
514
|
+
"parent_tool_use_id": None,
|
|
515
|
+
"uuid": str(uuid.uuid4()),
|
|
516
|
+
},
|
|
517
|
+
enabled=stream_json,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
transcript, tui_answer, exit_code, timed_out, run_start = run_tui(args, stream_json)
|
|
521
|
+
if args.raw_log:
|
|
522
|
+
path = Path(args.raw_log)
|
|
523
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
524
|
+
path.write_text(transcript)
|
|
525
|
+
|
|
526
|
+
persisted = read_persisted_assistant(args.session_id)
|
|
527
|
+
answer = persisted["text"] if persisted else tui_answer
|
|
528
|
+
final_answer_source = "session_jsonl" if persisted else "tui_transcript"
|
|
529
|
+
if persisted and tui_answer and tui_answer != persisted["text"]:
|
|
530
|
+
canonical = canonical_json_if_equivalent(tui_answer, persisted["text"])
|
|
531
|
+
if canonical is not None:
|
|
532
|
+
answer = canonical
|
|
533
|
+
final_answer_source = "json_canonicalized_from_matching_tui_and_session_jsonl"
|
|
534
|
+
final_model = persisted.get("model") if persisted else args.model
|
|
535
|
+
message_id = persisted.get("message_id") if persisted and persisted.get("message_id") else message_id
|
|
536
|
+
|
|
537
|
+
if answer and not args.live_tui_deltas:
|
|
538
|
+
emit(
|
|
539
|
+
{
|
|
540
|
+
"type": "stream_event",
|
|
541
|
+
"event": {
|
|
542
|
+
"type": "content_block_delta",
|
|
543
|
+
"index": 0,
|
|
544
|
+
"delta": {"type": "text_delta", "text": answer},
|
|
545
|
+
},
|
|
546
|
+
"session_id": args.session_id,
|
|
547
|
+
"parent_tool_use_id": None,
|
|
548
|
+
"uuid": str(uuid.uuid4()),
|
|
549
|
+
},
|
|
550
|
+
enabled=stream_json,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
failure = classify_failure(transcript, answer, timed_out)
|
|
554
|
+
is_error = failure is not None
|
|
555
|
+
usage = build_usage(answer)
|
|
556
|
+
duration_ms = now_ms(start)
|
|
557
|
+
|
|
558
|
+
if args.output_format == "text":
|
|
559
|
+
if answer:
|
|
560
|
+
print(answer)
|
|
561
|
+
return 0 if not is_error else 2
|
|
562
|
+
|
|
563
|
+
if args.output_format == "json":
|
|
564
|
+
print(
|
|
565
|
+
json.dumps(
|
|
566
|
+
{
|
|
567
|
+
"type": "result",
|
|
568
|
+
"subtype": "success" if not is_error else "error",
|
|
569
|
+
"is_error": is_error,
|
|
570
|
+
"duration_ms": duration_ms,
|
|
571
|
+
"duration_api_ms": None,
|
|
572
|
+
"num_turns": 1,
|
|
573
|
+
"result": answer,
|
|
574
|
+
"session_id": args.session_id,
|
|
575
|
+
"total_cost_usd": None,
|
|
576
|
+
"usage": usage,
|
|
577
|
+
"terminal_reason": "completed" if not is_error else failure,
|
|
578
|
+
"interactive_tui_backend": {
|
|
579
|
+
"raw_log": args.raw_log,
|
|
580
|
+
"session_jsonl": persisted.get("path") if persisted else None,
|
|
581
|
+
"tui_answer": tui_answer,
|
|
582
|
+
"final_answer_source": final_answer_source,
|
|
583
|
+
"timed_out": timed_out,
|
|
584
|
+
"exit_code": exit_code,
|
|
585
|
+
"extraction_confidence": "high" if persisted else ("medium" if answer else "none"),
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
ensure_ascii=False,
|
|
589
|
+
separators=(",", ":"),
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
return 0 if not is_error else 2
|
|
593
|
+
|
|
594
|
+
emit(
|
|
595
|
+
{
|
|
596
|
+
"type": "assistant",
|
|
597
|
+
"message": {
|
|
598
|
+
"model": final_model,
|
|
599
|
+
"id": message_id,
|
|
600
|
+
"type": "message",
|
|
601
|
+
"role": "assistant",
|
|
602
|
+
"content": [{"type": "text", "text": answer}] if answer else [],
|
|
603
|
+
"stop_reason": "end_turn" if not is_error else None,
|
|
604
|
+
"stop_sequence": None,
|
|
605
|
+
"stop_details": None,
|
|
606
|
+
"usage": {
|
|
607
|
+
"input_tokens": None,
|
|
608
|
+
"cache_creation_input_tokens": None,
|
|
609
|
+
"cache_read_input_tokens": None,
|
|
610
|
+
"output_tokens": usage["output_tokens"],
|
|
611
|
+
"service_tier": None,
|
|
612
|
+
},
|
|
613
|
+
"context_management": None,
|
|
614
|
+
},
|
|
615
|
+
"parent_tool_use_id": None,
|
|
616
|
+
"session_id": args.session_id,
|
|
617
|
+
"uuid": str(uuid.uuid4()),
|
|
618
|
+
}
|
|
619
|
+
)
|
|
620
|
+
emit(
|
|
621
|
+
{
|
|
622
|
+
"type": "stream_event",
|
|
623
|
+
"event": {"type": "content_block_stop", "index": 0},
|
|
624
|
+
"session_id": args.session_id,
|
|
625
|
+
"parent_tool_use_id": None,
|
|
626
|
+
"uuid": str(uuid.uuid4()),
|
|
627
|
+
}
|
|
628
|
+
)
|
|
629
|
+
emit(
|
|
630
|
+
{
|
|
631
|
+
"type": "stream_event",
|
|
632
|
+
"event": {
|
|
633
|
+
"type": "message_delta",
|
|
634
|
+
"delta": {"stop_reason": "end_turn" if not is_error else "error", "stop_sequence": None, "stop_details": None},
|
|
635
|
+
"usage": {
|
|
636
|
+
"input_tokens": None,
|
|
637
|
+
"cache_creation_input_tokens": None,
|
|
638
|
+
"cache_read_input_tokens": None,
|
|
639
|
+
"output_tokens": usage["output_tokens"],
|
|
640
|
+
"iterations": usage["iterations"],
|
|
641
|
+
},
|
|
642
|
+
"context_management": {"applied_edits": []},
|
|
643
|
+
},
|
|
644
|
+
"session_id": args.session_id,
|
|
645
|
+
"parent_tool_use_id": None,
|
|
646
|
+
"uuid": str(uuid.uuid4()),
|
|
647
|
+
}
|
|
648
|
+
)
|
|
649
|
+
emit(
|
|
650
|
+
{
|
|
651
|
+
"type": "stream_event",
|
|
652
|
+
"event": {"type": "message_stop"},
|
|
653
|
+
"session_id": args.session_id,
|
|
654
|
+
"parent_tool_use_id": None,
|
|
655
|
+
"uuid": str(uuid.uuid4()),
|
|
656
|
+
}
|
|
657
|
+
)
|
|
658
|
+
emit(
|
|
659
|
+
{
|
|
660
|
+
"type": "rate_limit_event",
|
|
661
|
+
"rate_limit_info": {"status": "unknown"},
|
|
662
|
+
"session_id": args.session_id,
|
|
663
|
+
"uuid": str(uuid.uuid4()),
|
|
664
|
+
}
|
|
665
|
+
)
|
|
666
|
+
emit(
|
|
667
|
+
{
|
|
668
|
+
"type": "result",
|
|
669
|
+
"subtype": "success" if not is_error else "error",
|
|
670
|
+
"is_error": is_error,
|
|
671
|
+
"api_error_status": None,
|
|
672
|
+
"duration_ms": duration_ms,
|
|
673
|
+
"duration_api_ms": None,
|
|
674
|
+
"num_turns": 1,
|
|
675
|
+
"result": answer,
|
|
676
|
+
"stop_reason": "end_turn" if not is_error else None,
|
|
677
|
+
"session_id": args.session_id,
|
|
678
|
+
"total_cost_usd": None,
|
|
679
|
+
"usage": usage,
|
|
680
|
+
"modelUsage": {},
|
|
681
|
+
"permission_denials": [],
|
|
682
|
+
"terminal_reason": "completed" if not is_error else failure,
|
|
683
|
+
"fast_mode_state": "off",
|
|
684
|
+
"uuid": str(uuid.uuid4()),
|
|
685
|
+
"interactive_tui_backend": {
|
|
686
|
+
"raw_log": args.raw_log,
|
|
687
|
+
"session_jsonl": persisted.get("path") if persisted else None,
|
|
688
|
+
"tui_answer": tui_answer,
|
|
689
|
+
"final_answer_source": final_answer_source,
|
|
690
|
+
"timed_out": timed_out,
|
|
691
|
+
"exit_code": exit_code,
|
|
692
|
+
"extraction_confidence": "high" if persisted else ("medium" if answer else "none"),
|
|
693
|
+
"compatibility_note": "Shape-compatible with claude -p stream-json core events; usage/cost/tool events are best-effort because TUI has no machine protocol.",
|
|
694
|
+
},
|
|
695
|
+
}
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
return 0 if not is_error else 2
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
if __name__ == "__main__":
|
|
702
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, AsyncIterator, Iterable
|
|
9
|
+
|
|
10
|
+
from .types import AssistantMessage, ResultMessage, SDKMessage, StreamEventMessage, SystemMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ClaudePOptions:
|
|
15
|
+
"""Options for the interactive Claude Code `claude -p` fallback.
|
|
16
|
+
|
|
17
|
+
The names mirror the official Claude Agent SDK's options style while
|
|
18
|
+
preserving CLI compatibility with `claude -p`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
cwd: str | None = None
|
|
22
|
+
model: str = "sonnet"
|
|
23
|
+
tools: str | Iterable[str] = "default"
|
|
24
|
+
permission_mode: str = "default"
|
|
25
|
+
output_format: str = "stream-json"
|
|
26
|
+
system_prompt: str | None = None
|
|
27
|
+
append_system_prompt: str | None = None
|
|
28
|
+
allowed_tools: list[str] = field(default_factory=list)
|
|
29
|
+
disallowed_tools: list[str] = field(default_factory=list)
|
|
30
|
+
mcp_config: list[str] = field(default_factory=list)
|
|
31
|
+
settings: str | None = None
|
|
32
|
+
session_id: str | None = None
|
|
33
|
+
timeout_sec: float = 90
|
|
34
|
+
quiet_after_sec: float = 3
|
|
35
|
+
raw_log: str | None = None
|
|
36
|
+
include_partial_messages: bool = True
|
|
37
|
+
executable: str | None = None
|
|
38
|
+
extra_args: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
def command(self, prompt: str) -> list[str]:
|
|
41
|
+
if self.executable:
|
|
42
|
+
cmd = [self.executable, prompt]
|
|
43
|
+
else:
|
|
44
|
+
cmd = [sys.executable, "-m", "claude_p.cli", prompt]
|
|
45
|
+
cmd.extend(["--model", self.model])
|
|
46
|
+
if isinstance(self.tools, str):
|
|
47
|
+
cmd.extend(["--tools", self.tools])
|
|
48
|
+
else:
|
|
49
|
+
cmd.extend(["--tools", ",".join(self.tools)])
|
|
50
|
+
cmd.extend(["--permission-mode", self.permission_mode])
|
|
51
|
+
cmd.extend(["--output-format", self.output_format])
|
|
52
|
+
cmd.extend(["--timeout-sec", str(self.timeout_sec)])
|
|
53
|
+
cmd.extend(["--quiet-after-sec", str(self.quiet_after_sec)])
|
|
54
|
+
if self.cwd:
|
|
55
|
+
cmd.extend(["--cwd", self.cwd])
|
|
56
|
+
if self.system_prompt:
|
|
57
|
+
cmd.extend(["--system-prompt", self.system_prompt])
|
|
58
|
+
if self.append_system_prompt:
|
|
59
|
+
cmd.extend(["--append-system-prompt", self.append_system_prompt])
|
|
60
|
+
for tool in self.allowed_tools:
|
|
61
|
+
cmd.extend(["--allowedTools", tool])
|
|
62
|
+
for tool in self.disallowed_tools:
|
|
63
|
+
cmd.extend(["--disallowedTools", tool])
|
|
64
|
+
for config in self.mcp_config:
|
|
65
|
+
cmd.extend(["--mcp-config", config])
|
|
66
|
+
if self.settings:
|
|
67
|
+
cmd.extend(["--settings", self.settings])
|
|
68
|
+
if self.session_id:
|
|
69
|
+
cmd.extend(["--session-id", self.session_id])
|
|
70
|
+
if self.raw_log:
|
|
71
|
+
cmd.extend(["--raw-log", self.raw_log])
|
|
72
|
+
if self.include_partial_messages:
|
|
73
|
+
cmd.append("--include-partial-messages")
|
|
74
|
+
cmd.extend(self.extra_args)
|
|
75
|
+
return cmd
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _message_from_raw(raw: dict[str, Any]) -> SDKMessage:
|
|
79
|
+
typ = raw.get("type")
|
|
80
|
+
if typ == "system":
|
|
81
|
+
return SystemMessage(type="system", raw=raw, subtype=raw.get("subtype"))
|
|
82
|
+
if typ == "stream_event":
|
|
83
|
+
return StreamEventMessage(type="stream_event", raw=raw, event=raw.get("event"))
|
|
84
|
+
if typ == "assistant":
|
|
85
|
+
content = raw.get("message", {}).get("content", [])
|
|
86
|
+
text_parts = [
|
|
87
|
+
block.get("text", "")
|
|
88
|
+
for block in content
|
|
89
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
90
|
+
]
|
|
91
|
+
return AssistantMessage(type="assistant", raw=raw, text="".join(text_parts))
|
|
92
|
+
if typ == "result":
|
|
93
|
+
return ResultMessage(
|
|
94
|
+
type="result",
|
|
95
|
+
raw=raw,
|
|
96
|
+
result=raw.get("result", ""),
|
|
97
|
+
is_error=bool(raw.get("is_error")),
|
|
98
|
+
session_id=raw.get("session_id"),
|
|
99
|
+
terminal_reason=raw.get("terminal_reason"),
|
|
100
|
+
)
|
|
101
|
+
return SDKMessage(type=str(typ or "raw"), raw=raw)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def query(prompt: str, *, options: ClaudePOptions | None = None) -> AsyncIterator[SDKMessage]:
|
|
105
|
+
"""Run a prompt and yield stream-json messages.
|
|
106
|
+
|
|
107
|
+
This mirrors the official Claude Agent SDK's `query(...)` shape, but the
|
|
108
|
+
backend is interactive Claude Code instead of `claude -p`.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
options = options or ClaudePOptions()
|
|
112
|
+
options.output_format = "stream-json"
|
|
113
|
+
proc = await asyncio.create_subprocess_exec(
|
|
114
|
+
*options.command(prompt),
|
|
115
|
+
cwd=options.cwd or os.getcwd(),
|
|
116
|
+
stdout=asyncio.subprocess.PIPE,
|
|
117
|
+
stderr=asyncio.subprocess.PIPE,
|
|
118
|
+
)
|
|
119
|
+
assert proc.stdout is not None
|
|
120
|
+
async for raw_line in proc.stdout:
|
|
121
|
+
line = raw_line.decode("utf-8", "replace").strip()
|
|
122
|
+
if not line:
|
|
123
|
+
continue
|
|
124
|
+
yield _message_from_raw(json.loads(line))
|
|
125
|
+
stderr = await proc.stderr.read() if proc.stderr else b""
|
|
126
|
+
code = await proc.wait()
|
|
127
|
+
if code != 0:
|
|
128
|
+
raise RuntimeError(stderr.decode("utf-8", "replace") or f"claude-p exited with {code}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ClaudePClient:
|
|
132
|
+
"""Small client wrapper inspired by ClaudeSDKClient."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, options: ClaudePOptions | None = None):
|
|
135
|
+
self.options = options or ClaudePOptions()
|
|
136
|
+
|
|
137
|
+
async def __aenter__(self) -> "ClaudePClient":
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
async def query(self, prompt: str) -> AsyncIterator[SDKMessage]:
|
|
144
|
+
async for message in query(prompt, options=self.options):
|
|
145
|
+
yield message
|
|
146
|
+
|
|
147
|
+
async def run(self, prompt: str) -> ResultMessage:
|
|
148
|
+
result: ResultMessage | None = None
|
|
149
|
+
async for message in self.query(prompt):
|
|
150
|
+
if isinstance(message, ResultMessage):
|
|
151
|
+
result = message
|
|
152
|
+
if result is None:
|
|
153
|
+
raise RuntimeError("claude-p did not emit a result message")
|
|
154
|
+
return result
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SDKMessage:
|
|
9
|
+
"""Base message wrapper returned by the Python SDK."""
|
|
10
|
+
|
|
11
|
+
type: str
|
|
12
|
+
raw: dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SystemMessage(SDKMessage):
|
|
17
|
+
subtype: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class StreamEventMessage(SDKMessage):
|
|
22
|
+
event: dict[str, Any] | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AssistantMessage(SDKMessage):
|
|
27
|
+
text: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ResultMessage(SDKMessage):
|
|
32
|
+
result: str
|
|
33
|
+
is_error: bool
|
|
34
|
+
session_id: str | None = None
|
|
35
|
+
terminal_reason: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
MessageKind = Literal["system", "stream_event", "assistant", "result", "raw"]
|
|
39
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from claude_p import ClaudePOptions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_options_command_includes_stream_json():
|
|
5
|
+
cmd = ClaudePOptions(model="sonnet", tools="").command("hello")
|
|
6
|
+
assert "--output-format" in cmd
|
|
7
|
+
assert "stream-json" in cmd
|
|
8
|
+
assert "--tools" in cmd
|
|
9
|
+
assert "" in cmd
|
|
10
|
+
|