simulai-cli 0.1.1__tar.gz → 2.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,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: simulai-cli
3
+ Version: 2.1.0
4
+ Summary: SimulAI CLI — persistent AI chat in your terminal. SmartRoute across 20+ models, Naira billing, and import your Claude Code / Codex sessions.
5
+ Author-email: Simul AI <hello@trysimulai.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://trysimulai.com
8
+ Project-URL: Documentation, https://docs.trysimulai.com
9
+ Project-URL: Repository, https://github.com/Gracemansam/simulai_client
10
+ Keywords: ai,llm,cli,nigeria,openai,claude,gemini,claude-code,codex,coding-agent,smartroute
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: typer>=0.9.0
24
+ Requires-Dist: rich>=13.0.0
25
+ Requires-Dist: httpx>=0.24.0
26
+
27
+ # SimulAI CLI v2
28
+
29
+ Persistent AI chat in your terminal. SmartRoute across 20+ models, Naira
30
+ billing, and one-command import of your Claude Code / Codex CLI sessions.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install simulai-cli
36
+ simulai login
37
+ ```
38
+
39
+ ## Core ideas
40
+
41
+ **Your conversation follows you.** Every exchange is saved server-side, so
42
+ a thread started in the terminal continues in the web app and vice versa.
43
+ Inside a repo, `simulai chat` resumes that repo's conversation automatically.
44
+
45
+ **SmartRoute by default, your call any time.** The same router the web
46
+ dashboard uses picks a model per message. `/model anthropic/claude-sonnet-4`
47
+ takes over whenever you want, and the choice persists.
48
+
49
+ **Hit a limit in another tool? Bring the session with you.**
50
+
51
+ ```bash
52
+ cd ~/code/my-api
53
+ simulai import claude-code
54
+ ```
55
+
56
+ Shows *this repo's* sessions from the last two weeks — not a month of every
57
+ project you've touched. Pick one, and it lands in SimulAI as a normal
58
+ conversation you can continue on any model.
59
+
60
+ ## Commands
61
+
62
+ | Command | What it does |
63
+ |---|---|
64
+ | `simulai login` | Log in (unlocks everything) |
65
+ | `simulai code "task"` | **Agentic coding** — reads, greps, edits and tests this repo |
66
+ | `simulai chat` | Resume this directory's conversation |
67
+ | `simulai chat "msg"` | One-shot |
68
+ | `simulai chat -m <model>` | Force a specific model |
69
+ | `simulai chat --web` | Live web search |
70
+ | `simulai chat -f file.py` | Attach a file |
71
+ | `simulai chats` | List conversations |
72
+ | `simulai resume` | Pick one and bind it here |
73
+ | `simulai import claude-code` | Import a Claude Code session from this repo |
74
+ | `simulai import codex` | Same for Codex CLI |
75
+ | `simulai import scan` | Check every supported agent |
76
+ | `simulai import file x.jsonl` | Import an exported transcript |
77
+ | `simulai import link <url>` | Import a ChatGPT/Claude share link |
78
+ | `simulai analyze file.py` | Review a file |
79
+ | `simulai models [query]` | Browse models |
80
+ | `simulai wallet` | Credit balance |
81
+ | `simulai usage` | Credit ledger — what you spent, on what |
82
+ | `simulai config effort max` | Change a default |
83
+
84
+ ## In-chat slash commands
85
+
86
+ `/model` `/models` `/smart` `/effort` `/web` `/mode` `/project` `/new`
87
+ `/title` `/code` `/save` `/file` `/cost` `/usage` `/trim` `/clear` `/exit`
88
+
89
+ Run `/help` for the full list.
@@ -0,0 +1,63 @@
1
+ # SimulAI CLI v2
2
+
3
+ Persistent AI chat in your terminal. SmartRoute across 20+ models, Naira
4
+ billing, and one-command import of your Claude Code / Codex CLI sessions.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install simulai-cli
10
+ simulai login
11
+ ```
12
+
13
+ ## Core ideas
14
+
15
+ **Your conversation follows you.** Every exchange is saved server-side, so
16
+ a thread started in the terminal continues in the web app and vice versa.
17
+ Inside a repo, `simulai chat` resumes that repo's conversation automatically.
18
+
19
+ **SmartRoute by default, your call any time.** The same router the web
20
+ dashboard uses picks a model per message. `/model anthropic/claude-sonnet-4`
21
+ takes over whenever you want, and the choice persists.
22
+
23
+ **Hit a limit in another tool? Bring the session with you.**
24
+
25
+ ```bash
26
+ cd ~/code/my-api
27
+ simulai import claude-code
28
+ ```
29
+
30
+ Shows *this repo's* sessions from the last two weeks — not a month of every
31
+ project you've touched. Pick one, and it lands in SimulAI as a normal
32
+ conversation you can continue on any model.
33
+
34
+ ## Commands
35
+
36
+ | Command | What it does |
37
+ |---|---|
38
+ | `simulai login` | Log in (unlocks everything) |
39
+ | `simulai code "task"` | **Agentic coding** — reads, greps, edits and tests this repo |
40
+ | `simulai chat` | Resume this directory's conversation |
41
+ | `simulai chat "msg"` | One-shot |
42
+ | `simulai chat -m <model>` | Force a specific model |
43
+ | `simulai chat --web` | Live web search |
44
+ | `simulai chat -f file.py` | Attach a file |
45
+ | `simulai chats` | List conversations |
46
+ | `simulai resume` | Pick one and bind it here |
47
+ | `simulai import claude-code` | Import a Claude Code session from this repo |
48
+ | `simulai import codex` | Same for Codex CLI |
49
+ | `simulai import scan` | Check every supported agent |
50
+ | `simulai import file x.jsonl` | Import an exported transcript |
51
+ | `simulai import link <url>` | Import a ChatGPT/Claude share link |
52
+ | `simulai analyze file.py` | Review a file |
53
+ | `simulai models [query]` | Browse models |
54
+ | `simulai wallet` | Credit balance |
55
+ | `simulai usage` | Credit ledger — what you spent, on what |
56
+ | `simulai config effort max` | Change a default |
57
+
58
+ ## In-chat slash commands
59
+
60
+ `/model` `/models` `/smart` `/effort` `/web` `/mode` `/project` `/new`
61
+ `/title` `/code` `/save` `/file` `/cost` `/usage` `/trim` `/clear` `/exit`
62
+
63
+ Run `/help` for the full list.
@@ -1,45 +1,47 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "simulai-cli"
7
- version = "0.1.1"
8
- description = "Simul AINigerian AI gateway CLI. Chat with 20+ models, analyze files, manage your wallet."
9
- readme = "README.md"
10
- license = { text = "MIT" }
11
- authors = [
12
- { name = "Simul AI", email = "hello@trysimulai.com" }
13
- ]
14
- keywords = ["ai", "llm", "cli", "nigeria", "openai", "claude", "gemini"]
15
- classifiers = [
16
- "Development Status :: 3 - Alpha",
17
- "Environment :: Console",
18
- "Intended Audience :: Developers",
19
- "License :: OSI Approved :: MIT License",
20
- "Programming Language :: Python :: 3",
21
- "Programming Language :: Python :: 3.9",
22
- "Programming Language :: Python :: 3.10",
23
- "Programming Language :: Python :: 3.11",
24
- "Programming Language :: Python :: 3.12",
25
- "Topic :: Software Development :: Libraries :: Python Modules",
26
- ]
27
- requires-python = ">=3.9"
28
- dependencies = [
29
- "typer>=0.9.0",
30
- "rich>=13.0.0",
31
- "httpx>=0.24.0",
32
- "keyring>=24.0.0",
33
- ]
34
-
35
- [project.scripts]
36
- simulai = "simulai_cli.main:app"
37
-
38
- [project.urls]
39
- Homepage = "https://trysimulai.com"
40
- Documentation = "https://docs.trysimulai.com"
41
- Repository = "https://github.com/Gracemansam/simulai_client"
42
-
43
- [tool.setuptools.packages.find]
44
- where = ["."]
45
- include = ["simulai_cli*"]
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "simulai-cli"
7
+ version = "2.1.0"
8
+ description = "SimulAI CLIpersistent AI chat in your terminal. SmartRoute across 20+ models, Naira billing, and import your Claude Code / Codex sessions."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [
12
+ { name = "Simul AI", email = "hello@trysimulai.com" }
13
+ ]
14
+ keywords = [
15
+ "ai", "llm", "cli", "nigeria", "openai", "claude", "gemini",
16
+ "claude-code", "codex", "coding-agent", "smartroute",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ ]
30
+ requires-python = ">=3.9"
31
+ dependencies = [
32
+ "typer>=0.9.0",
33
+ "rich>=13.0.0",
34
+ "httpx>=0.24.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ simulai = "simulai_cli.main:app"
39
+
40
+ [project.urls]
41
+ Homepage = "https://trysimulai.com"
42
+ Documentation = "https://docs.trysimulai.com"
43
+ Repository = "https://github.com/Gracemansam/simulai_client"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["."]
47
+ include = ["simulai_cli*"]
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,403 @@
1
+ """
2
+ The `simulai code` agent loop.
3
+
4
+ user prompt
5
+ → POST /v1/code/agent with the tool schema
6
+ → model streams prose and/or tool calls
7
+ → tools execute HERE, with approval for anything destructive
8
+ → results go back as role:"tool" messages
9
+ → repeat until the model stops asking for tools
10
+
11
+ Design decisions that matter for output quality:
12
+
13
+ • LOW TEMPERATURE (0.2, set server-side). Agent work wants determinism.
14
+ • EDITS ARE PREVIEWED AS DIFFS before they touch disk. You approve a diff,
15
+ not a promise.
16
+ • SYNTAX IS CHECKED BEFORE THE WRITE, and a failure is reported back to the
17
+ model as a tool error — so it repairs inside the loop instead of handing
18
+ you code that doesn't parse.
19
+ • A VERIFICATION PASS runs the project's tests after edits land, and
20
+ failures are fed back for up to two repair rounds. This is the single
21
+ biggest quality lever available without changing models.
22
+ • STEP CAP. Runaway loops burn credits; 25 steps is generous for real work
23
+ and cheap to raise.
24
+ """
25
+
26
+ import json
27
+ from typing import List, Optional
28
+
29
+ from rich.console import Console
30
+ from rich.panel import Panel
31
+ from rich.prompt import Prompt
32
+ from rich.syntax import Syntax
33
+
34
+ from . import render
35
+ from .api import ApiError, SimulAIClient
36
+ from .tools import (
37
+ DESTRUCTIVE,
38
+ TOOL_SCHEMA,
39
+ ToolExecutor,
40
+ Workspace,
41
+ detect_test_command,
42
+ git_status,
43
+ is_environment_failure,
44
+ )
45
+
46
+ console = Console()
47
+
48
+ MAX_STEPS = 25
49
+ MAX_REPAIR_ROUNDS = 2
50
+
51
+ SYSTEM_PROMPT = """You are a coding agent working directly in a developer's repository.
52
+
53
+ Rules that matter:
54
+ - Investigate before you edit. Use grep to locate code and read_file with line
55
+ ranges to inspect it. Never guess at file contents or API signatures.
56
+ - Prefer edit_file over write_file for existing files. old_string must match
57
+ the file byte-for-byte and appear exactly once — include surrounding lines
58
+ to disambiguate.
59
+ - Make the smallest change that fixes the problem. Do not reformat, rename,
60
+ or "improve" code you were not asked to touch.
61
+ - If an edit is rejected for syntax or ambiguity, read the file again and fix
62
+ your input. Do not retry the identical call.
63
+ - After changing code, propose a command that verifies it (the test suite, a
64
+ type-checker, or the specific failing test).
65
+ - When you are done, state plainly what you changed and what the user should
66
+ check. Do not claim you ran something you did not run.
67
+
68
+ You cannot see the repository except through your tools. The user sees every
69
+ tool call and approves anything destructive."""
70
+
71
+
72
+ def _tool_signature(name: str, args: dict) -> str:
73
+ """A stable fingerprint, used to catch the model retrying the identical
74
+ failing call — a common and expensive loop."""
75
+ return f"{name}:{json.dumps(args, sort_keys=True)[:400]}"
76
+
77
+
78
+ def run_agent(
79
+ client: SimulAIClient,
80
+ prompt: str,
81
+ root: str,
82
+ model: Optional[str] = None,
83
+ auto_approve: bool = False,
84
+ dry_run: bool = False,
85
+ effort: str = "balanced",
86
+ verify: bool = True,
87
+ ) -> None:
88
+ workspace = Workspace(root)
89
+ executor = ToolExecutor(workspace, auto_approve=auto_approve, dry_run=dry_run)
90
+
91
+ git = git_status(root)
92
+ if git["repo"] and git["dirty"] and not dry_run and not auto_approve:
93
+ console.print(
94
+ f"[yellow]⚠ Uncommitted changes on branch '{git['branch']}'.[/yellow] "
95
+ "[dim]Commit or stash first so you can undo cleanly.[/dim]"
96
+ )
97
+ if Prompt.ask("Continue anyway?", choices=["y", "n"], default="n") == "n":
98
+ return
99
+ if not git["repo"]:
100
+ console.print(
101
+ "[yellow]⚠ Not a git repository — edits cannot be rolled back with git.[/yellow]"
102
+ )
103
+
104
+ messages: List[dict] = [
105
+ {"role": "system", "content": SYSTEM_PROMPT},
106
+ {"role": "user", "content": f"Workspace root: {root}\n\n{prompt}"},
107
+ ]
108
+
109
+ seen_calls: set = set()
110
+ steps = 0
111
+ repair_rounds = 0
112
+ tokens_in = tokens_out = 0
113
+
114
+ while steps < MAX_STEPS:
115
+ steps += 1
116
+ text, tool_calls, usage = _stream_turn(client, messages, model, effort)
117
+ tokens_in += usage.get("input", 0)
118
+ tokens_out += usage.get("output", 0)
119
+
120
+ # NOTE: no markdown re-render here. _stream_turn already printed the
121
+ # text live as it arrived; rendering it again duplicated every final
122
+ # message. Live streaming wins over syntax highlighting in an agent
123
+ # run — the substance is in the diffs, which are highlighted, and
124
+ # prose between tool calls is short.
125
+
126
+ # ── Tool-capability check ────────────────────────────────────────
127
+ # If the very first turn comes back with no tool calls at all, the
128
+ # routed model almost certainly cannot call functions — OpenRouter
129
+ # silently drops the `tools` field for models that do not support
130
+ # it, so the model just answers in prose (usually by asking the user
131
+ # a question it could have answered with grep). The catalog has no
132
+ # supports_tools flag to filter on, so detect it here and say so
133
+ # plainly rather than letting the user think the agent is broken.
134
+ if steps == 1 and not tool_calls and executor.files_changed == []:
135
+ console.print(
136
+ "\n[yellow]⚠ The model made no tool calls.[/yellow] "
137
+ "[dim]It likely doesn't support function calling.[/dim]\n"
138
+ " Retry with a tool-capable model:\n"
139
+ " [bold]simulai models[/bold] then "
140
+ "[bold]simulai code -m <model-id> \"...\"[/bold]\n"
141
+ )
142
+
143
+ if not tool_calls:
144
+ # Model answered in prose — either it's done, or verification
145
+ # found problems worth one more round.
146
+ if verify and executor.files_changed and repair_rounds < MAX_REPAIR_ROUNDS:
147
+ failure = _verify(executor, root)
148
+ if failure:
149
+ repair_rounds += 1
150
+ console.print(
151
+ f"[yellow]Verification failed — asking for a fix "
152
+ f"(round {repair_rounds}/{MAX_REPAIR_ROUNDS})[/yellow]"
153
+ )
154
+ messages.append({"role": "assistant", "content": text or "(done)"})
155
+ messages.append(
156
+ {
157
+ "role": "user",
158
+ "content": (
159
+ "The test suite failed after your changes.\n\n"
160
+ "Fix the SOURCE CODE you changed. Do not:\n"
161
+ "- install packages or modify the environment\n"
162
+ "- add config files, wrappers or scripts\n"
163
+ "- change or delete the test to make it pass\n"
164
+ "If the failure is unrelated to your edit, say so "
165
+ "plainly and stop.\n\n"
166
+ f"{failure}"
167
+ ),
168
+ }
169
+ )
170
+ continue
171
+ break
172
+
173
+ # Persist the assistant's tool-call turn exactly as the API expects.
174
+ messages.append(
175
+ {
176
+ "role": "assistant",
177
+ "content": text or None,
178
+ "tool_calls": [
179
+ {
180
+ "id": call["id"],
181
+ "type": "function",
182
+ "function": {
183
+ "name": call["name"],
184
+ "arguments": call["arguments"],
185
+ },
186
+ }
187
+ for call in tool_calls
188
+ ],
189
+ }
190
+ )
191
+
192
+ for call in tool_calls:
193
+ result_text = _execute(executor, call, seen_calls)
194
+ messages.append(
195
+ {
196
+ "role": "tool",
197
+ "tool_call_id": call["id"],
198
+ "content": result_text,
199
+ }
200
+ )
201
+
202
+ if steps >= MAX_STEPS:
203
+ console.print(f"[yellow]Stopped at the {MAX_STEPS}-step cap.[/yellow]")
204
+
205
+ _summary(executor, tokens_in, tokens_out, git)
206
+
207
+
208
+ def _stream_turn(
209
+ client: SimulAIClient, messages: List[dict], model: Optional[str], effort: str
210
+ ) -> tuple:
211
+ """One request. Returns (text, tool_calls, usage)."""
212
+ text = ""
213
+ tool_calls: List[dict] = []
214
+ usage = {"input": 0, "output": 0}
215
+
216
+ console.print()
217
+ try:
218
+ for event in client.stream_agent(
219
+ messages=messages, tools=TOOL_SCHEMA, model=model, effort=effort
220
+ ):
221
+ kind = event["type"]
222
+ if kind == "text":
223
+ text += event["text"]
224
+ console.print(event["text"], end="")
225
+ elif kind == "tool_calls":
226
+ tool_calls = event["tool_calls"]
227
+ elif kind == "usage":
228
+ usage["input"] += event.get("input", 0)
229
+ usage["output"] += event.get("output", 0)
230
+ elif kind == "done":
231
+ break
232
+ except ApiError as exc:
233
+ render.error(exc.message)
234
+ return "", [], usage
235
+ except KeyboardInterrupt:
236
+ console.print("\n[dim]interrupted[/dim]")
237
+ return text, [], usage
238
+
239
+ console.print()
240
+ return text, tool_calls, usage
241
+
242
+
243
+ def _execute(executor: ToolExecutor, call: dict, seen: set) -> str:
244
+ """Run one tool call, prompting when destructive. Returns model-facing text."""
245
+ name = call["name"]
246
+ try:
247
+ args = json.loads(call["arguments"] or "{}")
248
+ except json.JSONDecodeError:
249
+ return "Error: your tool arguments were not valid JSON. Resend them correctly."
250
+
251
+ signature = _tool_signature(name, args)
252
+ if signature in seen and name in DESTRUCTIVE:
253
+ return (
254
+ "Error: you already made this exact call and it failed. "
255
+ "Read the current file contents before trying again."
256
+ )
257
+ seen.add(signature)
258
+
259
+ try:
260
+ # ── read-only tools: run immediately ──────────────────────────────
261
+ if name == "read_file":
262
+ result = executor.read_file(
263
+ args.get("path", ""), args.get("start_line"), args.get("end_line")
264
+ )
265
+ elif name == "list_files":
266
+ result = executor.list_files(args.get("path", "."), args.get("depth", 2))
267
+ elif name == "grep":
268
+ result = executor.grep(
269
+ args.get("pattern", ""), args.get("path", "."), args.get("glob")
270
+ )
271
+
272
+ # ── destructive tools: preview, approve, then apply ───────────────
273
+ elif name == "edit_file":
274
+ result, diff = executor.edit_file(
275
+ args.get("path", ""), args.get("old_string", ""), args.get("new_string", "")
276
+ )
277
+ if not result.ok:
278
+ console.print(f" [red]✗ {name} {args.get('path','')}[/red]")
279
+ return result.output
280
+ if not _approve(f"Edit {args.get('path')}", diff, executor):
281
+ return "The user declined this edit. Ask what they would prefer."
282
+ result = executor.apply_edit(
283
+ args.get("path", ""), args.get("old_string", ""), args.get("new_string", "")
284
+ )
285
+
286
+ elif name == "write_file":
287
+ result, diff = executor.write_file(args.get("path", ""), args.get("content", ""))
288
+ if not result.ok:
289
+ console.print(f" [red]✗ {name} {args.get('path','')}[/red]")
290
+ return result.output
291
+ if not _approve(f"Write {args.get('path')}", diff, executor):
292
+ return "The user declined this write. Ask what they would prefer."
293
+ result = executor.apply_write(args.get("path", ""), args.get("content", ""))
294
+
295
+ elif name == "run_command":
296
+ command = args.get("command", "")
297
+ reason = args.get("reason", "")
298
+ if not _approve(
299
+ f"Run: {command}" + (f"\n[dim]{reason}[/dim]" if reason else ""),
300
+ None,
301
+ executor,
302
+ ):
303
+ return "The user declined to run this command."
304
+ result = executor.run_command(command, reason)
305
+
306
+ else:
307
+ return f"Error: unknown tool '{name}'."
308
+
309
+ except PermissionError as exc:
310
+ console.print(f" [red]✗ {exc}[/red]")
311
+ return f"Error: {exc}"
312
+ except Exception as exc:
313
+ console.print(f" [red]✗ {name} failed: {exc}[/red]")
314
+ return f"Error running {name}: {exc}"
315
+
316
+ marker = "[green]✓[/green]" if result.ok else "[red]✗[/red]"
317
+ console.print(f" {marker} [dim]{result.display or name}[/dim]")
318
+ return result.output or ("Done." if result.ok else "Failed.")
319
+
320
+
321
+ def _approve(title: str, diff: Optional[str], executor: ToolExecutor) -> bool:
322
+ if executor.auto_approve or executor.dry_run:
323
+ return True
324
+ console.print()
325
+ if diff:
326
+ console.print(
327
+ Panel(
328
+ Syntax(diff, "diff", theme="monokai", word_wrap=True),
329
+ title=title,
330
+ border_style="yellow",
331
+ )
332
+ )
333
+ else:
334
+ console.print(Panel(title, border_style="yellow"))
335
+ answer = Prompt.ask("Apply?", choices=["y", "n", "a"], default="y")
336
+ if answer == "a":
337
+ executor.auto_approve = True
338
+ console.print("[dim]Auto-approving the rest of this run.[/dim]")
339
+ return True
340
+ return answer == "y"
341
+
342
+
343
+ def _verify(executor: ToolExecutor, root: str) -> Optional[str]:
344
+ """Run the project's tests. → failure text, or None if clean/unrunnable.
345
+
346
+ Three guards learned from a real run that went wrong:
347
+
348
+ 1. If the model ALREADY ran the tests during the loop, don't run them
349
+ again. Re-running duplicates a billed round trip and, worse, can
350
+ contradict what the model just concluded.
351
+ 2. If the command cannot RUN (not installed, not on PATH), that is an
352
+ environment problem, not a code problem. Report it and stop — do not
353
+ hand it to the model as a failure to fix.
354
+ 3. Only genuine test failures trigger a repair round.
355
+ """
356
+ command = detect_test_command(root)
357
+ if not command:
358
+ console.print(
359
+ "[dim]No test command detected — skipping verification. "
360
+ "Review the diff yourself.[/dim]"
361
+ )
362
+ return None
363
+
364
+ already = [c for c in executor.commands_run if "pytest" in c or "test" in c]
365
+ if already:
366
+ console.print(
367
+ f"[dim]Tests already run during the session ({already[-1][:60]}) "
368
+ "— skipping duplicate verification.[/dim]"
369
+ )
370
+ return None
371
+
372
+ console.print(f"\n[dim]Verifying with: {command}[/dim]")
373
+ result = executor.run_command(command, "post-edit verification")
374
+
375
+ if result.ok:
376
+ console.print(" [green]✓ tests pass[/green]")
377
+ return None
378
+
379
+ if is_environment_failure(result.output, 1):
380
+ console.print(
381
+ " [yellow]⚠ Verification could not run — the test tool isn't "
382
+ "installed or isn't on PATH.[/yellow]\n"
383
+ " [dim]This is an environment issue, not a problem with the "
384
+ "edit. Run your tests manually to confirm.[/dim]"
385
+ )
386
+ return None
387
+
388
+ console.print(" [red]✗ tests fail[/red]")
389
+ return result.output
390
+
391
+
392
+ def _summary(executor: ToolExecutor, tokens_in: int, tokens_out: int, git: dict) -> None:
393
+ console.print()
394
+ if executor.files_changed:
395
+ console.print("[bold]Files changed:[/bold]")
396
+ for path in executor.files_changed:
397
+ console.print(f" • {path}")
398
+ if git["repo"]:
399
+ console.print("\n[dim]Review with: git diff · Undo with: git checkout -- .[/dim]")
400
+ else:
401
+ console.print("[dim]No files were modified.[/dim]")
402
+ if tokens_in or tokens_out:
403
+ console.print(f"[dim]{tokens_in} in / {tokens_out} out tokens this run[/dim]")