Skip to content

Commit 03a7569

Browse files
committed
add json-verbose output format with full tool call history
1 parent c4dd75f commit 03a7569

File tree

4 files changed

+209
-7
lines changed

4 files changed

+209
-7
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ WORKDIR /workspace
6262
COPY entrypoint.sh /home/claude/entrypoint.sh
6363
COPY api_server.py /home/claude/api_server.py
6464
COPY telegram_bot.py /home/claude/telegram_bot.py
65+
COPY jsonverbose.py /home/claude/jsonverbose.py
6566
RUN chmod +x /home/claude/entrypoint.sh
6667

6768
ENTRYPOINT ["/home/claude/entrypoint.sh"]

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ Pass a prompt and get a response. `-p` is added automatically. No TTY, works fro
238238
```bash
239239
claude "explain this codebase" # plain text (default)
240240
claude "explain this codebase" --output-format json # JSON response
241+
claude "list all TODOs" --output-format json-verbose | jq . # JSON with full tool call history
241242
claude "list all TODOs" --output-format stream-json | jq . # streaming NDJSON
242243
claude "explain this codebase" --model opus # pick your model
243244
claude "review this" --system-prompt "You are a security auditor" # custom system prompt
@@ -298,6 +299,41 @@ You can also pin specific versions with full model names (`claude-opus-4-6`, `cl
298299
}
299300
```
300301

302+
**`json-verbose`** — single JSON object like `json`, but with a `turns` array showing every tool call, tool result, and assistant message. Under the hood it runs `stream-json` and assembles the events into one response. Best of both worlds — one object to parse, full visibility into what Claude did:
303+
304+
```json
305+
{
306+
"type": "result",
307+
"subtype": "success",
308+
"result": "The hostname is mothership.",
309+
"turns": [
310+
{
311+
"role": "assistant",
312+
"content": [
313+
{ "type": "tool_use", "id": "toolu_abc", "name": "Bash", "input": { "command": "hostname" } }
314+
]
315+
},
316+
{
317+
"role": "tool_result",
318+
"content": [
319+
{ "type": "tool_result", "tool_use_id": "toolu_abc", "is_error": false, "content": "mothership" }
320+
]
321+
},
322+
{
323+
"role": "assistant",
324+
"content": [
325+
{ "type": "text", "text": "The hostname is mothership." }
326+
]
327+
}
328+
],
329+
"system": { "session_id": "...", "model": "claude-opus-4-6", "cwd": "/workspace", "tools": ["Bash", "Read", ...] },
330+
"numTurns": 2,
331+
"durationMs": 10600,
332+
"totalCostUsd": 0.049,
333+
"sessionId": "..."
334+
}
335+
```
336+
301337
**`stream-json`** — NDJSON stream, one event per line. Event types: `system` (init), `assistant` (text/tool_use), `user` (tool results), `rate_limit_event`, `result` (final summary with cost). A typical multi-step run: `system` → (`assistant``user`) × N → `result`.
302338

303339
<details>

jsonverbose.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env python3
2+
"""Assemble stream-json JSONL into a single json-verbose response.
3+
4+
Reads JSONL from stdin (claude --output-format stream-json --verbose),
5+
collects all events into a turns array, and outputs a single JSON object
6+
that combines the final result with the full conversation history.
7+
"""
8+
9+
import json
10+
import sys
11+
12+
13+
def _extract_tool_uses(content):
14+
"""Extract tool_use entries from assistant message content."""
15+
out = []
16+
for block in content:
17+
if block.get("type") != "tool_use":
18+
continue
19+
entry = {
20+
"type": "tool_use",
21+
"id": block["id"],
22+
"name": block["name"],
23+
"input": block.get("input", {}),
24+
}
25+
out.append(entry)
26+
return out
27+
28+
29+
def _extract_text(content):
30+
"""Extract text blocks from assistant message content."""
31+
out = []
32+
for block in content:
33+
if block.get("type") != "text":
34+
continue
35+
out.append({"type": "text", "text": block["text"]})
36+
return out
37+
38+
39+
def _extract_tool_results(content):
40+
"""Extract tool_result entries from user message content."""
41+
out = []
42+
for block in content:
43+
if block.get("type") != "tool_result":
44+
continue
45+
entry = {
46+
"type": "tool_result",
47+
"tool_use_id": block.get("tool_use_id", ""),
48+
"is_error": block.get("is_error", False),
49+
}
50+
raw = block.get("content", "")
51+
if isinstance(raw, str):
52+
entry["content"] = raw
53+
elif isinstance(raw, list):
54+
# content can be a list of blocks
55+
texts = [b.get("text", "") for b in raw if b.get("type") == "text"]
56+
entry["content"] = "\n".join(texts) if texts else str(raw)
57+
else:
58+
entry["content"] = str(raw)
59+
out.append(entry)
60+
return out
61+
62+
63+
def assemble(lines):
64+
"""Parse JSONL lines and return assembled json-verbose dict."""
65+
turns = []
66+
result = None
67+
system_init = None
68+
69+
for line in lines:
70+
line = line.strip()
71+
if not line:
72+
continue
73+
try:
74+
event = json.loads(line)
75+
except json.JSONDecodeError:
76+
continue
77+
78+
etype = event.get("type", "")
79+
80+
if etype == "system" and event.get("subtype") == "init":
81+
system_init = {
82+
"session_id": event.get("session_id", ""),
83+
"model": event.get("model", ""),
84+
"cwd": event.get("cwd", ""),
85+
"tools": event.get("tools", []),
86+
}
87+
continue
88+
89+
if etype == "assistant":
90+
msg = event.get("message", {})
91+
content = msg.get("content", [])
92+
texts = _extract_text(content)
93+
tool_uses = _extract_tool_uses(content)
94+
parts = texts + tool_uses
95+
if not parts:
96+
continue
97+
turns.append({"role": "assistant", "content": parts})
98+
continue
99+
100+
if etype == "user":
101+
msg = event.get("message", {})
102+
content = msg.get("content", [])
103+
tool_results = _extract_tool_results(content)
104+
if not tool_results:
105+
continue
106+
turns.append({"role": "tool_result", "content": tool_results})
107+
continue
108+
109+
if etype == "result":
110+
result = event
111+
continue
112+
113+
if not result:
114+
return {
115+
"type": "result",
116+
"subtype": "error",
117+
"is_error": True,
118+
"result": "no result event found in stream",
119+
"turns": turns,
120+
}
121+
122+
result["turns"] = turns
123+
if system_init:
124+
result["system"] = system_init
125+
126+
return result
127+
128+
129+
def main():
130+
lines = sys.stdin.readlines()
131+
output = assemble(lines)
132+
json.dump(output, sys.stdout)
133+
sys.stdout.write("\n")
134+
135+
136+
if __name__ == "__main__":
137+
main()

wrapper.sh

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ if [ $# -gt 0 ]; then
121121
HAS_PROMPT=0
122122
HAS_PRINT=0
123123
HAS_NO_CONTINUE=0
124+
JSON_VERBOSE=0
124125
PASS_ARGS=(-p)
125126
EXPECT_VALUE=""
126127
for arg in "$@"; do
@@ -131,7 +132,8 @@ if [ $# -gt 0 ]; then
131132
case "$arg" in
132133
text|json) ;;
133134
stream-json) NEEDS_VERBOSE=1 ;;
134-
*) echo "❌ Invalid output format: $arg (allowed: text, json, stream-json)"; exit 1 ;;
135+
json-verbose) JSON_VERBOSE=1; NEEDS_VERBOSE=1 ;;
136+
*) echo "❌ Invalid output format: $arg (allowed: text, json, json-verbose, stream-json)"; exit 1 ;;
135137
esac
136138
;;
137139
--model|--system-prompt|--append-system-prompt|--json-schema|--effort|--resume) ;;
@@ -158,7 +160,8 @@ if [ $# -gt 0 ]; then
158160
case "$fmt" in
159161
text|json) ;;
160162
stream-json) NEEDS_VERBOSE=1 ;;
161-
*) echo "❌ Invalid output format: $fmt (allowed: text, json, stream-json)"; exit 1 ;;
163+
json-verbose) JSON_VERBOSE=1; NEEDS_VERBOSE=1 ;;
164+
*) echo "❌ Invalid output format: $fmt (allowed: text, json, json-verbose, stream-json)"; exit 1 ;;
162165
esac
163166
PASS_ARGS+=("$arg")
164167
;;
@@ -189,7 +192,20 @@ if [ $# -gt 0 ]; then
189192

190193
if [ "$HAS_PROMPT" = "1" ]; then
191194
[ "$NEEDS_VERBOSE" = "1" ] && PASS_ARGS+=(--verbose)
192-
[ "$HAS_OUTPUT_FORMAT" = "0" ] && PASS_ARGS+=(--output-format text)
195+
if [ "$HAS_OUTPUT_FORMAT" = "0" ]; then
196+
PASS_ARGS+=(--output-format text)
197+
elif [ "$JSON_VERBOSE" = "1" ]; then
198+
# replace json-verbose with stream-json in PASS_ARGS
199+
FIXED_ARGS=()
200+
for a in "${PASS_ARGS[@]}"; do
201+
case "$a" in
202+
json-verbose) FIXED_ARGS+=(stream-json) ;;
203+
--output-format=json-verbose) FIXED_ARGS+=(--output-format=stream-json) ;;
204+
*) FIXED_ARGS+=("$a") ;;
205+
esac
206+
done
207+
PASS_ARGS=("${FIXED_ARGS[@]}")
208+
fi
193209

194210
dbg "PASS_ARGS: ${PASS_ARGS[*]}"
195211

@@ -199,16 +215,28 @@ if [ $# -gt 0 ]; then
199215
prog_rc=0
200216
if ! docker ps -a --format '{{.Names}}' | grep -q "^${prog_name}$"; then
201217
dbg "prog: container does not exist, creating with docker run"
202-
docker run --name "$prog_name" "${DOCKER_ARGS[@]}" -e CLAUDE_CONTAINER_NAME="$prog_name" $CLAUDE_IMAGE "${PASS_ARGS[@]}"
203-
prog_rc=$?
218+
if [ "$JSON_VERBOSE" = "1" ]; then
219+
docker run --name "$prog_name" "${DOCKER_ARGS[@]}" -e CLAUDE_CONTAINER_NAME="$prog_name" $CLAUDE_IMAGE "${PASS_ARGS[@]}" \
220+
| docker run --rm -i --entrypoint python3 $CLAUDE_IMAGE /home/claude/jsonverbose.py
221+
prog_rc=${PIPESTATUS[0]}
222+
else
223+
docker run --name "$prog_name" "${DOCKER_ARGS[@]}" -e CLAUDE_CONTAINER_NAME="$prog_name" $CLAUDE_IMAGE "${PASS_ARGS[@]}"
224+
prog_rc=$?
225+
fi
204226
dbg "prog: docker run exited with $prog_rc"
205227
else
206228
dbg "prog: container exists, writing args file and starting"
207229
printf '%q ' "${PASS_ARGS[@]}" > "$CLAUDE_DIR/.${prog_name}-args"
208230
trap 'rm -f "$CLAUDE_DIR/.${prog_name}-args"' EXIT
209231
dbg "prog: docker start -a $prog_name"
210-
docker start -a "$prog_name"
211-
prog_rc=$?
232+
if [ "$JSON_VERBOSE" = "1" ]; then
233+
docker start -a "$prog_name" \
234+
| docker run --rm -i --entrypoint python3 $CLAUDE_IMAGE /home/claude/jsonverbose.py
235+
prog_rc=${PIPESTATUS[0]}
236+
else
237+
docker start -a "$prog_name"
238+
prog_rc=$?
239+
fi
212240
dbg "prog: docker start exited with $prog_rc"
213241
fi
214242
exit "$prog_rc"

0 commit comments

Comments
 (0)