agent-chat-reader 0.1.0__tar.gz → 0.1.2__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.
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.github/workflows/ci.yml +2 -2
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/PKG-INFO +1 -1
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/pyproject.toml +1 -1
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/__init__.py +1 -1
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/claude.py +15 -16
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/cli.py +57 -16
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/codex.py +4 -3
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/output.py +14 -6
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/tests/test_package.py +62 -22
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/uv.lock +1 -1
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.github/workflows/release.yml +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.gitignore +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.pre-commit-config.yaml +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/.python-version +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/AGENTS.md +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/LICENSE +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/README.md +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/docs/api.md +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/docs/ci-private-submodules.md +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/models.py +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/src/agent_chat_reader/py.typed +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/templates/licenses/Apache-2.0 +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/templates/licenses/MIT +0 -0
- {agent_chat_reader-0.1.0 → agent_chat_reader-0.1.2}/templates/licenses/README.md +0 -0
|
@@ -104,7 +104,7 @@ jobs:
|
|
|
104
104
|
run: python -m pip install --force-reinstall dist/*.whl
|
|
105
105
|
|
|
106
106
|
- name: Smoke test installed package
|
|
107
|
-
run: python -c "import
|
|
107
|
+
run: python -c "import agent_chat_reader; print(agent_chat_reader.__version__)"
|
|
108
108
|
|
|
109
109
|
package-uv:
|
|
110
110
|
name: Package Build (uv)
|
|
@@ -134,4 +134,4 @@ jobs:
|
|
|
134
134
|
run: uv pip install --force-reinstall dist/*.whl
|
|
135
135
|
|
|
136
136
|
- name: Smoke test installed package
|
|
137
|
-
run: uv run python -c "import
|
|
137
|
+
run: uv run python -c "import agent_chat_reader; print(agent_chat_reader.__version__)"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-chat-reader
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Read and search Codex CLI and Claude Code chat history from the terminal.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alik-git/agent-chat-reader
|
|
6
6
|
Project-URL: Repository, https://github.com/alik-git/agent-chat-reader
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
7
8
|
|
|
8
9
|
from agent_chat_reader.models import SessionMeta, Turn
|
|
9
10
|
|
|
@@ -31,23 +32,21 @@ def _session_title(path: Path) -> str:
|
|
|
31
32
|
return last_title
|
|
32
33
|
|
|
33
34
|
|
|
34
|
-
def _extract_user_text(content: str | list) -> str | None:
|
|
35
|
+
def _extract_user_text(content: str | list[Any]) -> str | None:
|
|
35
36
|
"""Extract real user text; return None for tool-result carriers."""
|
|
36
37
|
if isinstance(content, str):
|
|
37
38
|
return content.strip() or None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
text
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return text or None
|
|
50
|
-
return None
|
|
39
|
+
has_tool_result = any(
|
|
40
|
+
b.get("type") == "tool_result" for b in content if isinstance(b, dict)
|
|
41
|
+
)
|
|
42
|
+
if has_tool_result:
|
|
43
|
+
return None
|
|
44
|
+
text = " ".join(
|
|
45
|
+
b.get("text", "")
|
|
46
|
+
for b in content
|
|
47
|
+
if isinstance(b, dict) and b.get("type") == "text"
|
|
48
|
+
).strip()
|
|
49
|
+
return text or None
|
|
51
50
|
|
|
52
51
|
|
|
53
52
|
def list_sessions() -> list[SessionMeta]:
|
|
@@ -142,7 +141,7 @@ def read_turns(
|
|
|
142
141
|
return _apply_tail(turns, tail)
|
|
143
142
|
|
|
144
143
|
|
|
145
|
-
def _format_tool_call(block: dict) -> str:
|
|
144
|
+
def _format_tool_call(block: dict[str, Any]) -> str:
|
|
146
145
|
"""Format a tool_use block as a short bracketed summary."""
|
|
147
146
|
name = block.get("name", "?")
|
|
148
147
|
inp = block.get("input", {})
|
|
@@ -164,4 +163,4 @@ def _apply_tail(turns: list[Turn], tail: int | None) -> list[Turn]:
|
|
|
164
163
|
user_indices = [i for i, t in enumerate(turns) if t.role == "USER"]
|
|
165
164
|
if tail >= len(user_indices):
|
|
166
165
|
return turns
|
|
167
|
-
return turns[user_indices[-tail]:]
|
|
166
|
+
return turns[user_indices[-tail] :]
|
|
@@ -8,7 +8,12 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
from agent_chat_reader import __version__, claude, codex
|
|
10
10
|
from agent_chat_reader.models import SessionMeta
|
|
11
|
-
from agent_chat_reader.output import
|
|
11
|
+
from agent_chat_reader.output import (
|
|
12
|
+
FindHit,
|
|
13
|
+
print_find_result,
|
|
14
|
+
print_session_list,
|
|
15
|
+
print_turn,
|
|
16
|
+
)
|
|
12
17
|
|
|
13
18
|
|
|
14
19
|
def _find_session(session_id: str) -> tuple[Path, str] | None:
|
|
@@ -47,13 +52,18 @@ def cmd_list(
|
|
|
47
52
|
return 0
|
|
48
53
|
|
|
49
54
|
|
|
55
|
+
def _title_key(title: str) -> str:
|
|
56
|
+
"""Normalise a session title for dedup grouping."""
|
|
57
|
+
return title.strip()[:80].lower()
|
|
58
|
+
|
|
59
|
+
|
|
50
60
|
def cmd_find(
|
|
51
61
|
keyword: str,
|
|
52
62
|
*,
|
|
53
63
|
source_filter: str | None,
|
|
54
64
|
include_subagents: bool,
|
|
55
65
|
) -> int:
|
|
56
|
-
"""Search sessions for a keyword."""
|
|
66
|
+
"""Search sessions for a keyword, deduplicating continuation sessions."""
|
|
57
67
|
sessions: list[SessionMeta] = []
|
|
58
68
|
if source_filter != "claude":
|
|
59
69
|
sessions += codex.list_sessions(include_subagents=include_subagents)
|
|
@@ -62,7 +72,10 @@ def cmd_find(
|
|
|
62
72
|
|
|
63
73
|
sessions = sorted(sessions, key=lambda s: s.mtime, reverse=True)
|
|
64
74
|
keyword_lower = keyword.lower()
|
|
65
|
-
|
|
75
|
+
|
|
76
|
+
# Collect hits per session, then group by title to deduplicate continuations.
|
|
77
|
+
# Each group keeps the most-recent session's metadata as the representative.
|
|
78
|
+
groups: dict[str, tuple[SessionMeta, list[FindHit], int]] = {}
|
|
66
79
|
|
|
67
80
|
for s in sessions:
|
|
68
81
|
try:
|
|
@@ -73,17 +86,30 @@ def cmd_find(
|
|
|
73
86
|
except Exception:
|
|
74
87
|
continue
|
|
75
88
|
|
|
76
|
-
hits = [
|
|
77
|
-
(t.role, t.text[:120].replace("\n", " "))
|
|
89
|
+
hits: list[FindHit] = [
|
|
90
|
+
(t.role, t.text[:120].replace("\n", " "), t.timestamp)
|
|
78
91
|
for t in turns
|
|
79
92
|
if keyword_lower in t.text.lower()
|
|
80
93
|
]
|
|
81
|
-
if hits:
|
|
82
|
-
|
|
83
|
-
print_find_result(s, hits)
|
|
94
|
+
if not hits:
|
|
95
|
+
continue
|
|
84
96
|
|
|
85
|
-
|
|
97
|
+
key = _title_key(s.title)
|
|
98
|
+
if key not in groups:
|
|
99
|
+
groups[key] = (s, hits, 1)
|
|
100
|
+
else:
|
|
101
|
+
rep, existing_hits, count = groups[key]
|
|
102
|
+
# Merge hits, keeping the most-recent session as representative.
|
|
103
|
+
rep = s if s.mtime > rep.mtime else rep
|
|
104
|
+
groups[key] = (rep, existing_hits + hits, count + 1)
|
|
105
|
+
|
|
106
|
+
if not groups:
|
|
86
107
|
print(f"No sessions found containing {keyword!r}")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
for rep, hits, count in groups.values():
|
|
111
|
+
print_find_result(rep, hits, merged_count=count)
|
|
112
|
+
|
|
87
113
|
return 0
|
|
88
114
|
|
|
89
115
|
|
|
@@ -118,7 +144,7 @@ def cmd_read(
|
|
|
118
144
|
for turn in turns:
|
|
119
145
|
print_turn(turn)
|
|
120
146
|
|
|
121
|
-
print(f"\n{'─'*60}")
|
|
147
|
+
print(f"\n{'─' * 60}")
|
|
122
148
|
print(f"Total turns: {len(turns)}")
|
|
123
149
|
return 0
|
|
124
150
|
|
|
@@ -130,27 +156,42 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
130
156
|
description="Read and search Codex CLI and Claude Code chat history.",
|
|
131
157
|
)
|
|
132
158
|
p.add_argument(
|
|
133
|
-
"--version",
|
|
159
|
+
"--version",
|
|
160
|
+
action="version",
|
|
161
|
+
version=f"agent-chat-reader {__version__}",
|
|
134
162
|
)
|
|
135
163
|
p.add_argument("session", nargs="?", help="Session ID or file path to read")
|
|
136
164
|
p.add_argument("--list", "-l", action="store_true", help="List recent sessions")
|
|
137
165
|
p.add_argument(
|
|
138
|
-
"--find",
|
|
166
|
+
"--find",
|
|
167
|
+
"-f",
|
|
168
|
+
metavar="KEYWORD",
|
|
169
|
+
help="Search sessions for keyword",
|
|
139
170
|
)
|
|
140
171
|
p.add_argument("--source", choices=["codex", "claude"], help="Filter to one source")
|
|
141
172
|
p.add_argument(
|
|
142
|
-
"--verbose",
|
|
173
|
+
"--verbose",
|
|
174
|
+
"-v",
|
|
175
|
+
action="store_true",
|
|
143
176
|
help="Include tool call summaries (Claude sessions)",
|
|
144
177
|
)
|
|
145
178
|
p.add_argument(
|
|
146
|
-
"--tail",
|
|
179
|
+
"--tail",
|
|
180
|
+
"-n",
|
|
181
|
+
type=int,
|
|
182
|
+
metavar="N",
|
|
183
|
+
help="Show only the last N user turns",
|
|
147
184
|
)
|
|
148
185
|
p.add_argument(
|
|
149
|
-
"--include-subagents",
|
|
186
|
+
"--include-subagents",
|
|
187
|
+
action="store_true",
|
|
150
188
|
help="Include guardian/subagent sessions",
|
|
151
189
|
)
|
|
152
190
|
p.add_argument(
|
|
153
|
-
"--limit",
|
|
191
|
+
"--limit",
|
|
192
|
+
type=int,
|
|
193
|
+
default=40,
|
|
194
|
+
help="Max sessions for --list (default: 40)",
|
|
154
195
|
)
|
|
155
196
|
|
|
156
197
|
args = p.parse_args(argv)
|
|
@@ -21,7 +21,7 @@ def is_subagent(path: Path) -> bool:
|
|
|
21
21
|
if payload.get("thread_source") == "subagent":
|
|
22
22
|
return True
|
|
23
23
|
source = payload.get("source", {})
|
|
24
|
-
return isinstance(source, dict) and "subagent" in source
|
|
24
|
+
return bool(isinstance(source, dict) and "subagent" in source)
|
|
25
25
|
except Exception:
|
|
26
26
|
pass
|
|
27
27
|
return False
|
|
@@ -37,7 +37,8 @@ def _first_user_message(path: Path) -> str:
|
|
|
37
37
|
rec.get("type") == "event_msg"
|
|
38
38
|
and rec.get("payload", {}).get("type") == "user_message"
|
|
39
39
|
):
|
|
40
|
-
|
|
40
|
+
msg: str = rec["payload"].get("message", "")
|
|
41
|
+
return msg.replace("\n", " ")[:80]
|
|
41
42
|
except Exception:
|
|
42
43
|
pass
|
|
43
44
|
return ""
|
|
@@ -148,4 +149,4 @@ def _apply_tail(turns: list[Turn], tail: int | None) -> list[Turn]:
|
|
|
148
149
|
user_indices = [i for i, t in enumerate(turns) if t.role == "USER"]
|
|
149
150
|
if tail >= len(user_indices):
|
|
150
151
|
return turns
|
|
151
|
-
return turns[user_indices[-tail]:]
|
|
152
|
+
return turns[user_indices[-tail] :]
|
|
@@ -10,6 +10,9 @@ from agent_chat_reader.models import SessionMeta, Turn
|
|
|
10
10
|
_MAX_TURN_LEN = 3000
|
|
11
11
|
_WRAP_THRESHOLD = 400
|
|
12
12
|
|
|
13
|
+
# Type alias: (role, snippet, timestamp)
|
|
14
|
+
FindHit = tuple[str, str, str]
|
|
15
|
+
|
|
13
16
|
|
|
14
17
|
def fmt_ts(ts_str: str) -> str:
|
|
15
18
|
"""Format an ISO timestamp to a short local time string."""
|
|
@@ -30,7 +33,7 @@ def fmt_mtime(mtime: float) -> str:
|
|
|
30
33
|
def print_turn(turn: Turn) -> None:
|
|
31
34
|
"""Print a single conversation turn with a header rule."""
|
|
32
35
|
ts_str = f" {fmt_ts(turn.timestamp)}" if turn.timestamp else ""
|
|
33
|
-
print(f"\n{'─'*60}")
|
|
36
|
+
print(f"\n{'─' * 60}")
|
|
34
37
|
print(f"[{turn.role}]{ts_str}")
|
|
35
38
|
print("─" * 60)
|
|
36
39
|
text = turn.text
|
|
@@ -54,16 +57,21 @@ def print_session_list(sessions: list[SessionMeta]) -> None:
|
|
|
54
57
|
|
|
55
58
|
def print_find_result(
|
|
56
59
|
session: SessionMeta,
|
|
57
|
-
hits: list[
|
|
60
|
+
hits: list[FindHit],
|
|
58
61
|
*,
|
|
59
62
|
max_hits: int = 4,
|
|
63
|
+
merged_count: int = 1,
|
|
60
64
|
) -> None:
|
|
61
65
|
"""Print a single session's find results."""
|
|
62
|
-
print(f"\n{'='*70}")
|
|
63
|
-
|
|
66
|
+
print(f"\n{'=' * 70}")
|
|
67
|
+
merged = f" [{merged_count} sessions]" if merged_count > 1 else ""
|
|
68
|
+
print(
|
|
69
|
+
f"[{session.source.upper()}] {session.id} {fmt_mtime(session.mtime)}{merged}"
|
|
70
|
+
)
|
|
64
71
|
if session.title:
|
|
65
72
|
print(f' "{session.title[:60]}"')
|
|
66
|
-
for role, snippet in hits[:max_hits]:
|
|
67
|
-
|
|
73
|
+
for role, snippet, ts in hits[:max_hits]:
|
|
74
|
+
ts_str = f" {fmt_ts(ts)}" if ts else ""
|
|
75
|
+
print(f" [{role}{ts_str}] {snippet!r}")
|
|
68
76
|
if len(hits) > max_hits:
|
|
69
77
|
print(f" ... and {len(hits) - max_hits} more matches")
|
|
@@ -15,6 +15,7 @@ from agent_chat_reader.models import Turn
|
|
|
15
15
|
|
|
16
16
|
# ── Version ───────────────────────────────────────────────────────────────────
|
|
17
17
|
|
|
18
|
+
|
|
18
19
|
def test_version() -> None:
|
|
19
20
|
"""Package version is a non-empty string."""
|
|
20
21
|
assert isinstance(agent_chat_reader.__version__, str)
|
|
@@ -31,6 +32,7 @@ def test_cli_version(capsys: pytest.CaptureFixture[str]) -> None:
|
|
|
31
32
|
|
|
32
33
|
# ── Codex parsing ─────────────────────────────────────────────────────────────
|
|
33
34
|
|
|
35
|
+
|
|
34
36
|
def _write_jsonl(path: Path, records: list[dict]) -> None:
|
|
35
37
|
"""Write a list of dicts as JSONL."""
|
|
36
38
|
with path.open("w") as fh:
|
|
@@ -44,15 +46,30 @@ _SESSION_FILE = "rollout-2026-01-01T00-00-00-019eae7d-da44-7413-8eb7-52d87219b1d
|
|
|
44
46
|
def test_codex_read_turns_basic(tmp_path: Path) -> None:
|
|
45
47
|
"""Extracts user and agent turns from a minimal Codex session."""
|
|
46
48
|
session = tmp_path / _SESSION_FILE
|
|
47
|
-
_write_jsonl(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
_write_jsonl(
|
|
50
|
+
session,
|
|
51
|
+
[
|
|
52
|
+
{"type": "session_meta", "payload": {"thread_source": "user"}},
|
|
53
|
+
{
|
|
54
|
+
"type": "event_msg",
|
|
55
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
56
|
+
"payload": {"type": "user_message", "message": "hello"},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "event_msg",
|
|
60
|
+
"timestamp": "2026-01-01T00:00:01Z",
|
|
61
|
+
"payload": {"type": "agent_message", "message": "hi there"},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"type": "response_item",
|
|
65
|
+
"timestamp": "2026-01-01T00:00:02Z",
|
|
66
|
+
"payload": {
|
|
67
|
+
"role": "assistant",
|
|
68
|
+
"content": [{"type": "text", "text": "ok"}],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
)
|
|
56
73
|
turns = read_turns(session)
|
|
57
74
|
assert turns[0] == Turn("USER", "hello", "2026-01-01T00:00:00Z")
|
|
58
75
|
assert turns[1] == Turn("AGENT", "hi there", "2026-01-01T00:00:01Z")
|
|
@@ -62,14 +79,29 @@ def test_codex_read_turns_basic(tmp_path: Path) -> None:
|
|
|
62
79
|
def test_codex_deduplicates_agent_and_response_item(tmp_path: Path) -> None:
|
|
63
80
|
"""agent_message and response_item with same text are deduplicated."""
|
|
64
81
|
session = tmp_path / _SESSION_FILE
|
|
65
|
-
_write_jsonl(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
_write_jsonl(
|
|
83
|
+
session,
|
|
84
|
+
[
|
|
85
|
+
{
|
|
86
|
+
"type": "event_msg",
|
|
87
|
+
"timestamp": "t1",
|
|
88
|
+
"payload": {"type": "user_message", "message": "hi"},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"type": "event_msg",
|
|
92
|
+
"timestamp": "t2",
|
|
93
|
+
"payload": {"type": "agent_message", "message": "same text"},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"type": "response_item",
|
|
97
|
+
"timestamp": "t3",
|
|
98
|
+
"payload": {
|
|
99
|
+
"role": "assistant",
|
|
100
|
+
"content": [{"type": "text", "text": "same text"}],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
)
|
|
73
105
|
turns = read_turns(session)
|
|
74
106
|
agent_turns = [t for t in turns if t.role == "AGENT"]
|
|
75
107
|
assert len(agent_turns) == 1
|
|
@@ -79,11 +111,17 @@ def test_codex_deduplicates_agent_and_response_item(tmp_path: Path) -> None:
|
|
|
79
111
|
def test_codex_skips_empty_messages(tmp_path: Path) -> None:
|
|
80
112
|
"""Empty user/agent messages are not emitted as turns."""
|
|
81
113
|
session = tmp_path / _SESSION_FILE
|
|
82
|
-
_write_jsonl(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
114
|
+
_write_jsonl(
|
|
115
|
+
session,
|
|
116
|
+
[
|
|
117
|
+
{"type": "event_msg", "payload": {"type": "user_message", "message": " "}},
|
|
118
|
+
{"type": "event_msg", "payload": {"type": "agent_message", "message": ""}},
|
|
119
|
+
{
|
|
120
|
+
"type": "event_msg",
|
|
121
|
+
"payload": {"type": "user_message", "message": "real"},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
)
|
|
87
125
|
turns = read_turns(session)
|
|
88
126
|
assert len(turns) == 1
|
|
89
127
|
assert turns[0].text == "real"
|
|
@@ -118,6 +156,7 @@ def test_apply_tail_none_returns_all() -> None:
|
|
|
118
156
|
|
|
119
157
|
# ── Claude parsing ────────────────────────────────────────────────────────────
|
|
120
158
|
|
|
159
|
+
|
|
121
160
|
def test_claude_extract_user_text_string() -> None:
|
|
122
161
|
"""Plain string content is returned as-is."""
|
|
123
162
|
assert _extract_user_text("hello") == "hello"
|
|
@@ -142,6 +181,7 @@ def test_claude_extract_user_text_text_block() -> None:
|
|
|
142
181
|
|
|
143
182
|
# ── CLI smoke ─────────────────────────────────────────────────────────────────
|
|
144
183
|
|
|
184
|
+
|
|
145
185
|
def test_cli_no_args_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
|
|
146
186
|
"""Calling main() with no args prints help and exits 0."""
|
|
147
187
|
assert main([]) == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|